diff --git a/scripts/test-appcds.js b/scripts/test-appcds.js index 86deb6d..c66e9bc 100644 --- a/scripts/test-appcds.js +++ b/scripts/test-appcds.js @@ -6,7 +6,10 @@ const os = require('os'); const path = require('path'); const { createTsLoader } = require('../e2e/harness/tsLoader'); -const { appCdsJvmOptions } = createTsLoader()('src/install/fsUtils.ts'); +const { appCdsJvmOptions, hasCompactObjectHeaders, COMPACT_OBJECT_HEADERS } = createTsLoader()('src/install/fsUtils.ts'); + +// the server runs with compact object headers unless javaOpts say otherwise (the last option wins) +const COMPACT = [COMPACT_OBJECT_HEADERS]; function install(root, release = 'JAVA_VERSION="25.0.1"\n') { const runtime = path.join(root, 'wurst-runtime'); @@ -32,13 +35,27 @@ 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.strictEqual(COMPACT_OBJECT_HEADERS, '-XX:+UseCompactObjectHeaders'); + assert.strictEqual(hasCompactObjectHeaders([]), false); + assert.strictEqual(hasCompactObjectHeaders(COMPACT), true); + assert.strictEqual(hasCompactObjectHeaders([...COMPACT, '-Xmx2g']), true); + assert.strictEqual(hasCompactObjectHeaders([...COMPACT, '-XX:-UseCompactObjectHeaders']), false); + assert.strictEqual(hasCompactObjectHeaders([...COMPACT, '-XX:-UseCompactObjectHeaders', '-XX:+UseCompactObjectHeaders']), true); + + const options = appCdsJvmOptions(java, jar, COMPACT); 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(appCdsJvmOptions(java, jar, COMPACT), options, 'the same install gets the same archive'); + assert.deepStrictEqual(appCdsJvmOptions(java, jar, [...COMPACT, '-Xmx2g']), options, 'other options keep the archive'); + // people already have the option in wurst.javaOpts: given twice it is the same mode, so the same archive + assert.deepStrictEqual(appCdsJvmOptions(java, jar, [...COMPACT, ...COMPACT]), options, 'the option twice keeps the archive'); + // a JVM uses an archive only for its object header mode, so switching it off gets an archive of its own + const withoutCompact = appCdsJvmOptions(java, jar, [...COMPACT, '-XX:-UseCompactObjectHeaders']); + assert.notStrictEqual(archiveOf(withoutCompact), archive); + assert.deepStrictEqual(appCdsJvmOptions(java, jar, COMPACT), options, 'and the archive for compact headers is kept'); 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 @@ -46,7 +63,7 @@ try { 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)); + const updated = archiveOf(appCdsJvmOptions(java, jar, COMPACT)); 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']); @@ -54,20 +71,20 @@ try { // 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); + assert.notStrictEqual(archiveOf(appCdsJvmOptions(java, jar, COMPACT)), 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()); + assert.notStrictEqual(archiveOf(appCdsJvmOptions(other.java, other.jar, COMPACT)).split(path.sep).pop(), + archiveOf(appCdsJvmOptions(java, jar, COMPACT)).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')), []); + assert.deepStrictEqual(appCdsJvmOptions(java, path.join(tmp, 'missing', 'wurstscript.jar'), COMPACT), []); // and so does a jar which is not there fs.unlinkSync(jar); - assert.deepStrictEqual(appCdsJvmOptions(java, jar), []); + assert.deepStrictEqual(appCdsJvmOptions(java, jar, COMPACT), []); assert.ok(!fs.readdirSync(compiler).some((entry) => entry.startsWith('.write-probe'))); console.log('appcds options ok'); } finally { diff --git a/scripts/test-lsp-appcds.js b/scripts/test-lsp-appcds.js index 90a8ed4..d7ce8a7 100644 --- a/scripts/test-lsp-appcds.js +++ b/scripts/test-lsp-appcds.js @@ -1,7 +1,7 @@ '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 +// (the runtime needs the base CDS archive for compact object headers, classes_coh.jsa): node scripts/test-lsp-appcds.js const assert = require('assert'); const fs = require('fs'); const os = require('os'); @@ -11,7 +11,18 @@ 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'); +const { appCdsJvmOptions, COMPACT_OBJECT_HEADERS } = createTsLoader()('src/install/fsUtils.ts'); + +// the options the extension starts the server with, as getServerOptions builds them +function serverOptions(java, jar, javaOpts = []) { + return [COMPACT_OBJECT_HEADERS, ...appCdsJvmOptions(java, jar, [COMPACT_OBJECT_HEADERS, ...javaOpts]), ...javaOpts]; +} + +function archiveOf(options) { + const flag = options.find((option) => option.startsWith('-XX:SharedArchiveFile=')); + assert.ok(flag, 'the options name no archive'); + return flag.slice('-XX:SharedArchiveFile='.length); +} async function session(java, jar, project, options) { const child = spawn(java, [...options, '-jar', jar, '-languageServer'], { windowsHide: true, stdio: 'pipe' }); @@ -71,9 +82,9 @@ async function main() { 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 options = serverOptions(java, copy); + assert.ok(options.includes('-XX:+AutoCreateSharedArchive'), 'the options have no archive'); + const archive = archiveOf(options); const first = await session(java, copy, project, options); assert.deepStrictEqual(first.states, ['loading', 'ready']); @@ -92,13 +103,34 @@ async function main() { fs.appendFileSync(copy, ''); const stat = fs.statSync(copy); fs.utimesSync(copy, stat.atime, new Date(stat.mtimeMs + 5000)); - const updatedOptions = appCdsJvmOptions(java, copy); + const updatedOptions = serverOptions(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))); + const updatedArchive = archiveOf(updatedOptions); + assert.ok(fs.existsSync(updatedArchive)); + + // people already have the option in wurst.javaOpts, so the server is started with it twice: the same archive, + // and a session which starts from it + const twice = serverOptions(java, copy, ['-XX:+UseCompactObjectHeaders']); + assert.strictEqual(twice.filter((option) => option === '-XX:+UseCompactObjectHeaders').length, 2); + assert.strictEqual(archiveOf(twice), updatedArchive); + const writtenUpdated = fs.statSync(updatedArchive).mtimeMs; + const duplicate = await session(java, copy, project, twice); + assert.deepStrictEqual(duplicate.states, ['loading', 'ready']); + assert.strictEqual(duplicate.protocolErrors, 0); + assert.strictEqual(duplicate.code, 0, 'the option twice starts and ends cleanly'); + assert.strictEqual(fs.statSync(updatedArchive).mtimeMs, writtenUpdated, 'and starts from the archive'); + + // a wurst.javaOpts entry which switches the headers off: the runtime has no base archive for that mode, so the + // server starts without any archive, and ends cleanly + const plain = serverOptions(java, copy, ['-XX:-UseCompactObjectHeaders']); + const fourth = await session(java, copy, project, plain); + assert.deepStrictEqual(fourth.states, ['loading', 'ready']); + assert.strictEqual(fourth.protocolErrors, 0); + assert.strictEqual(fourth.code, 0, 'the server ends cleanly without compact object headers'); console.log(`appcds language server ok (ready after ${first.readyMs} ms, then ${second.readyMs} ms)`); } finally { fs.rmSync(root, { recursive: true, force: true }); diff --git a/src/install/fsUtils.ts b/src/install/fsUtils.ts index cbf569c..310f8e1 100644 --- a/src/install/fsUtils.ts +++ b/src/install/fsUtils.ts @@ -7,6 +7,23 @@ import { WURST_HOME, RUNTIME_DIR, COMPILER_DIR, GRILL_HOME_DIR, LEGACY_GRILL_DIR, } from '../paths'; +/** + * The language server holds millions of small objects, which compact object headers make 6% smaller on a large + * project (castle fight: 488 to 457 MB after a collection), at the same start time. It is always passed first, so a + * `wurst.javaOpts` entry can still switch it off. + */ +export const COMPACT_OBJECT_HEADERS = '-XX:+UseCompactObjectHeaders'; + +/** Whether a JVM started with these options has compact object headers: the last of the two options wins. */ +export function hasCompactObjectHeaders(jvmOptions: string[]): boolean { + let compact = false; + for (const option of jvmOptions) { + if (option === '-XX:+UseCompactObjectHeaders') compact = true; + else if (option === '-XX:-UseCompactObjectHeaders') compact = false; + } + return compact; +} + /** * 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). @@ -17,10 +34,11 @@ import { * 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. + * Needs the runtime's own base archive, which the distribution builds, for compact object headers (classes_coh.jsa); + * without it the JVM runs as before. A JVM uses an archive only for the object header mode it runs with, so the mode + * is part of what the archive is named after; `jvmOptions` are the options the server is started with. */ -export function appCdsJvmOptions(javaExecutable: string, compilerJar: string): string[] { +export function appCdsJvmOptions(javaExecutable: string, compilerJar: string, jvmOptions: string[]): string[] { try { const dir = path.dirname(compilerJar); // access(W_OK) says yes to any folder on Windows @@ -36,7 +54,8 @@ export function appCdsJvmOptions(javaExecutable: string, compilerJar: string): s } const jar = fs.statSync(compilerJar); const key = crypto.createHash('sha1') - .update([javaExecutable, release, compilerJar, jar.size, Math.floor(jar.mtimeMs)].join('|')) + .update([javaExecutable, release, compilerJar, jar.size, Math.floor(jar.mtimeMs), + hasCompactObjectHeaders(jvmOptions)].join('|')) .digest('hex').slice(0, 12); const archive = path.join(dir, `wurstscript-${key}.jsa`); for (const entry of fs.readdirSync(dir)) { diff --git a/src/languageServer.ts b/src/languageServer.ts index 704ce2d..8a8b262 100644 --- a/src/languageServer.ts +++ b/src/languageServer.ts @@ -5,7 +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 { appCdsJvmOptions, COMPACT_OBJECT_HEADERS } from './install/fsUtils'; import { getLanguageServerJava, checkCustomJavaVersion, getInstalledVersionString, ensureInstalledOrOfferMigration, maybeOfferUpdate } from './install/installer'; import type { UpdateAvailable } from './install/installer'; import { appendDiagnostic, formatDiagnosticError } from './features/diagnostics'; @@ -347,7 +347,8 @@ async function getServerOptions(): Promise { const java = getLanguageServerJava(); if (customJava) await checkCustomJavaVersion(customJava); const platformOpts = process.platform === 'darwin' ? ['-Dapple.awt.UIElement=true'] : []; - const args = [...platformOpts, ...appCdsJvmOptions(java, COMPILER_JAR), ...javaOpts, '-jar', COMPILER_JAR, '-languageServer']; + const jvmOpts = [COMPACT_OBJECT_HEADERS, ...javaOpts]; + const args = [...platformOpts, COMPACT_OBJECT_HEADERS, ...appCdsJvmOptions(java, COMPILER_JAR, jvmOpts), ...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');