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');