Skip to content

Commit c1bd627

Browse files
committed
TML-2984: surface config-load failures and retain the last-good project
A failed project load publishes one diagnostic on the config-file URI at document start (push channel unconditionally -- the config file belongs to the TypeScript language service, so pull never reaches us), cleared on the next successful load or when the project is dropped for document-lifecycle reasons. The loading entry now carries the previous ProjectState: a broken reload restores it so open schema documents keep their parse, symbol-table, and interpreter diagnostics, while a failed first load keeps todays no-project behavior. Superseded loads stay silent. Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
1 parent 2c32994 commit c1bd627

2 files changed

Lines changed: 264 additions & 31 deletions

File tree

packages/1-framework/3-tooling/language-server/src/server.ts

Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fileURLToPath } from 'node:url';
1+
import { fileURLToPath, pathToFileURL } from 'node:url';
22
import { findNearestConfigPathForFile } from '@prisma-next/config-loader';
33
import type { SymbolTable } from '@prisma-next/psl-parser';
44
import { type FormatOptions, format } from '@prisma-next/psl-parser/format';
@@ -75,20 +75,25 @@ type ManagedProject =
7575
readonly status: 'loading';
7676
readonly load: Promise<ProjectState>;
7777
/**
78-
* Whether a loaded project existed when this load (chain) began — a
79-
* failed reload must still clear the markers push clients were shown,
80-
* while a failed first load must not publish anything.
78+
* The project that was loaded when this load (chain) began. A failed
79+
* reload restores it — a broken config edit must not destroy a working
80+
* project — while a failed first load (no last-good) publishes nothing
81+
* and leaves documents unmanaged.
8182
*/
82-
readonly hadLoadedProject: boolean;
83+
readonly lastGood: ProjectState | undefined;
8384
}
8485
| { readonly status: 'loaded'; readonly project: ProjectState };
8586

87+
const CONFIG_LOAD_FAILED_CODE = 'PRISMA_NEXT_CONFIG_LOAD_FAILED';
88+
8689
const semanticTokenSourceLimit = 100_000;
8790

