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
57 changes: 46 additions & 11 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ export class SessionManager implements Middleware {
this.versionDetails = await this.getVersionDetails();
if (this.versionDetails === undefined) {
void this.setSessionFailedOpenBug(
"Unable to get version details!",
"PowerShell started but didn't report its version in time, so the session couldn't finish starting up!",
);
return;
}
Expand Down Expand Up @@ -849,14 +849,31 @@ export class SessionManager implements Middleware {
): Promise<LanguageClient> {
this.logger.writeDebug("Connecting to language service...");
const connectFunc = (): Promise<StreamInfo> => {
return new Promise<StreamInfo>((resolve, _reject) => {
const socket = net.connect(
sessionDetails.languageServicePipeName,
);
return new Promise<StreamInfo>((resolve, reject) => {
const pipeName = sessionDetails.languageServicePipeName;
const socket = net.connect(pipeName);

// Reject (rather than hang forever) if the transport never
// connects, e.g. a stalled named pipe or socket.
const timeout = setTimeout(() => {
const message = `Timed out connecting to language service at '${pipeName}'!`;
this.logger.writeError(message);
socket.destroy();
reject(new Error(message));
}, 60 * 1000);

socket.on("connect", () => {
clearTimeout(timeout);
this.logger.writeDebug("Language service connected.");
resolve({ writer: socket, reader: socket });
});

socket.on("error", (error) => {
clearTimeout(timeout);
const message = `Error connecting to language service at '${pipeName}': ${error.message}`;
this.logger.writeError(message);
reject(error);
});
});
};

Expand Down Expand Up @@ -1070,16 +1087,34 @@ Type 'help' to get help.
private async getVersionDetails(): Promise<
IPowerShellVersionDetails | undefined
> {
// Take one minute to get version details, otherwise cancel and fail.
// Take one minute to get version details, otherwise cancel and fail
// gracefully by returning undefined rather than throwing out of
// start(). The caller handles undefined by surfacing the startup
// failure to the user.
const timeout = new vscode.CancellationTokenSource();
setTimeout(() => {
const timer = setTimeout(() => {
timeout.cancel();
}, 60 * 1000);

const versionDetails = await this.languageClient?.sendRequest(
PowerShellVersionRequestType,
timeout.token,
);
let versionDetails: IPowerShellVersionDetails | undefined;
try {
versionDetails = await this.languageClient?.sendRequest(
PowerShellVersionRequestType,
timeout.token,
);
} catch (err) {
// A cancelled token (our timeout elapsed) or any other transport
// failure rejects the request. Swallow it and return undefined so
// the caller's failure handling runs instead of throwing. We log
// the underlying cause here since the caller's error won't include
// it; the caller surfaces the user-facing message.
this.logger.writeWarning(
`The PowerShell version request did not complete: ${err}`,
);
} finally {
clearTimeout(timer);
timeout.dispose();
}

// This is pretty much the only telemetry event we care about.
// TODO: We actually could send this earlier from PSES itself.
Expand Down
98 changes: 97 additions & 1 deletion test/core/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import * as assert from "assert";
import Sinon from "sinon";
import * as vscode from "vscode";
import type { DocumentSelector } from "vscode-languageclient";
import { SessionManager } from "../../src/session";
import {
type IPowerShellVersionDetails,
SessionManager,
} from "../../src/session";
import { stubInterface, testLogger } from "../utils";

describe("SessionManager middleware", () => {
Expand Down Expand Up @@ -163,6 +166,99 @@ describe("SessionManager middleware", () => {
});
});

describe("SessionManager.getVersionDetails", () => {
afterEach(() => {
Sinon.restore();
});

it("returns undefined instead of throwing when the request rejects", async () => {
const manager = makeManager();
// Simulate the request rejecting, as it does when our timeout cancels
// the token. This must not throw out of getVersionDetails().
setSendRequest(manager, () => Promise.reject(new Error("Canceled")));

const result = await getVersionDetails(manager);
assert.strictEqual(
result,
undefined,
"a rejected (e.g. timed-out/cancelled) request should resolve to undefined",
);
});

it("returns the details on the normal fast path", async () => {
const manager = makeManager();
const details: IPowerShellVersionDetails = {
version: "7.4.0",
edition: "Core",
commit: "7.4.0",
architecture: "X64",
};
setSendRequest(manager, () => Promise.resolve(details));

const result = await getVersionDetails(manager);
assert.strictEqual(result, details);
});
});

function makeManager(): SessionManager {
Sinon.stub(vscode.commands, "registerCommand").returns(disposableStub());
Sinon.stub(vscode.workspace, "onDidChangeConfiguration").returns(
disposableStub(),
);
Sinon.stub(vscode.languages, "createLanguageStatusItem").returns(
stubInterface<vscode.LanguageStatusItem>({
text: "",
detail: "",
busy: false,
severity: vscode.LanguageStatusSeverity.Information,
dispose: () => {
return;
},
}),
);

return new SessionManager(
stubInterface<vscode.ExtensionContext>({
globalStorageUri: vscode.Uri.file("C:/tmp"),
extensionMode: vscode.ExtensionMode.Test,
subscriptions: [],
logUri: vscode.Uri.file("C:/tmp"),
}),
testLogger,
["powershell"] as DocumentSelector,
"Visual Studio Code",
"PowerShell",
"2026.5.0",
"ms-vscode",
stubInterface<TelemetryReporter>(),
);
}

function setSendRequest(
manager: SessionManager,
sendRequest: () => Promise<IPowerShellVersionDetails | undefined>,
): void {
(
manager as unknown as {
languageClient: {
sendRequest: () => Promise<
IPowerShellVersionDetails | undefined
>;
};
}
).languageClient = { sendRequest };
}

function getVersionDetails(
manager: SessionManager,
): Promise<IPowerShellVersionDetails | undefined> {
return (
manager as unknown as {
getVersionDetails(): Promise<IPowerShellVersionDetails | undefined>;
}
).getVersionDetails();
}

function disposableStub(): vscode.Disposable {
return {
dispose: (): void => {
Expand Down
Loading