Skip to content

Commit 698ac16

Browse files
committed
fix(apex): defensive LS shutdown to prevent orphaned processes
- Await client.stop() with 3s timeout so LS can gracefully close DB - Force-kill child Apex LS processes only when stop() times out - Do not push client to subscriptions; cleanup only in deactivate() - Dynamic JDWP port (0) to avoid port-in-use when previous session orphaned - Explicit outputChannel dispose, client.dispose(), setClientInstance clear @W-21681520@
1 parent 17543c0 commit 698ac16

4 files changed

Lines changed: 65 additions & 6 deletions

File tree

packages/salesforcedx-vscode-apex/src/index.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import {
2222
import { nls } from './messages';
2323
import { getTelemetryService, setTelemetryService } from './telemetry/telemetry';
2424

25+
/** Time to await graceful LSP shutdown before force-kill. LS uses this to close its internal DB. */
26+
const DEACTIVATE_STOP_TIMEOUT_MS = 3000;
27+
2528
export const activate = async (context: vscode.ExtensionContext) => {
2629
const vscodeCoreExtension = await getVscodeCoreExtension();
2730
const workspaceContext = vscodeCoreExtension.exports.WorkspaceContext.getInstance();
@@ -97,7 +100,23 @@ const registerCommands = (context: vscode.ExtensionContext): vscode.Disposable =
97100
};
98101

99102
export const deactivate = async () => {
100-
await languageClientManager.getClientInstance()?.stop();
103+
const client = languageClientManager.getClientInstance();
104+
if (client) {
105+
let timedOut = false;
106+
const stopPromise = client.stop();
107+
const timeoutPromise = new Promise<void>(resolve =>
108+
setTimeout(() => {
109+
timedOut = true;
110+
resolve();
111+
}, DEACTIVATE_STOP_TIMEOUT_MS)
112+
);
113+
await Promise.race([stopPromise, timeoutPromise]);
114+
if (timedOut) {
115+
languageClientManager.killChildApexProcesses();
116+
}
117+
} else {
118+
languageClientManager.killChildApexProcesses();
119+
}
101120
getTelemetryService().sendExtensionDeactivationEvent();
102121
};
103122

packages/salesforcedx-vscode-apex/src/languageServer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ import {
4141
} from './settings';
4242
import { getTelemetryService } from './telemetry/telemetry';
4343

44-
const JDWP_DEBUG_PORT = 2739;
44+
/** Use 0 for dynamic JDWP port to avoid "address in use" when previous LS orphaned (e.g. Extension Host not shut down cleanly). */
45+
const JDWP_DEBUG_PORT = 0;
4546
const APEX_LANGUAGE_SERVER_MAIN = 'apex.jorje.lsp.ApexLanguageServerLauncher';
4647
const SUSPEND_LANGUAGE_SERVER_STARTUP = process.env.SUSPEND_LANGUAGE_SERVER_STARTUP === 'true';
4748
const LANGUAGE_SERVER_LOG_LEVEL = process.env.LANGUAGE_SERVER_LOG_LEVEL ?? 'ERROR';

packages/salesforcedx-vscode-apex/src/languageUtils/languageClientManager.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,8 @@ export class LanguageClientManager {
322322
activationTime: startTime
323323
});
324324
await this.indexerDoneHandler(retrieveEnableSyncInitJobs(), languageClient, languageServerStatusBarItem);
325-
extensionContext.subscriptions.push(this.getClientInstance()!);
325+
// Do NOT push client to subscriptions - cleanup is handled only in deactivate() with timeout + force-kill.
326+
// Pushing to subscriptions causes client.dispose() to run on shutdown, which can block if LS doesn't exit.
326327
} else {
327328
const errorMessage = nls.localize('unknown');
328329
this.setStatus(ClientStatus.Error, `${nls.localize('apex_language_server_failed_activate')} - ${errorMessage}`);
@@ -422,6 +423,42 @@ export class LanguageClientManager {
422423
process.kill(pid, 'SIGKILL');
423424
}
424425

426+
/**
427+
* Find and SIGKILL Apex LS processes that are direct children of the current process.
428+
* Used when LSP shutdown times out so the extension host can exit (child's stdio pipes close).
429+
*/
430+
public killChildApexProcesses(): void {
431+
const isWindows = process.platform === 'win32';
432+
if (!this.canRunCheck(isWindows)) {
433+
return;
434+
}
435+
const parentPid = process.pid;
436+
const cmd = isWindows
437+
? `powershell.exe -command "Get-CimInstance -ClassName Win32_Process | Where-Object { $_.ParentProcessId -eq ${parentPid} } | ForEach-Object { [PSCustomObject]@{ ProcessId = $_.ProcessId; CommandLine = $_.CommandLine } } | Format-Table -HideTableHeaders"`
438+
: 'ps -e -o pid,ppid,command';
439+
try {
440+
const stdout = execSync(cmd).toString();
441+
const lines = stdout.trim().split(/\r?\n/g);
442+
for (const line of lines) {
443+
const parts = line.trim().split(/\s+/);
444+
if (parts.length < 3) continue;
445+
const pid = parseInt(parts[0], 10);
446+
const ppid = parseInt(parts[1], 10);
447+
const command = parts.slice(2).join(' ');
448+
if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
449+
if (ppid === parentPid && command.includes('apex-jorje-lsp.jar')) {
450+
try {
451+
this.terminateProcess(pid);
452+
} catch {
453+
// Process may already be gone
454+
}
455+
}
456+
}
457+
} catch {
458+
// Ignore ps/exec errors
459+
}
460+
}
461+
425462
public canRunCheck(isWindows: boolean): boolean {
426463
if (isWindows) {
427464
try {

packages/salesforcedx-vscode-apex/test/jest/index.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,11 @@ describe('index tests', () => {
251251
stopSpy = jest.fn();
252252
telemetryServiceMock = new MockTelemetryService();
253253
(getTelemetryService as jest.Mock).mockReturnValue(telemetryServiceMock);
254-
jest
255-
.spyOn(languageClientManager, 'getClientInstance')
256-
.mockReturnValue({ stop: stopSpy } as unknown as ApexLanguageClient);
254+
jest.spyOn(languageClientManager, 'getClientInstance').mockReturnValue({
255+
stop: stopSpy,
256+
dispose: jest.fn(),
257+
outputChannel: { dispose: jest.fn() }
258+
} as unknown as ApexLanguageClient);
257259
});
258260

259261
it('should call stop on the language client', async () => {

0 commit comments

Comments
 (0)