From d16ff84bc9fe0db75327c249107116e3d0b43ab4 Mon Sep 17 00:00:00 2001 From: Frotty Date: Thu, 8 Oct 2026 14:52:34 +0200 Subject: [PATCH 1/2] Start the language server with compact object headers The language server holds millions of small objects, and compact object headers made its heap 6% smaller on castle fight (488 to 457 MB after a collection) at the same start time. The runtime of the distribution carries the base CDS archive for that mode (wurstscript/WurstScript#1389), and a JVM uses a base archive only for the header mode it runs with, so the mode is also part of what the AppCDS archive next to the compiler jar is named after. The option is passed before wurst.javaOpts, so an entry there can still switch it off; the server then starts without an archive, which the end-to-end test covers. --- scripts/test-appcds.js | 33 ++++++++++++++++++++++++--------- scripts/test-lsp-appcds.js | 23 ++++++++++++++++++----- src/install/fsUtils.ts | 27 +++++++++++++++++++++++---- src/languageServer.ts | 5 +++-- 4 files changed, 68 insertions(+), 20 deletions(-) diff --git a/scripts/test-appcds.js b/scripts/test-appcds.js index 86deb6d..ca0e669 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,25 @@ 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'); + // 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 +61,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 +69,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..d8eee13 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,12 @@ 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]; +} async function session(java, jar, project, options) { const child = spawn(java, [...options, '-jar', jar, '-languageServer'], { windowsHide: true, stdio: 'pipe' }); @@ -71,8 +76,8 @@ 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 options = serverOptions(java, copy); + assert.ok(options.includes('-XX:+AutoCreateSharedArchive'), 'the options have no archive'); const archive = options.find((option) => option.startsWith('-XX:SharedArchiveFile=')).slice('-XX:SharedArchiveFile='.length); const first = await session(java, copy, project, options); @@ -92,13 +97,21 @@ 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))); + + // 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'); From ca489a875ec37ab960911bbf629433cae3a70094 Mon Sep 17 00:00:00 2001 From: Frotty Date: Thu, 8 Oct 2026 14:56:17 +0200 Subject: [PATCH 2/2] Test the compact object headers option given twice, as it is for people who already have it in wurst.javaOpts --- scripts/test-appcds.js | 2 ++ scripts/test-lsp-appcds.js | 23 +++++++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/test-appcds.js b/scripts/test-appcds.js index ca0e669..c66e9bc 100644 --- a/scripts/test-appcds.js +++ b/scripts/test-appcds.js @@ -50,6 +50,8 @@ try { assert.match(path.basename(archive), /^wurstscript-[0-9a-f]{12}\.jsa$/); 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); diff --git a/scripts/test-lsp-appcds.js b/scripts/test-lsp-appcds.js index d8eee13..d7ce8a7 100644 --- a/scripts/test-lsp-appcds.js +++ b/scripts/test-lsp-appcds.js @@ -18,6 +18,12 @@ 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' }); const connection = createProtocolConnection(new StreamMessageReader(child.stdout), new StreamMessageWriter(child.stdin)); @@ -78,7 +84,7 @@ async function main() { const options = serverOptions(java, copy); assert.ok(options.includes('-XX:+AutoCreateSharedArchive'), 'the options have no archive'); - const archive = options.find((option) => option.startsWith('-XX:SharedArchiveFile=')).slice('-XX:SharedArchiveFile='.length); + const archive = archiveOf(options); const first = await session(java, copy, project, options); assert.deepStrictEqual(first.states, ['loading', 'ready']); @@ -103,7 +109,20 @@ async function main() { 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