From 91add1869f60f7ee7c9f71ad3e5d49e8cb311aed Mon Sep 17 00:00:00 2001 From: Frotty Date: Thu, 8 Oct 2026 12:48:49 +0200 Subject: [PATCH] Start the language server from a JDK-managed AppCDS archive The server now starts with -XX:+AutoCreateSharedArchive and an archive next to the compiler jar: the JVM writes it when the first session ends (about 1.2 s more, once), and the sessions after it start from it. Language server start to ready, median of four starts: 4.39 s to 3.71 s on a small project, 18.98 s to 17.28 s on castle fight. It needs the base archive of the runtime, which the distribution of the compiler now builds (jlink --generate-cds-archive); without one the JVM runs as before. Two things the JVM does not handle, so appCdsJvmOptions does: - an archive for another jar, or another runtime, is ignored but never replaced, so an update would leave the server without one. The archive is named after the runtime, jar path, size and modification time, and the archives of others are removed; - when it cannot write the archive the JVM ends with a crash status. No options when a probe file cannot be written next to the jar. -Xlog:disable keeps JVM messages about the archive off stdout, which is the protocol stream. Two first sessions at once both end cleanly and leave a valid archive. test:appcds covers the options; scripts/test-lsp-appcds.js (opt-in, needs a built compiler distribution like test-lsp-readiness.js) starts the real server three times: first session writes the archive, the second leaves it, an updated jar gets a new one. --- package.json | 3 +- scripts/test-appcds.js | 75 ++++++++++++++++++++++++++ scripts/test-lsp-appcds.js | 108 +++++++++++++++++++++++++++++++++++++ src/install/fsUtils.ts | 44 +++++++++++++++ src/languageServer.ts | 3 +- 5 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 scripts/test-appcds.js create mode 100644 scripts/test-lsp-appcds.js diff --git a/package.json b/package.json index f5210bf..8cec88c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/test-appcds.js b/scripts/test-appcds.js new file mode 100644 index 0000000..86deb6d --- /dev/null +++ b/scripts/test-appcds.js @@ -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 }); +} diff --git a/scripts/test-lsp-appcds.js b/scripts/test-lsp-appcds.js new file mode 100644 index 0000000..90a8ed4 --- /dev/null +++ b/scripts/test-lsp-appcds.js @@ -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 +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 '); + 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); }); diff --git a/src/install/fsUtils.ts b/src/install/fsUtils.ts index 5b4da9b..cbf569c 100644 --- a/src/install/fsUtils.ts +++ b/src/install/fsUtils.ts @@ -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)); } diff --git a/src/languageServer.ts b/src/languageServer.ts index eb92862..704ce2d 100644 --- a/src/languageServer.ts +++ b/src/languageServer.ts @@ -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'; @@ -346,7 +347,7 @@ async function getServerOptions(): Promise { 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');