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
1 change: 1 addition & 0 deletions client/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<Liquid
getState: () => ({
status: extension.status,
diagnostics: [...(extension.diagnostics ?? [])],
serverPid: extension.serverProcess?.pid,
}),
onWebviewMessage: extension.webview!.onWebviewMessage,
};
Expand Down
7 changes: 4 additions & 3 deletions client/src/lsp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export async function runLanguageServer(context: vscode.ExtensionContext, javaEx
cwd: workspaceFolder ? normalizeFilePath(workspaceFolder.uri.fsPath) : context.extensionPath, // root path
};
extension.logger!.client.info("Creating language server process...");
extension.serverProcess = child_process.spawn(javaExecutablePath, args, options);
const serverProcess = child_process.spawn(javaExecutablePath, args, options);
extension.serverProcess = serverProcess;

// listen to process events
extension.serverProcess.stdout?.on("data", (data) => {
Expand All @@ -39,9 +40,9 @@ export async function runLanguageServer(context: vscode.ExtensionContext, javaEx
extension.serverProcess.on("error", (err) => {
extension.logger!.server.error(`Failed to start: ${err}`);
});
extension.serverProcess.on("close", (code) => {
serverProcess.on("close", (code) => {
extension.logger!.server.info(`Process exited with code ${code}`);
extension.serverProcess = undefined;
if (extension.serverProcess === serverProcess) extension.serverProcess = undefined;
});
return port;
}
Expand Down
142 changes: 142 additions & 0 deletions client/src/test/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import * as assert from 'node:assert/strict';
import * as path from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import * as vscode from 'vscode';
import type { LJDiagnostic } from '../types/diagnostics';
import type { LJContext } from '../types/context';
import type { LiquidJavaTestApi } from '../types/test-api';

suite('Bundled LiquidJava webview and lifecycle', () => {
test('sends verification messages and verifies again after Stop, Start, and Restart', async () => {
const installed = vscode.extensions.getExtension<LiquidJavaTestApi>('AlcidesFonseca.liquid-java');
assert.ok(installed);
const api = await installed.activate();
await api.ready;

const workspace = vscode.workspace.workspaceFolders?.[0];
assert.ok(workspace);
const uri = vscode.Uri.joinPath(workspace.uri, 'src/main/java/FailingRefinement.java');
const sameFile = (file: string) => vscode.Uri.file(path.resolve(file)).fsPath === uri.fsPath;
const subscriptions: vscode.Disposable[] = [];
const nextEvent = <T>(event: vscode.Event<T>, matches: (value: T) => boolean) =>
new Promise<T>((resolve, reject) => {
const failure = api.onFailure(() => {
subscription.dispose();
failure.dispose();
reject(new Error('LiquidJava verifier crashed while waiting for lifecycle verification'));
});
subscriptions.push(failure);
const subscription = event(value => {
if (matches(value)) {
subscription.dispose();
failure.dispose();
resolve(value);
}
});
subscriptions.push(subscription);
});
const nextDiagnostics = () => nextEvent(api.onDiagnostics, () => true);
const assertFixtureDiagnostics = (diagnostics: LJDiagnostic[]) => {
const error = diagnostics.find(diagnostic => diagnostic.type === 'refinement-error' && sameFile(diagnostic.file));
assert.ok(error, JSON.stringify(diagnostics));
assert.equal(error.category, 'error');
assert.equal(error.title, 'Refinement Error');
assert.ok(error.position);
assert.ok(error.position.file && sameFile(error.position.file));
};
const originalSource = await readFile(uri.fsPath, 'utf8');
const assertServerRunning = () => {
const pid = api.getState().serverPid;
assert.ok(pid, 'the bundled extension must expose its running Java server');
assert.doesNotThrow(() => process.kill(pid, 0));
return pid;
};
const assertServerStopped = (pid: number) => {
assert.equal(api.getState().serverPid, undefined);
assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }, 'Stop must terminate the Java process');
assert.equal(api.getState().status, 'stopped');
assert.deepEqual(api.getState().diagnostics, []);
};

try {
await vscode.commands.executeCommand('workbench.action.closeSidebar');
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
await vscode.commands.executeCommand('liquidjava.stop');
await vscode.commands.executeCommand('liquidjava.start');
const ready = nextEvent(api.onWebviewMessage, event =>
event.direction === 'fromWebview' && event.message.type === 'ready');
await vscode.commands.executeCommand('liquidjava.showView');
await ready;

const initialDiagnostics = nextDiagnostics();
const initialContext = nextEvent(api.onWebviewMessage, event =>
event.direction === 'toWebview' && event.message.type === 'context' &&
event.message.context.localVars.some((variable: LJContext['localVars'][number]) => variable.name === 'valid'));
const document = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(document);
const [initialResult] = await Promise.all([initialDiagnostics, initialContext]);
assertFixtureDiagnostics(initialResult);

// change the context on disk without triggering automatic verification on save
assert.ok(originalSource.includes('int valid = 1;'));
await writeFile(uri.fsPath, originalSource.replace('int valid = 1;', 'int verifiedNow = 1;'));

const diagnosticMessage = nextEvent(api.onWebviewMessage, event =>
event.direction === 'toWebview' && event.message.type === 'diagnostics');
const contextMessage = nextEvent(api.onWebviewMessage, event =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can match the old context. handleLJDiagnostics sends a context message with the cached extension.context from the previous verification, so this listener can resolve on that message, not on the context Verify produces. The fixture doesn't change between runs, so the old and new contexts look the same, and the assertions on valid pass even if Verify stopped sending a context.

Suggest waiting for the context message that follows the liquidjava/context notification for this run, for example by clearing the cached context before Verify, or by matching on something that changes per run.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6ded2e6: changed the fixture on disk between runs and require the renamed variable in the outbound context, so cached context cannot satisfy the manual Verify assertion. Lifecycle tests passed on stable and minimum VS Code.

event.direction === 'toWebview' && event.message.type === 'context' &&
event.message.context.localVars.some((variable: LJContext['localVars'][number]) => variable.name === 'verifiedNow'));
const manualDiagnostics = nextDiagnostics();
await vscode.commands.executeCommand('liquidjava.verify');
assert.equal(api.getState().status, 'loading');
const [diagnostics, outboundDiagnostics, outboundContext] = await Promise.all([
manualDiagnostics, diagnosticMessage, contextMessage,
]);
assertFixtureDiagnostics(diagnostics);
assertFixtureDiagnostics(outboundDiagnostics.message.diagnostics);
const context: LJContext = outboundContext.message.context;
const valid = context.localVars.find(variable => variable.name === 'verifiedNow');
assert.ok(valid, JSON.stringify(context));
assert.ok(valid.position);
assert.ok(sameFile(valid.position.file));
assert.equal(valid.type, 'int');
assert.ok(valid.mainRefinement.includes('> 0'));
assert.equal(api.getState().status, 'failed');

const originalPid = assertServerRunning();
const stopped = nextEvent(api.onWebviewMessage, event =>
event.direction === 'toWebview' && event.message.type === 'status' && event.message.status === 'stopped');
await vscode.commands.executeCommand('liquidjava.stop');
await stopped;
assertServerStopped(originalPid);

const startDiagnostics = nextDiagnostics();
await vscode.commands.executeCommand('liquidjava.start');
assertFixtureDiagnostics(await startDiagnostics);
const startedPid = assertServerRunning();
assert.notEqual(startedPid, originalPid);
assert.equal(api.getState().status, 'failed');

const restartStopped = nextEvent(api.onWebviewMessage, event =>
event.direction === 'toWebview' && event.message.type === 'status' && event.message.status === 'stopped');
const restartDiagnostics = nextDiagnostics();
await vscode.commands.executeCommand('liquidjava.restart');
await restartStopped;
assertFixtureDiagnostics(await restartDiagnostics);
const restartedPid = assertServerRunning();
assert.notEqual(restartedPid, startedPid);
assert.throws(() => process.kill(startedPid, 0), { code: 'ESRCH' });

const finalDiagnostics = nextDiagnostics();
await vscode.commands.executeCommand('liquidjava.verify');
assertFixtureDiagnostics(await finalDiagnostics);
assert.equal(api.getState().status, 'failed');
} finally {
subscriptions.forEach(subscription => subscription.dispose());
await writeFile(uri.fsPath, originalSource);
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
await vscode.commands.executeCommand('liquidjava.stop');
await vscode.commands.executeCommand('liquidjava.start');
}
});
});
2 changes: 1 addition & 1 deletion client/src/types/test-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ export interface LiquidJavaTestApi {
readonly ready: Promise<void>;
readonly onDiagnostics: vscode.Event<LJDiagnostic[]>;
readonly onFailure: vscode.Event<void>;
getState(): { status: ExtensionStatus | undefined; diagnostics: LJDiagnostic[] };
getState(): { status: ExtensionStatus | undefined; diagnostics: LJDiagnostic[]; serverPid: number | undefined };
readonly onWebviewMessage: vscode.Event<WebviewMessage>;
}
6 changes: 4 additions & 2 deletions client/src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export async function connectToPort(
*/
export async function killProcess(proc?: child_process.ChildProcess) {
return new Promise<void>((resolve, reject) => {
if (!proc || proc.killed || proc.pid === undefined) {
if (!proc || proc.exitCode !== null || proc.signalCode !== null || proc.pid === undefined) {
// already killed
resolve();
return;
Expand All @@ -116,10 +116,12 @@ export async function killProcess(proc?: child_process.ChildProcess) {
});
} else {
// Unix
const onClose = () => resolve();
proc.once("close", onClose);
try {
process.kill(proc.pid, "SIGKILL");
resolve();
} catch (err) {
proc.removeListener("close", onClose);
reject(err);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import liquidjava.specification.Refinement;

public class FailingRefinement {
@Refinement("_ > 0")
int positive = -1;
void check() {
@Refinement("_ > 0")
int valid = 1;

@Refinement("_ > 0")
int positive = -1;
}
}
Loading