8891
export function createServer(connection: Connection): LanguageServer {
8992
const documents = new TextDocuments(TextDocument);
9093
const managedProjects = new Map<string, ManagedProject>();
9194
const documentConfigPaths = new Map<string, string>();
95+
// Config-file URIs with a currently published load-failure diagnostic.
96+
const configFailureUris = new Map<string, string>();
9297
let rootPath = process.cwd();
9398
let watchedConfigGlob = join(rootPath, '**', CONFIG_FILENAME);
9499
let clientCapabilities = noClientCapabilities;
@@ -217,34 +222,78 @@ export function createServer(connection: Connection): LanguageServer {
217222

218223
// A load replaces the entry with `loading` immediately, so reads during a
219224
// config reload await the fresh resolution instead of the pre-reload
220-
// project. A failed load leaves its entry in place — every awaiter funnels
221-
// the failure into `stopManagingProject`, which needs the entry to decide
222-
// whether push clears are owed.
225+
// project. A failed first load leaves its entry in place — every awaiter
226+
// funnels the failure into `stopManagingProject` — while a failed reload
227+
// restores the last-good project so open documents keep being served.
223228
function startProjectLoad(configPath: string): Promise<ProjectState> {
224229
const existing = managedProjects.get(configPath);
225230
const previousLoad = existing?.status === 'loading' ? existing.load : undefined;
226-
const hadLoadedProject =
227-
existing?.status === 'loaded' ||
228-
(existing?.status === 'loading' && existing.hadLoadedProject);
231+
const lastGood =
232+
existing?.status === 'loaded'
233+
? existing.project
234+
: existing?.status === 'loading'
235+
? existing.lastGood
236+
: undefined;
229237
const load: Promise<ProjectState> = (previousLoad ?? Promise.resolve(undefined))
230238
.catch(() => undefined)
231239
.then(() => loadProject(configPath))
232-
.then((project) => {
233-
// A load that outlives the last association must not keep a project
234-
// entry alive.
235-
if (isCurrentLoad(configPath, load)) {
236-
if (hasManagedDocuments(configPath)) {
237-
managedProjects.set(configPath, { status: 'loaded', project });
238-
} else {
239-
managedProjects.delete(configPath);
240+
.then(
241+
(project) => {
242+
// A load that outlives the last association must not keep a project
243+
// entry alive.
244+
if (isCurrentLoad(configPath, load)) {
245+
if (hasManagedDocuments(configPath)) {
246+
managedProjects.set(configPath, { status: 'loaded', project });
247+
} else {
248+
managedProjects.delete(configPath);
249+
}
250+
clearConfigFailure(configPath);
240251
}
241-
}
242-
return project;
243-
});
244-
managedProjects.set(configPath, { status: 'loading', load, hadLoadedProject });
252+
return project;
253+
},
254+
(error: unknown) => {
255+
// Only the current load's outcome speaks; superseded failures stay
256+
// silent.
257+
if (isCurrentLoad(configPath, load)) {
258+
publishConfigFailure(configPath, error);
259+
if (lastGood !== undefined) {
260+
managedProjects.set(configPath, { status: 'loaded', project: lastGood });
261+
return lastGood;
262+
}
263+
}
264+
throw error;
265+
},
266+
);
267+
managedProjects.set(configPath, { status: 'loading', load, lastGood });
245268
return load;
246269
}
247270

271+
function publishConfigFailure(configPath: string, error: unknown): void {
272+
const uri = pathToFileURL(configPath).toString();
273+
configFailureUris.set(configPath, uri);
274+
sendDiagnostics({
275+
uri,
276+
diagnostics: [
277+
{
278+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
279+
message: error instanceof Error ? error.message : String(error),
280+
code: CONFIG_LOAD_FAILED_CODE,
281+
severity: DiagnosticSeverity.Error,
282+
source: 'prisma-next',
283+
},
284+
],
285+
});
286+
}
287+
288+
function clearConfigFailure(configPath: string): void {
289+
const uri = configFailureUris.get(configPath);
290+
if (uri === undefined) {
291+
return;
292+
}
293+
configFailureUris.delete(configPath);
294+
sendDiagnostics({ uri, diagnostics: [] });
295+
}
296+
248297
function isCurrentLoad(configPath: string, load: Promise<ProjectState>): boolean {
249298
const entry = managedProjects.get(configPath);
250299
return entry?.status === 'loading' && entry.load === load;
@@ -276,10 +325,14 @@ export function createServer(connection: Connection): LanguageServer {
276325
return project;
277326
}
278327

328+
// Callers are the failed-load funnels — the config-failure marker must
329+
// survive this drop so a broken config stays visible; it clears on the
330+
// next successful load or when the config's project is dropped for
331+
// document-lifecycle reasons (dropProjectWithoutManagedDocuments).
279332
function stopManagingProject(configPath: string): void {
280333
const entry = managedProjects.get(configPath);
281334
const hadProject =
282-
entry?.status === 'loaded' || (entry?.status === 'loading' && entry.hadLoadedProject);
335+
entry?.status === 'loaded' || (entry?.status === 'loading' && entry.lastGood !== undefined);
283336
managedProjects.delete(configPath);
284337
for (const document of documents.all()) {
285338
if (documentConfigPaths.get(document.uri) === configPath) {
@@ -597,6 +650,7 @@ export function createServer(connection: Connection): LanguageServer {
597650
if (managedProjects.get(configPath)?.status === 'loaded') {
598651
managedProjects.delete(configPath);
599652
}
653+
clearConfigFailure(configPath);
600654
}
601655

602656
return {

packages/1-framework/3-tooling/language-server/test/server.test.ts

Lines changed: 186 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1671,23 +1671,26 @@ describe('language server config watching', { timeout: timeouts.databaseOperatio
16711671
expect((await diagnosed).length).toBeGreaterThan(0);
16721672
});
16731673

1674-
it('stops managing a project when a config edit breaks the config', async () => {
1674+
it('keeps serving the last-good project when a config edit breaks the config', async () => {
16751675
const hook = mutableResolve(resolveToSchema);
16761676
harness = startHarness(hook.resolve, watchedFilesCapabilities);
16771677
await harness.initialize();
16781678

16791679
harness.client.sendNotification(DidOpenTextDocumentNotification.type, {
16801680
textDocument: { uri: schemaUri, languageId: 'prisma', version: 1, text: 'model {' },
16811681
});
1682-
expect((await harness.waitForDiagnostics(schemaUri)).length).toBeGreaterThan(0);
1682+
const before = await harness.waitForDiagnostics(schemaUri);
1683+
expect(before.length).toBeGreaterThan(0);
16831684

1684-
const cleared = harness.waitForDiagnosticsMatching(
1685-
schemaUri,
1686-
(diagnostics) => diagnostics.length === 0,
1687-
);
16881685
hook.set(resolveFails);
16891686
harness.notifyConfigChanged();
1690-
expect(await cleared).toEqual([]);
1687+
1688+
// The broken reload surfaces on the config file; the schema keeps its
1689+
// last-good diagnostics instead of being cleared.
1690+
const configDiagnostics = await harness.waitForDiagnostics(configUri);
1691+
expect(configDiagnostics).toHaveLength(1);
1692+
await settle();
1693+
expect(harness.latestDiagnostics(schemaUri)).toEqual(before);
16911694
});
16921695
});
16931696

@@ -2214,3 +2217,179 @@ describe('language server interpreter diagnostics', { timeout: timeouts.database
22142217
expect(spy).toHaveBeenCalledTimes(2);
22152218
});
22162219
});
2220+
2221+
describe('language server config failure surfacing', {
2222+
timeout: timeouts.databaseOperation,
2223+
}, () => {
2224+
const cleanSchema = 'model User {\n id Int @id\n}\n';
2225+
const expectedConfigFailure = (message: string): Diagnostic => ({
2226+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
2227+
message,
2228+
code: 'PRISMA_NEXT_CONFIG_LOAD_FAILED',
2229+
severity: DiagnosticSeverity.Error,
2230+
source: 'prisma-next',
2231+
});
2232+
2233+
function interpretingResolution(): ConfigResolution {
2234+
const source = {
2235+
sourceFormat: 'psl',
2236+
inputs: [schemaPath],
2237+
load: async () => ok({} as never),
2238+
interpret: () =>
2239+
notOk({
2240+
summary: 'Schema has 1 error',
2241+
diagnostics: [{ code: 'PSL_INTERPRETER_FINDING', message: 'finding' }],
2242+
}),
2243+
} as unknown as PslInterpretCapable;
2244+
return {
2245+
...resolutionForInputs([schemaPath]),
2246+
interpretation: { source, context: {} as unknown as ContractSourceContext },
2247+
};
2248+
}
2249+
2250+
it('publishes one config diagnostic on first-load failure and leaves the document unmanaged', async () => {
2251+
harness = startHarness(async () => {
2252+
throw new Error('config exploded');
2253+
});
2254+
await harness.initialize();
2255+
openDocument(harness, schemaUri, cleanSchema);
2256+
2257+
const published = await harness.waitForDiagnostics(configUri);
2258+
expect(published).toEqual([expectedConfigFailure('config exploded')]);
2259+
expect(harness.getDocumentAst(schemaUri)).toBeUndefined();
2260+
2261+
// The marker persists after the failed load settles.
2262+
await settle();
2263+
expect(harness.latestDiagnostics(configUri)).toEqual([
2264+
expectedConfigFailure('config exploded'),
2265+
]);
2266+
});
2267+
2268+
it('publishes the config diagnostic exactly once when two documents await the same load', async () => {
2269+
const otherPath = join(root, 'other.psl');
2270+
const otherUri = pathToFileURL(otherPath).toString();
2271+
const gate = deferredSettleable<ConfigResolution>();
2272+
harness = startHarness(() => gate.promise);
2273+
await harness.initialize();
2274+
openDocument(harness, schemaUri, cleanSchema);
2275+
openDocument(harness, otherUri, cleanSchema);
2276+
// Both open notifications are processed before the shared load settles.
2277+
await settle();
2278+
gate.reject(new Error('config exploded'));
2279+
2280+
await harness.waitForDiagnostics(configUri);
2281+
await settle();
2282+
expect(harness.publishCount(configUri)).toBe(1);
2283+
});
2284+
2285+
it('publishes the config diagnostic even for pull-capable clients', async () => {
2286+
harness = startHarness(async () => {
2287+
throw new Error('config exploded');
2288+
}, pullDiagnosticsCapabilities);
2289+
await harness.initialize();
2290+
openDocument(harness, schemaUri, cleanSchema);
2291+
// Pull clients trigger project resolution through the pull request; the
2292+
// config failure must still arrive on the push channel.
2293+
const report = await requestPullDiagnostics(harness, schemaUri);
2294+
expect(fullReportItems(report)).toEqual([]);
2295+
2296+
const published = await harness.waitForDiagnostics(configUri);
2297+
expect(published).toEqual([expectedConfigFailure('config exploded')]);
2298+
});
2299+
2300+
it('clears the config diagnostic on the next successful load', async () => {
2301+
let failures = 1;
2302+
harness = startHarness(async () => {
2303+
if (failures > 0) {
2304+
failures -= 1;
2305+
throw new Error('config exploded');
2306+
}
2307+
return resolutionForInputs([schemaPath]);
2308+
});
2309+
await harness.initialize();
2310+
openDocument(harness, schemaUri, cleanSchema);
2311+
await harness.waitForDiagnostics(configUri);
2312+
2313+
harness.notifyConfigChanged();
2314+
2315+
await harness.waitForDiagnosticsMatching(configUri, (diagnostics) => diagnostics.length === 0);
2316+
expect(harness.latestDiagnostics(configUri)).toEqual([]);
2317+
});
2318+
2319+
it('keeps serving the last-good project through a broken reload, then swaps in the fix', async () => {
2320+
let mode: 'good' | 'broken' | 'fixed' = 'good';
2321+
harness = startHarness(async () => {
2322+
if (mode === 'broken') {
2323+
throw new Error('config exploded');
2324+
}
2325+
return interpretingResolution();
2326+
}, pullDiagnosticsCapabilities);
2327+
await harness.initialize();
2328+
openDocument(harness, schemaUri, cleanSchema);
2329+
2330+
const before = await requestPullDiagnostics(harness, schemaUri);
2331+
expect(fullReportItems(before).map((d) => d.code)).toContain('PSL_INTERPRETER_FINDING');
2332+
2333+
mode = 'broken';
2334+
harness.notifyConfigChanged();
2335+
const failure = await harness.waitForDiagnostics(configUri);
2336+
expect(failure).toEqual([expectedConfigFailure('config exploded')]);
2337+
2338+
const retained = await requestPullDiagnostics(harness, schemaUri);
2339+
expect(fullReportItems(retained).map((d) => d.code)).toContain('PSL_INTERPRETER_FINDING');
2340+
2341+
mode = 'fixed';
2342+
harness.notifyConfigChanged();
2343+
await harness.waitForDiagnosticsMatching(configUri, (diagnostics) => diagnostics.length === 0);
2344+
2345+
const after = await requestPullDiagnostics(harness, schemaUri);
2346+
expect(fullReportItems(after).map((d) => d.code)).toContain('PSL_INTERPRETER_FINDING');
2347+
});
2348+
2349+
it('clears the config diagnostic when the last managed document closes', async () => {
2350+
// A retained last-good project dropped by its final document close must
2351+
// not leave a zombie config marker behind.
2352+
let broken = false;
2353+
harness = startHarness(async () => {
2354+
if (broken) {
2355+
throw new Error('config exploded');
2356+
}
2357+
return resolutionForInputs([schemaPath]);
2358+
});
2359+
await harness.initialize();
2360+
openDocument(harness, schemaUri, cleanSchema);
2361+
await harness.waitForDiagnostics(schemaUri);
2362+
2363+
broken = true;
2364+
harness.notifyConfigChanged();
2365+
await harness.waitForDiagnostics(configUri);
2366+
2367+
harness.client.sendNotification(DidCloseTextDocumentNotification.type, {
2368+
textDocument: { uri: schemaUri },
2369+
});
2370+
2371+
await harness.waitForDiagnosticsMatching(configUri, (diagnostics) => diagnostics.length === 0);
2372+
});
2373+
2374+
it('stays silent for a superseded load failure', async () => {
2375+
const first = deferredSettleable<ConfigResolution>();
2376+
let call = 0;
2377+
harness = startHarness(() => {
2378+
call += 1;
2379+
return call === 1 ? first.promise : Promise.resolve(resolutionForInputs([schemaPath]));
2380+
});
2381+
await harness.initialize();
2382+
openDocument(harness, schemaUri, cleanSchema);
2383+
2384+
harness.notifyConfigChanged();
2385+
// The watched-config handler swaps the current load synchronously before
2386+
// its first await; settling lets that notification dispatch, so the
2387+
// rejection below lands on a superseded load.
2388+
await settle();
2389+
first.reject(new Error('superseded failure'));
2390+
2391+
await harness.waitForDiagnostics(schemaUri);
2392+
await settle();
2393+
expect(harness.publishCount(configUri)).toBe(0);
2394+
});
2395+
});

0 commit comments

Comments
 (0)