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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -970,8 +970,9 @@
"prewatch-web": "node ./scripts/build-local-model-library.js",
"compile-web": "webpack",
"watch-web": "webpack --watch",
"test": "npm run test:fuzzy && npm run test:image-decoders && npm run test:diagnostics && npm run test:language-features && npm run test:editor-work && npm run test:webview && npm run test:private-libraries",
"test": "npm run test:appcds && npm run test:fuzzy && npm run test:image-decoders && npm run test:diagnostics && npm run test:language-features && npm run test:editor-work && npm run test:webview && npm run test:private-libraries",
"test:private-libraries": "node ./scripts/test-private-library-loader.js",
"test:appcds": "node ./scripts/test-appcds.js",
"test:e2e:production": "npm run package-web && playwright test --project=webview",
"test:editor-work": "node ./scripts/test-editor-work.js",
"test:e2e": "npm run compile-web && playwright test --project=webview",
Expand Down
75 changes: 75 additions & 0 deletions scripts/test-appcds.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use strict';

const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { createTsLoader } = require('../e2e/harness/tsLoader');

const { appCdsJvmOptions } = createTsLoader()('src/install/fsUtils.ts');

function install(root, release = 'JAVA_VERSION="25.0.1"\n') {
const runtime = path.join(root, 'wurst-runtime');
const compiler = path.join(root, 'wurst-compiler');
fs.mkdirSync(path.join(runtime, 'bin'), { recursive: true });
fs.mkdirSync(compiler, { recursive: true });
fs.writeFileSync(path.join(runtime, 'release'), release);
const java = path.join(runtime, 'bin', 'java.exe');
const jar = path.join(compiler, 'wurstscript.jar');
fs.writeFileSync(java, '');
fs.writeFileSync(jar, 'compiler 1');
return { java, jar, compiler };
}

function archiveOf(options) {
const flag = options.find((option) => option.startsWith('-XX:SharedArchiveFile='));
assert.ok(flag, `no archive in ${JSON.stringify(options)}`);
return flag.slice('-XX:SharedArchiveFile='.length);
}

