Files
openidea/java/java-usages/tests/configuration.py
T
qiaojianlong c4b87ae09e perf(java): prewarm configuration navigation and share results between hover and click (#947)
Configuration navigation from YAML/properties still waited for a cold index the first
time and repeated the lookup between the ⌘-hover probe and the click.

- Trusted projects with a Maven/Gradle/Eclipse build file start Java shortly after
  opening. Once JDT reports ServiceReady, the app calls the new
  openidea.java.configuration.warmup command: a low-priority background job builds
  the configuration index one project at a time (open YAML/properties modules first),
  waits for workspace builds, has a 30 s budget per project, and only fills free cache
  room.
- Index builds take a cancellable per-project lock instead of one global lock, so a
  background build never delays another project, and a warmed snapshot is dropped
  if the Java model changed meanwhile.
- In YAML/properties the editor underlines the whole key (hyphens and dots
  included), hover and Go to Definition share one bounded request per key, and the
  declaration chooser names the whole key.

Bundle java-usages 1.6.0 is rebuilt reproducibly with build.py against JDT LS
1.63.0-202610191322 (SHA-256 33cfa1bb…). The real-JDT harness checks that the first
lookup reuses the warmed index.

Co-authored-by: qjlqyf <109515705+qjlqyf@users.noreply.github.com>
2026-11-06 12:23:39 +08:00

267 lines
17 KiB
Python

"""Real JDT LS configuration-navigation regression and warm-query benchmark.
Run with Java 25 on PATH: python3 configuration.py /path/to/jdtls
Fixtures use small Spring API stubs; no Maven/network/build of a user's project.
"""
import os
import json
import pathlib
import platform
import queue
import subprocess
import sys
import tempfile
import threading
import time
import zipfile
home = pathlib.Path(sys.argv[1]).resolve()
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar'
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-configuration-'))
project = work / 'project'
source_dir = project / 'src'
source_dir.mkdir(parents=True)
def write(relative, text):
file = source_dir / relative
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(text)
return file
write('org/springframework/boot/context/properties/ConfigurationProperties.java', '''package org.springframework.boot.context.properties;
public @interface ConfigurationProperties { String value() default ""; String prefix() default ""; }''')
write('org/springframework/boot/context/properties/bind/Name.java', 'package org.springframework.boot.context.properties.bind; public @interface Name { String value(); }')
write('org/springframework/beans/factory/annotation/Value.java', '''package org.springframework.beans.factory.annotation;
public @interface Value { String value(); }''')
write('org/springframework/core/env/PropertyResolver.java', '''package org.springframework.core.env;
public interface PropertyResolver { String getProperty(String key); String getRequiredProperty(String key); }''')
write('org/springframework/core/env/Environment.java', '''package org.springframework.core.env;
public interface Environment extends PropertyResolver {}''')
source = '''package demo;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
@ConfigurationProperties(prefix="app")
class Settings extends Parent {
int timeOut;
Nested nested;
java.util.List<Nested> clients;
java.util.Map<String, Nested> routes;
java.util.List<String> tags;
java.util.Map<String, String> labels;
public int getTimeOut() { return timeOut; }
public void setTimeOut(int value) { timeOut = value; }
}
class Parent { boolean enabled; }
class Nested { String url; }
@ConfigurationProperties("record")
record RecordSettings(String name, int count) {}
@ConfigurationProperties("constructor")
class ConstructorSettings {
ConstructorSettings(@org.springframework.boot.context.properties.bind.Name("remote-name") String name) {}
}
class Consumers {
static final String KEY = "app.time-out";
@Value("${app.time-out:30}") String timeout;
@Value("${app.missing:${app.backup:42}}") String fallback;
String call(Environment env) { return env.getProperty(KEY); }
String required(Environment env) { return env.getRequiredProperty("app.required"); }
String dynamic(Environment env, String key) { return env.getProperty(key); }
String unrelated(Fake env) { return env.getProperty("app.time-out"); }
@ConfigurationProperties("bean") Nested bean() { return new Nested(); }
}
class Fake { String getProperty(String name) { return name; } }
'''
file = write('demo/Settings.java', source)
if os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1':
for i in range(3000):
write(f'noise/Plain{i}.java', f'package noise; class Plain{i} {{ int number; }}')
for i in range(100):
write(f'noise/Config{i}.java', f'package noise; @org.springframework.boot.context.properties.ConfigurationProperties(prefix="stress{i}") class Config{i} {{ int value; }}')
write('demo/NotSpring.java', '''package demo;
@interface ConfigurationProperties { String prefix(); }
@ConfigurationProperties(prefix="fake") class NotSpring { int timeOut; }
''')
local_metadata = project / 'src/main/resources/META-INF'
local_metadata.mkdir(parents=True)
(local_metadata / 'additional-spring-configuration-metadata.json').write_text(json.dumps({
'groups': [{'name': 'extra', 'sourceType': 'demo.Nested', 'type': 'demo.Nested'}],
'properties': [{'name': 'extra.url', 'sourceType': 'demo.Nested'}]
}))
# A dependency with metadata and attached source exercises actual library locations.
libsource = work / 'libsrc/vendor/LibrarySettings.java'
libsource.parent.mkdir(parents=True)
libsource.write_text('package vendor; public class LibrarySettings { public int port; public int getPort() { return port; } }')
classes = work / 'libclasses'
classes.mkdir()
subprocess.run(['javac', '--release', '21', '-d', str(classes), str(libsource)], check=True)
library = work / 'library.jar'
metadata = {'groups': [{'name': 'vendor', 'type': 'vendor.LibrarySettings', 'sourceType': 'vendor.LibrarySettings'}],
'properties': [{'name': 'vendor.port', 'type': 'java.lang.Integer', 'sourceType': 'vendor.LibrarySettings'}]}
with zipfile.ZipFile(library, 'w') as z:
z.write(classes / 'vendor/LibrarySettings.class', 'vendor/LibrarySettings.class')
z.writestr('META-INF/spring-configuration-metadata.json', json.dumps(metadata))
sourcejar = work / 'library-sources.jar'
with zipfile.ZipFile(sourcejar, 'w') as z:
z.write(libsource, 'vendor/LibrarySettings.java')
(project / '.project').write_text('<projectDescription><name>configuration</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>')
spring_jar = os.environ.get('OPENIDEA_SPRING_BOOT_JAR')
extra_classpath = f'<classpathentry kind="lib" path="{pathlib.Path(spring_jar).resolve()}"/>' if spring_jar else ''
(project / '.classpath').write_text(f'<classpath><classpathentry kind="src" path="src"/><classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/><classpathentry kind="lib" path="{library}" sourcepath="{sourcejar}"/>{extra_classpath}<classpathentry kind="output" path="bin"/></classpath>')
config = work / 'config'
config.mkdir()
name = {'Darwin': 'config_mac', 'Windows': 'config_win'}.get(platform.system(), 'config_linux')
names = ([name + '_arm'] if platform.machine().lower() in ('arm64', 'aarch64') else []) + [name]
(config / 'config.ini').write_bytes(next(home / n / 'config.ini' for n in names if (home / n / 'config.ini').exists()).read_bytes())
log = (work / 'stderr.log').open('w')
proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log)
messages = queue.Queue()
lock = threading.Lock()
def send(message):
data = json.dumps(message).encode()
with lock:
proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data)
proc.stdin.flush()
def read():
while True:
headers = {}
while True:
line = proc.stdout.readline()
if not line: return
if line == b'\r\n': break
k, v = line.decode().split(':', 1)
headers[k.lower()] = v.strip()
message = json.loads(proc.stdout.read(int(headers['content-length'])))
if 'method' in message and 'id' in message:
send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None})
else: messages.put(message)
threading.Thread(target=read, daemon=True).start()
sequence = 0
transcript = []
def request(method, params):
global sequence
sequence += 1
send({'jsonrpc': '2.0', 'id': sequence, 'method': method, 'params': params})
deadline = time.monotonic() + 180
while time.monotonic() < deadline:
msg = messages.get(timeout=max(1, deadline - time.monotonic()))
transcript.append(msg)
if msg.get('id') == sequence:
if 'error' in msg: raise RuntimeError(msg['error'])
return msg.get('result')
raise TimeoutError(method)
def query(key, uri=None, line=0, col=0):
return request('workspace/executeCommand', {'command': 'openidea.java.configuration', 'arguments': [uri or (project / 'application.yml').as_uri(), key, line, col, key is None, False]})
def names(result):
return {p['name'] for p in result['keys']}
try:
init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'configuration'}], 'capabilities': {'textDocument': {'definition': {'linkSupport': False}}}, 'initializationOptions': {'bundles': [str(bundle)], 'extendedClientCapabilities': {'classFileContentsSupport': True}, 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}})
send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}})
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': file.as_uri(), 'languageId': 'java', 'version': 1, 'text': source}}})
request('java/buildWorkspace', True)
assert 'openidea.java.configuration' in init['capabilities']['executeCommandProvider']['commands'], init
def warmup(action='start'):
return request('workspace/executeCommand', {'command': 'openidea.java.configuration.warmup', 'arguments': [action, [(project / 'application.yml').as_uri()]]})
# Queuing is cheap and idempotent; the index is built by a background job.
queued_at = time.perf_counter()
queued = warmup()
assert queued['scheduled'] and queued['pending'] > 0 and not queued['completed'], queued
assert warmup()['pending'] == queued['pending'], 'a repeated start queued the project twice'
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
warmed = warmup('status')
if not warmed['scheduled']: break
time.sleep(.1)
assert not warmed['scheduled'] and warmed['completed'], warmed
assert warmup()['pending'] == 0, 'a repeated start queued a warmed project again'
prewarm_seconds = time.perf_counter() - queued_at
results = {}
for key in ['app', 'vendor', 'app.time-out', 'app.enabled', 'app.nested.url', 'app.clients[0].url', 'app.routes.east.url', 'app.tags[0]', 'app.labels.region', 'app.labels[region.name]', 'constructor.remote-name', 'record.name', 'bean.url', 'app.backup', 'app.required', 'vendor.port', 'extra.url']:
start = time.perf_counter()
result = query(key)
results[key] = {'seconds': time.perf_counter() - start, 'response': result}
if key == 'app':
assert result['generation'] in warmed['completed'].values(), ('the first lookup rebuilt the warmed index', result, warmed)
assert result['locations'], (key, result)
attached = request('java/classFileContents', {'uri': results['vendor.port']['response']['locations'][0]['uri']})
assert attached and 'LibrarySettings' in attached and 'port' in attached, attached
if spring_jar:
for key in ['server.port', 'spring.datasource.url', 'spring.jackson.date-format']:
result = query(key)
assert result['locations'], (key, result)
assert all(loc['uri'].startswith('jdt://') for loc in result['locations']), result
results[key] = {'response': result}
if key == 'server.port':
decompiled = request('java/classFileContents', {'uri': result['locations'][0]['uri']})
assert decompiled and 'ServerProperties' in decompiled and 'port' in decompiled, decompiled
assert not query('app.tags[0].value')['locations']
assert not query('app.labels.region.coder')['locations']
assert not query('fake.time-out')['locations']
assert not query('app.does-not-exist')['locations']
metadata_file = local_metadata / 'additional-spring-configuration-metadata.json'
metadata_file.write_text(metadata_file.read_text().replace('extra', 'newer'))
assert query('newer.url')['locations']
assert not query('extra.url')['locations']
# Property references include member usages and consumers, without declarations.
references = request('workspace/executeCommand', {'command': 'openidea.java.configuration',
'arguments': [(project / 'application.yml').as_uri(), 'app.time-out', 0, 0, True, False]})
assert references['locations'], references
assert any(loc['range']['start']['line'] == next(i for i, line in enumerate(source.splitlines()) if 'getProperty(KEY)' in line) for loc in references['locations']), references
# 3 binding targets + @Value + Environment constant; Fake.getProperty excluded.
timeout = query('app.time-out')
assert len(timeout['locations']) == 5, timeout
for needle, expected in [('int timeOut;', 'app.timeOut'), ('"${app.time-out:30}"', 'app.time-out'), ('env.getProperty(KEY)', 'app.time-out')]:
line, text = next((i, s) for i, s in enumerate(source.splitlines()) if needle in s)
col = text.index(needle) + (4 if needle == 'int timeOut;' else (len('env.getProperty(') if needle.startswith('env.') else 4))
reverse = query(None, file.as_uri(), line, col)
assert expected in names(reverse), (needle, reverse)
warm = []
for _ in range(20):
start = time.perf_counter(); query('app.time-out'); warm.append(time.perf_counter() - start)
# The snapshot stays warm across editor activity that cannot change it:
# opening/closing another file, a full build, and an edit once it is saved.
generation = query('app.time-out')['generation']
other = source_dir / 'demo/NotSpring.java'
other_text = other.read_text()
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': other.as_uri(), 'languageId': 'java', 'version': 1, 'text': other_text}}})
assert query('app.time-out')['generation'] == generation, 'opening an editor rebuilt the index'
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': other.as_uri()}}})
request('java/buildWorkspace', True)
assert query('app.time-out')['generation'] == generation, 'closing an editor or building rebuilt the index'
saved_text = other_text + '// saved\n'
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': other.as_uri(), 'languageId': 'java', 'version': 1, 'text': other_text}}})
send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': other.as_uri(), 'version': 2}, 'contentChanges': [{'text': saved_text}]}})
other.write_text(saved_text)
send({'jsonrpc': '2.0', 'method': 'textDocument/didSave', 'params': {'textDocument': {'uri': other.as_uri()}}})
after_save = query('app.time-out')['generation']
assert query('app.time-out')['generation'] == after_save, 'a saved editor keeps rebuilding the index'
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': other.as_uri()}}})
after_close = query('app.time-out')['generation']
assert query('app.time-out')['generation'] == after_close
# A source added on disk still invalidates it.
added = write('demo/Added.java', 'package demo; @org.springframework.boot.context.properties.ConfigurationProperties("added") class Added { int size; }')
send({'jsonrpc': '2.0', 'method': 'workspace/didChangeWatchedFiles', 'params': {'changes': [{'uri': added.as_uri(), 'type': 1}]}})
deadline = time.monotonic() + 30
while not query('added.size')['locations']:
assert time.monotonic() < deadline, 'a new source on disk did not reach the index'
time.sleep(0.2)
# Unsaved changes must replace old keys, and closing must restore disk keys.
changed = source.replace('prefix="app"', 'prefix="edited"').replace('${app.time-out:30}', '${edited.timeout:30}')
send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': file.as_uri(), 'version': 2}, 'contentChanges': [{'text': changed}]}})
edit_start = time.perf_counter()
updated = query('edited.time-out')
edit_ms = (time.perf_counter() - edit_start) * 1000
assert updated['locations'], updated
assert not query('app.nested.url')['locations']
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': file.as_uri()}}})
restored = query('app.nested.url')
assert restored['locations'], restored
cancelled = warmup('cancel')
assert not cancelled['scheduled'] and cancelled['pending'] == 0, cancelled
evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'prewarmSeconds': prewarm_seconds, 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000, 'generations': [generation, after_save, after_close]}
(work / 'evidence.json').write_text(json.dumps(evidence, indent=2))
print(json.dumps(evidence, indent=2))
finally:
(work / 'transcript.json').write_text(json.dumps(transcript, indent=2))
proc.terminate(); proc.wait(timeout=20)
print('Evidence:', work, file=sys.stderr)