Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
- `stacks/lint` and `stacks/test` are deliberate near-verbatim copies of the upstream extensions, kept close to upstream so changes can be synced by diffing. Do NOT deduplicate or refactor across the two stacks — the duplication is the point; consolidation is a later, explicit phase.
- The copies diverge from upstream in exactly nine ways (the "adaptations" below). When syncing upstream, preserve them. A tenth divergence is either a bug or must be added to this list.
- **Tracked upstream state.** `stacks/lint` is synced to web-infra-dev/rslint `packages/vscode-extension` at **39536fd6** (#1617 — per-document core resolution, `CoreResolver` + `RuntimeManager`, `corePath`, PnP removed) and **892482e0** (#1630 — `configPath` on `rslint/configRefresh`). `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do.
- **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts.

## The nine adaptations

Expand Down
51 changes: 32 additions & 19 deletions packages/vscode/src/stacks/lint/Rslint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ import {
type WorkspaceDocumentRouter,
} from './WorkspaceDocumentRouter';

/**
* Bound for each wait inside `close()`. Covers a warm worker boot (~1s), and
* is what a changed-key replacement or deactivation pays, worst case, for a
* hung start (the superseded close is awaited on the per-document tail).
*/
const CLOSE_SETTLEMENT_TIMEOUT_MS = 2_000;

const LOCKFILE_NAMES = [
'package-lock.json',
'pnpm-lock.yaml',
Expand Down Expand Up @@ -362,13 +369,14 @@ export class Rslint implements Disposable {
await this.startPromise;
}

private isPlannedStartAbort(error: unknown): boolean {
return (
this.closing || (error instanceof Error && error.name === 'AbortError')
);
}

private reportStartFailure(error: unknown): void {
if (
this.closing ||
(error instanceof Error && error.name === 'AbortError')
) {
return;
}
if (this.isPlannedStartAbort(error)) return;
this.report(statusForRslintStartFailure(error));
}

Expand Down Expand Up @@ -519,7 +527,11 @@ export class Rslint implements Disposable {
this.logger.info('Rslint language client started successfully');
this.reportRunning();
} catch (error: unknown) {
this.logger.error('Failed to start Rslint language client', error);
// A close or supersede during start is a planned abort, not a failure;
// logging it as an error made every teardown race look like a crash.
if (!this.isPlannedStartAbort(error)) {
this.logger.error('Failed to start Rslint language client', error);
}
throw error;
}
}
Expand Down Expand Up @@ -654,6 +666,18 @@ export class Rslint implements Disposable {
this.client = undefined;
const clientStartPromise = this.clientStartPromise;
this.clientStartPromise = undefined;
// Severing the transport under an in-flight initialize makes
// vscode-languageclient force-notify ("couldn't create connection to
// server" / "Server initialization failed"), so a Starting client gets a
// bounded chance to settle first — a successful start then stops cleanly,
// a hung one falls through to the hard teardown.
if (client?.state === State.Starting && clientStartPromise) {
await waitForPromiseSettlement(
clientStartPromise,
CLOSE_SETTLEMENT_TIMEOUT_MS,
'language client start before teardown',
).catch(() => undefined);
}
const clientStopped =
client?.state === State.Starting
? observeClientStopped(client)
Expand All @@ -677,22 +701,11 @@ export class Rslint implements Disposable {
} catch (error) {
clientErrors.push(error);
}
if (clientStartPromise) {
try {
await waitForPromiseSettlement(
clientStartPromise,
2_000,
'language client start',
);
} catch (error) {
clientErrors.push(error);
}
}
if (clientStopped) {
try {
await waitForPromiseSettlement(
clientStopped.promise,
2_000,
CLOSE_SETTLEMENT_TIMEOUT_MS,
'language client terminal state',
);
} catch (error) {
Expand Down
87 changes: 69 additions & 18 deletions packages/vscode/src/stacks/lint/RuntimeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
// - The extra hooks (`onDocumentFailure` / `onDocumentSettled` /
// `onRuntimeClosed`) exist only so the controller can keep its per-folder
// status fold in step; they carry no lifecycle decisions.
// - One ahead-of-upstream fix: `reconcile` resolves before sweeping pending
// uses (`planDocumentCore`) — see AGENTS.md ("Ahead of upstream").

import { workspace, type TextDocument, type WorkspaceFolder } from 'vscode';
import type {
Expand Down Expand Up @@ -77,6 +79,21 @@ export interface RuntimeManagerOptions {
readonly onRuntimeClosed?: (resolved: ResolvedCoreRuntime) => void;
}

/** What one reconcile decided for its document, shared by the pending-use
* sweep and the queued operation (see `planDocumentCore`). */
type DocumentCorePlan =
| { readonly action: 'detach' }
| {
readonly action: 'bind';
readonly workspaceFolder: WorkspaceFolder;
readonly resolved: ResolvedCoreRuntime;
}
| {
readonly action: 'report';
readonly workspaceFolder: WorkspaceFolder;
readonly error: unknown;
};

interface RuntimeEntry {
readonly resolved: ResolvedCoreRuntime;
readonly runtime: ManagedRslintRuntime;
Expand Down Expand Up @@ -159,10 +176,23 @@ export class RuntimeManager {
if (this.closing) return;
const key = documentKey(document);
const epoch = this.nextDocumentEpoch(key);
this.releasePendingDocumentUses(key);
// Resolve before touching pending uses: a reconcile landing on the key a
// pending start is already producing must adopt that start, not tear it
// down mid-initialize — vscode-languageclient force-notifies ("couldn't
// create connection to server") when its in-flight initialize is severed,
// and the register-time pass, a detection change and `onDidOpen` routinely
// land inside one worker startup window. A key change still cancels the
// pending start immediately, so a hung start cannot block the tail
// (assumes the resolver settles — a bounded fs walk).
const plan = await this.planDocumentCore(document);
if (!this.isCurrentDocument(document, epoch)) return;
this.releasePendingDocumentUses(
key,
plan.action === 'bind' ? plan.resolved.key : undefined,
);
await this.enqueueDocument(key, async () => {
if (!this.isCurrentDocument(document, epoch)) return;
await this.reconcileCurrentDocument(document, epoch);
await this.reconcileCurrentDocument(document, epoch, plan);
});
}

Expand All @@ -186,12 +216,14 @@ export class RuntimeManager {
await (this.closePromise ??= this.closeImpl());
}

private async reconcileCurrentDocument(
/**
* The document's target, decided before the per-document tail is entered so
* `reconcile` can spare a pending same-key start from the pending-use sweep;
* the queued operation consumes the same plan so the two never disagree.
*/
private async planDocumentCore(
document: TextDocument,
epoch: number,
): Promise<void> {
const key = documentKey(document);
const existing = this.bindings.get(key);
): Promise<DocumentCorePlan> {
const workspaceFolder = workspace.getWorkspaceFolder(document.uri);
const mode = workspaceFolder
? this.options.folderMode(workspaceFolder)
Expand All @@ -201,27 +233,39 @@ export class RuntimeManager {
!workspaceFolder ||
mode === undefined
) {
await this.detachDocument(document);
return;
return { action: 'detach' };
}
const configuration = workspace.getConfiguration(
'rstack.rslint',
document.uri,
);

let resolved: ResolvedCoreRuntime;
try {
resolved = await this.resolver.resolve(document, workspaceFolder, {
const resolved = await this.resolver.resolve(document, workspaceFolder, {
mode,
corePath: configuration.get<string>('corePath'),
});
return { action: 'bind', workspaceFolder, resolved };
} catch (error) {
if (this.isCurrentDocument(document, epoch)) {
this.reportFailure(document, workspaceFolder, error, existing);
}
return { action: 'report', workspaceFolder, error };
}
}

private async reconcileCurrentDocument(
document: TextDocument,
epoch: number,
plan: DocumentCorePlan,
): Promise<void> {
const key = documentKey(document);
const existing = this.bindings.get(key);
if (plan.action === 'detach') {
await this.detachDocument(document);
return;
}
if (!this.isCurrentDocument(document, epoch)) return;
if (plan.action === 'report') {
this.reportFailure(document, plan.workspaceFolder, plan.error, existing);
return;
}
const { workspaceFolder, resolved } = plan;
if (existing?.resolved.key === resolved.key) {
this.options.onDocumentSettled?.(document);
return;
Expand All @@ -233,7 +277,9 @@ export class RuntimeManager {
replacement = this.acquireRuntime(resolved, key);
await replacement.startPromise;
if (!this.isCurrentDocument(document, epoch)) {
await this.releaseRuntimeAfterFailure(replacement, key);
// A newer reconcile owns this document; its sweep already decided this
// entry's fate (kept for same-key adoption, released otherwise).
// Releasing again would tear down what the successor is binding.
return;
}
await this.router.assign(document, resolved.key);
Expand Down Expand Up @@ -382,10 +428,15 @@ export class RuntimeManager {
}
}

private releasePendingDocumentUses(documentUri: string): void {
private releasePendingDocumentUses(
documentUri: string,
keepKey?: string,
): void {
const bound = this.bindings.get(documentUri);
for (const entry of [...this.entries.values()]) {
if (entry === bound || !entry.users.has(documentUri)) continue;
// Same key as the sweep's plan: adopt the pending start, don't restart.
if (entry.resolved.key === keepKey) continue;
void this.releaseRuntime(entry, documentUri).catch((error: unknown) => {
this.logger.error(
`Failed to cancel pending Rslint core ${entry.resolved.installation.packageDirectory}`,
Expand Down
Loading