const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-appcds-'));
try {
const { java, jar, compiler } = install(tmp);

// the options: a JDK managed archive next to the jar, and no JVM log lines on the protocol stream
const options = appCdsJvmOptions(java, jar);
assert.deepStrictEqual(options.filter((option) => !option.startsWith('-XX:SharedArchiveFile=')),
['-XX:+AutoCreateSharedArchive', '-Xlog:disable']);
const archive = archiveOf(options);
assert.strictEqual(path.dirname(archive), compiler);
assert.match(path.basename(archive), /^wurstscript-[0-9a-f]{12}\.jsa$/);
assert.deepStrictEqual(appCdsJvmOptions(java, jar), options, 'the same install gets the same archive');
assert.deepStrictEqual(fs.readdirSync(compiler), ['wurstscript.jar'], 'the write probe is removed');

// an update: the archive of the old jar does not fit and is not replaced by the JVM, so it is removed here
fs.writeFileSync(archive, 'archive of compiler 1');
fs.writeFileSync(path.join(compiler, 'notes.jsa'), 'not ours');
fs.writeFileSync(path.join(compiler, 'wurstscript-latest.jsa'), 'not ours');
fs.writeFileSync(jar, 'compiler 2 is longer');
const updated = archiveOf(appCdsJvmOptions(java, jar));
assert.notStrictEqual(updated, archive);
assert.ok(!fs.existsSync(archive), 'the archive of the old jar is removed');
assert.deepStrictEqual(fs.readdirSync(compiler).sort(), ['notes.jsa', 'wurstscript-latest.jsa', 'wurstscript.jar']);

// a jar with the same size, rewritten later, is another jar
const stat = fs.statSync(jar);
fs.utimesSync(jar, stat.atime, new Date(stat.mtimeMs + 5000));
assert.notStrictEqual(archiveOf(appCdsJvmOptions(java, jar)), updated);

// another runtime, at the same path or elsewhere, is another archive
const other = install(path.join(tmp, 'other'), 'JAVA_VERSION="25.0.3"\n');
fs.copyFileSync(jar, other.jar);
fs.utimesSync(other.jar, fs.statSync(jar).atime, fs.statSync(jar).mtime);
assert.notStrictEqual(archiveOf(appCdsJvmOptions(other.java, other.jar)).split(path.sep).pop(),
archiveOf(appCdsJvmOptions(java, jar)).split(path.sep).pop());

// a folder which cannot be written gets no options (the JVM aborts at exit when it cannot write the archive)
assert.deepStrictEqual(appCdsJvmOptions(java, path.join(tmp, 'missing', 'wurstscript.jar')), []);
// and so does a jar which is not there
fs.unlinkSync(jar);
assert.deepStrictEqual(appCdsJvmOptions(java, jar), []);
assert.ok(!fs.readdirSync(compiler).some((entry) => entry.startsWith('.write-probe')));
console.log('appcds options ok');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
108 changes: 108 additions & 0 deletions scripts/test-lsp-appcds.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use strict';

// Opt-in cross-repository test: pass the compiler jar of a built distribution and the java of its runtime
// (the runtime needs the base CDS archive jlink makes): node scripts/test-lsp-appcds.js <compiler.jar> <java>
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { pathToFileURL } = require('url');
const { spawn } = require('child_process');
const { createProtocolConnection, StreamMessageReader, StreamMessageWriter } = require('vscode-languageserver-protocol/node');
const { createTsLoader } = require('../e2e/harness/tsLoader');

const { appCdsJvmOptions } = createTsLoader()('src/install/fsUtils.ts');

async function session(java, jar, project, options) {
const child = spawn(java, [...options, '-jar', jar, '-languageServer'], { windowsHide: true, stdio: 'pipe' });
const connection = createProtocolConnection(new StreamMessageReader(child.stdout), new StreamMessageWriter(child.stdin));
let stderr = '';
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-4096); });
const closed = new Promise((resolve) => child.on('close', resolve));
const states = [];
let protocolErrors = 0;
let finished;
const ready = new Promise((resolve) => { finished = resolve; });
connection.onNotification('wurst/initialBuildStatus', (params) => {
states.push(params.state);
if (params.state === 'ready' || params.state === 'failed') finished();
});
connection.onRequest('window/workDoneProgress/create', () => null);
connection.onRequest('workspace/configuration', () => []);
connection.onRequest('client/registerCapability', () => null);
connection.onRequest('window/showMessageRequest', () => null);
connection.onNotification(() => {});
connection.onError(() => { protocolErrors++; });
connection.listen();
let timer;
const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`no readiness: ${stderr}`)), 60000); });
const started = Date.now();
try {
await Promise.race([(async () => {
await connection.sendRequest('initialize', {
processId: process.pid, rootUri: pathToFileURL(project).toString(),
capabilities: { window: { workDoneProgress: false }, experimental: { wurstInitialBuildStatus: true } },
});
await connection.sendNotification('initialized', {});
await ready;
})(), timeout]);
const readyMs = Date.now() - started;
await connection.sendRequest('shutdown');
await connection.sendNotification('exit');
const code = await closed;
return { code, states, protocolErrors, readyMs };
} finally {
clearTimeout(timer);
child.kill();
}
}

async function main() {
const [jar, java] = process.argv.slice(2);
assert.ok(jar && java, 'usage: node scripts/test-lsp-appcds.js <compiler.jar> <java>');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-appcds-lsp-'));
try {
const project = path.join(root, 'project');
fs.mkdirSync(path.join(project, 'wurst'), { recursive: true });
fs.writeFileSync(path.join(project, 'wurst', 'Main.wurst'), 'package Main\nfunction beforeEdit()\n skip\n');
// a copy, so that the archive is not written into the distribution which is being tested
const compilerDir = path.join(root, 'wurst-compiler');
fs.mkdirSync(compilerDir);
const copy = path.join(compilerDir, path.basename(jar));
fs.copyFileSync(jar, copy);

const options = appCdsJvmOptions(java, copy);
assert.ok(options.length > 0, 'the options are empty');
const archive = options.find((option) => option.startsWith('-XX:SharedArchiveFile=')).slice('-XX:SharedArchiveFile='.length);

const first = await session(java, copy, project, options);
assert.deepStrictEqual(first.states, ['loading', 'ready']);
assert.strictEqual(first.protocolErrors, 0);
assert.strictEqual(first.code, 0, 'the first session ends cleanly');
assert.ok(fs.existsSync(archive) && fs.statSync(archive).size > 0, 'the first session writes the archive');
const written = fs.statSync(archive).mtimeMs;

const second = await session(java, copy, project, options);
assert.deepStrictEqual(second.states, ['loading', 'ready']);
assert.strictEqual(second.protocolErrors, 0);
assert.strictEqual(second.code, 0);
assert.strictEqual(fs.statSync(archive).mtimeMs, written, 'the second session starts from the archive and leaves it');

// an update: the options name another archive, the old one goes, and the first session writes the new one
fs.appendFileSync(copy, '');
const stat = fs.statSync(copy);
fs.utimesSync(copy, stat.atime, new Date(stat.mtimeMs + 5000));
const updatedOptions = appCdsJvmOptions(java, copy);
assert.notDeepStrictEqual(updatedOptions, options);
assert.ok(!fs.existsSync(archive), 'the archive of the old jar is removed');
const third = await session(java, copy, project, updatedOptions);
assert.deepStrictEqual(third.states, ['loading', 'ready']);
assert.strictEqual(third.code, 0);
assert.ok(fs.existsSync(updatedOptions.find((option) => option.startsWith('-XX:SharedArchiveFile=')).slice('-XX:SharedArchiveFile='.length)));
console.log(`appcds language server ok (ready after ${first.readyMs} ms, then ${second.readyMs} ms)`);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}

main().then(() => process.exit(0), (error) => { console.error(error); process.exit(1); });
44 changes: 44 additions & 0 deletions src/install/fsUtils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,55 @@
'use strict';

import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import {
WURST_HOME, RUNTIME_DIR, COMPILER_DIR, GRILL_HOME_DIR, LEGACY_GRILL_DIR,
} from '../paths';

/**
* JVM options which start the language server from an AppCDS archive next to the compiler jar: the JVM writes the
* archive when the first session ends, and the sessions after it start from it (about 15% sooner).
*
* The archive is named after what it was recorded for (runtime, jar path, size and modification time). The JVM
* only ignores an archive which does not fit, it does not write a new one, so an update would leave the server
* without; the archives of other jars and runtimes are removed here instead. No options when the folder cannot
* be written, because the JVM then aborts when the session ends. `-Xlog:disable` because the JVM reports archive
* trouble on stdout, which is the protocol stream.
*
* Needs the runtime's own base archive, which the distribution builds (jlink --generate-cds-archive); without it
* the JVM runs as before.
*/
export function appCdsJvmOptions(javaExecutable: string, compilerJar: string): string[] {
try {
const dir = path.dirname(compilerJar);
// access(W_OK) says yes to any folder on Windows
const probe = path.join(dir, `.write-probe-${process.pid}`);
fs.writeFileSync(probe, '');
fs.unlinkSync(probe);

let release = '';
try {
release = fs.readFileSync(path.join(path.dirname(path.dirname(javaExecutable)), 'release'), 'utf8');
} catch {
// a java without a release file (from PATH) is told apart by its path
}
const jar = fs.statSync(compilerJar);
const key = crypto.createHash('sha1')
.update([javaExecutable, release, compilerJar, jar.size, Math.floor(jar.mtimeMs)].join('|'))
.digest('hex').slice(0, 12);
const archive = path.join(dir, `wurstscript-${key}.jsa`);
for (const entry of fs.readdirSync(dir)) {
if (/^wurstscript-[0-9a-f]{12}\.jsa$/.test(entry) && entry !== path.basename(archive)) {
try { fs.unlinkSync(path.join(dir, entry)); } catch { /* in use by a session of the old version */ }
}
}
return ['-XX:+AutoCreateSharedArchive', `-XX:SharedArchiveFile=${archive}`, '-Xlog:disable'];
} catch {
return [];
}
}

export function sleep(ms: number) {
return new Promise((res) => setTimeout(res, ms));
}
Expand Down
3 changes: 2 additions & 1 deletion src/languageServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as vscode from 'vscode';
import { workspace, ExtensionContext } from 'vscode';
import { LanguageClient, LanguageClientOptions, ServerOptions, Executable, State, DidOpenTextDocumentNotification } from 'vscode-languageclient/node';
import { RUNTIME_DIR, COMPILER_JAR } from './paths';
import { appCdsJvmOptions } from './install/fsUtils';
import { getLanguageServerJava, checkCustomJavaVersion, getInstalledVersionString, ensureInstalledOrOfferMigration, maybeOfferUpdate } from './install/installer';
import type { UpdateAvailable } from './install/installer';
import { appendDiagnostic, formatDiagnosticError } from './features/diagnostics';
Expand Down Expand Up @@ -346,7 +347,7 @@ async function getServerOptions(): Promise<ServerOptions> {
const java = getLanguageServerJava();
if (customJava) await checkCustomJavaVersion(customJava);
const platformOpts = process.platform === 'darwin' ? ['-Dapple.awt.UIElement=true'] : [];
const args = [...platformOpts, ...javaOpts, '-jar', COMPILER_JAR, '-languageServer'];
const args = [...platformOpts, ...appCdsJvmOptions(java, COMPILER_JAR), ...javaOpts, '-jar', COMPILER_JAR, '-languageServer'];

if (debugMode && (await isPortOpen(5005))) {
args.unshift('-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005,quiet=y');
Expand Down
Loading