mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
Adds a Memory tab to the Debug tool window for JVM sessions: loaded classes with instance counts and shallow sizes (with deltas between refreshes), read over the HotSpot attach listener (`GC.class_histogram`), falling back to counting reachable objects through the debugger on JDKs that cannot answer while suspended. While paused, a class's instances, an instance's fields and its referrers are listed through a JDWP link (the Kotlin adapter's path, and a jdtls bundle `java/jvm-inspect` for java-debug). Debug launches started by the IDE get `-XX:+StartAttachListener` unless the user's VM options decide otherwise (not on Windows). New crates: ide-jdwp, ide-jvm-attach, ide-jvm-inspect. The bundled jar rebuilds byte-for-byte from build.py against the pinned JDT LS plugins. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com>
447 lines
18 KiB
Python
447 lines
18 KiB
Python
"""Drive the bundle inside a real JDT LS with java-debug.
|
|
|
|
Usage: OPENIDEA_TEST_JAVA=<jdk>/bin/java[:<jdk>/bin/java...] \
|
|
python3 inspect.py /path/to/jdtls /path/to/com.microsoft.java.debug.plugin-<v>.jar [--heap N]
|
|
|
|
JDT LS runs on the `java` on PATH (JDK 25 for 1.63.0). OPENIDEA_TEST_JAVA lists the debuggee JDKs:
|
|
the attach checks use the first; `classes` runs on the JDK 11 one; `--heap N` counts N objects on the
|
|
JDK 21 one while a java-debug request waits behind the count. Prints OK/FAIL/SKIP per check.
|
|
"""
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
COMMANDS = ['openidea.jvm.vms', 'openidea.jvm.classes', 'openidea.jvm.instances',
|
|
'openidea.jvm.fields', 'openidea.jvm.referrers']
|
|
BREAKPOINT_LINE = 6
|
|
HEAP = '''public class Heap {
|
|
static final class Live { final int v; Live(int v) { this.v = v; } }
|
|
static Object[] keep;
|
|
static volatile int tick;
|
|
static void pause() {
|
|
tick++;
|
|
}
|
|
public static void main(String[] a) throws Exception {
|
|
int n = Integer.parseInt(a[0]);
|
|
keep = new Object[n];
|
|
for (int i = 0; i < n; i++) keep[i] = new Live(i);
|
|
System.out.println("READY " + n);
|
|
while (true) { pause(); Thread.sleep(100); }
|
|
}
|
|
}
|
|
'''
|
|
|
|
args = [a for a in sys.argv[1:]]
|
|
heap = None
|
|
if '--heap' in args:
|
|
i = args.index('--heap')
|
|
heap = int(args[i + 1])
|
|
del args[i:i + 2]
|
|
home, java_debug = (pathlib.Path(a).resolve() for a in args[:2])
|
|
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-jvm-inspect.jar'
|
|
javas = [p for p in os.environ.get('OPENIDEA_TEST_JAVA', '').split(os.pathsep) if p]
|
|
if not javas:
|
|
sys.exit('set OPENIDEA_TEST_JAVA to one or more <jdk>/bin/java, separated by ' + os.pathsep)
|
|
failures = []
|
|
T0 = time.time()
|
|
|
|
|
|
def report(status, name, detail=''):
|
|
print(f'[{time.time() - T0:6.1f}s] {status} {name}' + (f': {detail}' if detail else ''), flush=True)
|
|
if status == 'FAIL':
|
|
failures.append(name)
|
|
|
|
|
|
def check(name, ok, detail=''):
|
|
report('OK' if ok else 'FAIL', name, '' if ok else detail)
|
|
return ok
|
|
|
|
|
|
def major(java):
|
|
out = subprocess.run([java, '-version'], capture_output=True, text=True).stderr
|
|
version = re.search(r'version "([^"]+)"', out).group(1)
|
|
parts = version.split('.')
|
|
return int(parts[1]) if parts[0] == '1' else int(re.match(r'\d+', parts[0]).group())
|
|
|
|
|
|
class Rpc:
|
|
"""Content-Length framed JSON; responses are matched by id so two threads can wait at once."""
|
|
|
|
def __init__(self, read, write):
|
|
self.read, self.write = read, write
|
|
self.lock = threading.Lock()
|
|
self.cv = threading.Condition()
|
|
self.next_id = 0
|
|
self.responses = {}
|
|
self.events = []
|
|
threading.Thread(target=self.pump, daemon=True).start()
|
|
|
|
def pump(self):
|
|
while True:
|
|
header = b''
|
|
while not header.endswith(b'\r\n\r\n'):
|
|
c = self.read(1)
|
|
if not c:
|
|
return
|
|
header += c
|
|
length = int(re.search(rb'Content-Length: (\d+)', header).group(1))
|
|
body = b''
|
|
while len(body) < length:
|
|
chunk = self.read(length - len(body))
|
|
if not chunk:
|
|
return
|
|
body += chunk
|
|
self.receive(json.loads(body))
|
|
|
|
def send(self, message):
|
|
data = json.dumps(message).encode()
|
|
with self.lock:
|
|
self.write(b'Content-Length: %d\r\n\r\n' % len(data) + data)
|
|
|
|
def allocate(self):
|
|
with self.lock:
|
|
self.next_id += 1
|
|
return self.next_id
|
|
|
|
def store(self, key, message):
|
|
with self.cv:
|
|
self.responses[key] = message
|
|
self.cv.notify_all()
|
|
|
|
def wait_for(self, key, timeout, what):
|
|
end = time.time() + timeout
|
|
with self.cv:
|
|
while key not in self.responses:
|
|
if not self.cv.wait(max(0.01, end - time.time())) and time.time() > end:
|
|
raise TimeoutError(what)
|
|
return self.responses.pop(key)
|
|
|
|
def wait_event(self, predicate, timeout, what):
|
|
end = time.time() + timeout
|
|
with self.cv:
|
|
while True:
|
|
for event in self.events:
|
|
if predicate(event):
|
|
self.events.remove(event)
|
|
return event
|
|
if not self.cv.wait(max(0.01, end - time.time())) and time.time() > end:
|
|
raise TimeoutError(what)
|
|
|
|
|
|
class Lsp(Rpc):
|
|
def receive(self, m):
|
|
if 'method' in m and 'id' in m:
|
|
self.send({'jsonrpc': '2.0', 'id': m['id'], 'result': None})
|
|
elif 'method' in m:
|
|
with self.cv:
|
|
self.events.append(m)
|
|
self.cv.notify_all()
|
|
else:
|
|
self.store(m['id'], m)
|
|
|
|
def request(self, method, params, timeout=120):
|
|
rid = self.allocate()
|
|
self.send({'jsonrpc': '2.0', 'id': rid, 'method': method, 'params': params})
|
|
return self.wait_for(rid, timeout, method)
|
|
|
|
def notify(self, method, params):
|
|
self.send({'jsonrpc': '2.0', 'method': method, 'params': params})
|
|
|
|
def command(self, command, *arguments, timeout=120):
|
|
r = self.request('workspace/executeCommand', {'command': command, 'arguments': list(arguments)}, timeout)
|
|
if 'error' in r:
|
|
raise RuntimeError(f'{command}: {r["error"]}')
|
|
return r['result']
|
|
|
|
def inspect(self, name, *arguments, timeout=180):
|
|
return self.command('openidea.jvm.' + name, *arguments, timeout=timeout)
|
|
|
|
|
|
class Dap(Rpc):
|
|
def receive(self, m):
|
|
if m['type'] == 'response':
|
|
self.store(m['request_seq'], m)
|
|
elif m['type'] == 'event':
|
|
with self.cv:
|
|
self.events.append(m)
|
|
self.cv.notify_all()
|
|
elif m['type'] == 'request': # runInTerminal and the like: refuse
|
|
self.send({'seq': 0, 'type': 'response', 'request_seq': m['seq'], 'command': m['command'], 'success': False})
|
|
|
|
def request(self, command, arguments=None, timeout=60):
|
|
rid = self.allocate()
|
|
self.send({'seq': rid, 'type': 'request', 'command': command, 'arguments': arguments or {}})
|
|
return self.wait_for(rid, timeout, command)
|
|
|
|
def start(self, command, arguments):
|
|
rid = self.allocate()
|
|
self.send({'seq': rid, 'type': 'request', 'command': command, 'arguments': arguments})
|
|
|
|
|
|
# resolved: on macOS the temp dir is under the /var symlink, and JDT LS matches canonical URIs
|
|
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-jvm-inspect-')).resolve()
|
|
project = work / 'project'
|
|
(project / 'src').mkdir(parents=True)
|
|
(project / 'src' / 'Heap.java').write_text(HEAP)
|
|
source = str(project / 'src' / 'Heap.java')
|
|
classes = work / 'classes'
|
|
subprocess.run(['javac', '-g', '--release', '8', '-Xlint:-options', '-d', str(classes), source], check=True)
|
|
config = work / 'config'
|
|
config.mkdir()
|
|
system, machine = platform.system(), platform.machine().lower()
|
|
config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux')
|
|
names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name]
|
|
(config / 'config.ini').write_bytes(next(home / n / 'config.ini' for n in names if (home / n / 'config.ini').is_file()).read_bytes())
|
|
|
|
jdtls = 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 / 'data')],
|
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=(work / 'jdtls.err').open('w'))
|
|
|
|
|
|
def write_jdtls(data):
|
|
jdtls.stdin.write(data)
|
|
jdtls.stdin.flush()
|
|
|
|
|
|
lsp = Lsp(jdtls.stdout.read, write_jdtls)
|
|
jvms = [] # debuggees started here
|
|
launched = [] # pids of debuggees java-debug started
|
|
project_name = None
|
|
modules, classpaths = [], []
|
|
|
|
|
|
def free_port():
|
|
s = socket.socket()
|
|
s.bind(('127.0.0.1', 0))
|
|
port = s.getsockname()[1]
|
|
s.close()
|
|
return port
|
|
|
|
|
|
def start_jvm(java, n, xmx='256m'):
|
|
port = free_port()
|
|
p = subprocess.Popen([java, f'-Xmx{xmx}', f'-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:{port}',
|
|
'-cp', str(classes), 'Heap', str(n)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
jvms.append(p)
|
|
for line in p.stdout:
|
|
if line.startswith('READY'):
|
|
break
|
|
threading.Thread(target=lambda: [None for _ in p.stdout], daemon=True).start()
|
|
return p, port
|
|
|
|
|
|
def session():
|
|
port = lsp.command('vscode.java.startDebugSession', timeout=60)
|
|
sock = socket.create_connection(('127.0.0.1', port))
|
|
reader = sock.makefile('rb', buffering=0)
|
|
d = Dap(reader.read, sock.sendall)
|
|
d.request('initialize', {'clientID': 'openidea-jvm-inspect', 'adapterID': 'java', 'linesStartAt1': True,
|
|
'columnsStartAt1': True, 'pathFormat': 'path'})
|
|
return d
|
|
|
|
|
|
def stop_at_breakpoint(d, command, arguments):
|
|
d.start(command, arguments)
|
|
d.wait_event(lambda e: e['event'] == 'initialized', 60, 'initialized')
|
|
d.request('setBreakpoints', {'source': {'path': source}, 'breakpoints': [{'line': BREAKPOINT_LINE}]})
|
|
d.request('configurationDone')
|
|
stopped = d.wait_event(lambda e: e['event'] == 'stopped', 120, 'stopped')
|
|
# Later resumes must not stop again.
|
|
d.request('setBreakpoints', {'source': {'path': source}, 'breakpoints': []})
|
|
return stopped['body']['threadId']
|
|
|
|
|
|
def attach(port):
|
|
d = session()
|
|
thread = stop_at_breakpoint(d, 'attach', {'hostName': '127.0.0.1', 'port': port, 'timeout': 30000, 'projectName': project_name})
|
|
return d, thread
|
|
|
|
|
|
def launch(n):
|
|
d = session()
|
|
thread = stop_at_breakpoint(d, 'launch', {'mainClass': 'Heap', 'projectName': project_name, 'modulePaths': modules,
|
|
'classPaths': classpaths, 'args': str(n), 'cwd': str(project),
|
|
'console': 'internalConsole', 'shortenCommandLine': 'none'})
|
|
pid = d.request('processId')['body']['processId']
|
|
launched.append(pid)
|
|
return d, thread, pid
|
|
|
|
|
|
def end(d):
|
|
try:
|
|
d.request('disconnect', {'terminateDebuggee': True}, 20)
|
|
except Exception as e: # the JVM is killed below anyway
|
|
report('NOTE', 'disconnect failed', repr(e))
|
|
|
|
|
|
def by_port(port, generation=1):
|
|
return {'pid': None, 'host': 'localhost', 'port': port, 'generation': generation}
|
|
|
|
|
|
def ok(reply):
|
|
if 'ok' not in reply:
|
|
raise AssertionError(f'expected ok, got {reply}')
|
|
return reply['ok']
|
|
|
|
|
|
def live(target, limit=1000):
|
|
return ok(lsp.inspect('instances', target, 'Heap$Live', limit))
|
|
|
|
|
|
def check_selection_and_objects():
|
|
first = javas[0]
|
|
(_, p1), (_, p2) = start_jvm(first, 11), start_jvm(first, 13)
|
|
a1, _ = attach(p1)
|
|
a2, a2_thread = attach(p2)
|
|
l, _, pid = launch(1500)
|
|
vms = ok(lsp.inspect('vms'))
|
|
print(' vms:', json.dumps(vms), flush=True)
|
|
check('vms lists the launch by pid and both attaches by port',
|
|
sorted(v.get('remotePort') for v in vms if v.get('pid') is None) == sorted([p1, p2])
|
|
and [v.get('pid') for v in vms].count(pid) == 1, vms)
|
|
launch_target = {'pid': pid, 'host': None, 'port': None, 'generation': 1}
|
|
n = len(live(launch_target, 2000))
|
|
check('launch selected by pid: instances of Heap$Live = min(1500, 1000)', n == 1000, n)
|
|
got = (len(live(by_port(p1))), len(live(by_port(p2))))
|
|
check('two attaches to the same program each get their VM by port: 11 and 13 instances', got == (11, 13), got)
|
|
instance = live(by_port(p1), 1)[0]
|
|
fields = ok(lsp.inspect('fields', by_port(p1), instance['id']))
|
|
v = next((f['value'] for f in fields if f['name'] == 'v'), None)
|
|
check('fields of a Heap$Live instance include v (int)', v is not None and v['kind'] == 'primitive' and v['typeName'] == 'int', fields)
|
|
referrers = ok(lsp.inspect('referrers', by_port(p1), instance['id'], 50))
|
|
expected = [{'value': {'kind': 'array', 'typeName': 'java.lang.Object[]', 'length': 11}, 'via': [f'[{v["text"]}]']}]
|
|
shape = [{'value': {k: r['value'].get(k) for k in ('kind', 'typeName', 'length')}, 'via': r['via']} for r in referrers]
|
|
check('referrers of an instance: the array holding it, via its index', shape == expected, referrers)
|
|
error = lsp.inspect('fields', by_port(p1, 2), instance['id'])
|
|
check('an id from an earlier stop is stale', error.get('error') == 'stale', error)
|
|
error = lsp.inspect('instances', by_port(free_port()), 'Heap$Live', 1)
|
|
check('a port no session uses is vmNotFound', error.get('error') == 'vmNotFound', error)
|
|
error = lsp.inspect('instances', by_port(p1), 'NoSuchClass', 1)
|
|
check('a class that is not loaded is classNotLoaded', error.get('error') == 'classNotLoaded', error)
|
|
a2.request('continue', {'threadId': a2_thread})
|
|
for _ in range(50):
|
|
error = lsp.inspect('instances', by_port(p2), 'Heap$Live', 1)
|
|
if error.get('error') == 'notSuspended':
|
|
break
|
|
time.sleep(0.1)
|
|
check('a running VM is notSuspended', error.get('error') == 'notSuspended', error)
|
|
eleven = next((j for j in javas if major(j) == 11), None)
|
|
if eleven is None:
|
|
report('SKIP', 'classes on JDK 11', 'no JDK 11 in OPENIDEA_TEST_JAVA')
|
|
else:
|
|
if eleven == first:
|
|
port, extra = p1, None
|
|
else:
|
|
_, port = start_jvm(eleven, 17)
|
|
extra, _ = attach(port)
|
|
counts = ok(lsp.inspect('classes', by_port(port)))
|
|
n = {c['name']: c['instances'] for c in counts}.get('Heap$Live')
|
|
expected_n = 11 if extra is None else 17
|
|
check(f'classes on JDK 11 while stopped: Heap$Live = {expected_n}, no zero rows',
|
|
n == expected_n and all(c['instances'] > 0 for c in counts), n)
|
|
if extra:
|
|
end(extra)
|
|
for d in (a1, a2, l):
|
|
end(d)
|
|
time.sleep(1)
|
|
check('vms is empty after the sessions end', ok(lsp.inspect('vms')) == [])
|
|
|
|
|
|
def check_large_heap(n):
|
|
java = next((j for j in javas if major(j) == 21), None)
|
|
if java is None:
|
|
report('SKIP', f'--heap {n}', 'no JDK 21 in OPENIDEA_TEST_JAVA')
|
|
return
|
|
_, port = start_jvm(java, n, '2g')
|
|
d, thread = attach(port)
|
|
frame = d.request('stackTrace', {'threadId': thread})['body']['stackFrames'][0]['id']
|
|
box = {}
|
|
counting = threading.Thread(target=lambda: box.update(r=lsp.inspect('classes', by_port(port), timeout=300)))
|
|
started = time.time()
|
|
counting.start()
|
|
time.sleep(0.5)
|
|
t0 = time.time()
|
|
r = d.request('evaluate', {'expression': 'Heap.keep.length', 'frameId': frame, 'context': 'repl'}, 120)
|
|
waited = time.time() - t0
|
|
counting.join()
|
|
counted = time.time() - started
|
|
check(f'a DAP evaluate sent 0.5 s into classes succeeds (waited {waited:.1f} s; classes took {counted:.1f} s)',
|
|
r['success'] and r['body']['result'] == str(n), r)
|
|
counts = {c['name']: c['instances'] for c in ok(box['r'])}
|
|
check(f'classes counts {n} Heap$Live', counts.get('Heap$Live') == n, counts.get('Heap$Live'))
|
|
timeout = next(v['timeout'] for v in ok(lsp.inspect('vms')) if v['remotePort'] == port)
|
|
check('the request timeout is back to 3000 after classes', timeout == 3000, timeout)
|
|
instance = live(by_port(port), 1)[0]
|
|
referrers = ok(lsp.inspect('referrers', by_port(port), instance['id'], 50))
|
|
check(f'a referrer array of {n} elements is not searched for the index',
|
|
[r['via'] for r in referrers] == [[]] and referrers[0]['value']['length'] == n, referrers)
|
|
timeout = next(v['timeout'] for v in ok(lsp.inspect('vms')) if v['remotePort'] == port)
|
|
check('the request timeout is back to 3000 after referrers', timeout == 3000, timeout)
|
|
end(d)
|
|
|
|
|
|
try:
|
|
uri = project.as_uri()
|
|
r = lsp.request('initialize', {'processId': os.getpid(), 'rootUri': uri, 'capabilities': {'workspace': {'executeCommand': {}}},
|
|
'initializationOptions': {'bundles': [str(java_debug), str(bundle)],
|
|
'settings': {'java': {'project': {'sourcePaths': ['src']}}}},
|
|
'workspaceFolders': [{'uri': uri, 'name': 'project'}]}, timeout=180)
|
|
registered = r['result']['capabilities'].get('executeCommandProvider', {}).get('commands', [])
|
|
if not check('JDT LS registers the bundle commands and java-debug', all(c in registered for c in COMMANDS + ['vscode.java.startDebugSession']),
|
|
[c for c in registered if c.startswith(('openidea', 'vscode.java.start'))]):
|
|
sys.exit(1)
|
|
lsp.notify('initialized', {})
|
|
lsp.wait_event(lambda m: m.get('method') == 'language/status' and m['params'].get('type') in ('ServiceReady', 'Started'), 240, 'ServiceReady')
|
|
lsp.notify('textDocument/didOpen', {'textDocument': {'uri': pathlib.Path(source).as_uri(), 'languageId': 'java', 'version': 1, 'text': HEAP}})
|
|
check('vms is empty before any session', ok(lsp.inspect('vms')) == [])
|
|
for _ in range(30):
|
|
main = lsp.command('vscode.java.resolveMainClass', uri)
|
|
if main:
|
|
break
|
|
time.sleep(2)
|
|
project_name = main[0].get('projectName')
|
|
modules, classpaths = lsp.command('vscode.java.resolveClasspath', main[0]['mainClass'], project_name)
|
|
check_selection_and_objects()
|
|
if heap:
|
|
check_large_heap(heap)
|
|
except Exception as e:
|
|
report('FAIL', 'harness', repr(e))
|
|
finally:
|
|
for p in jvms:
|
|
if p.poll() is None:
|
|
p.kill()
|
|
p.wait(10)
|
|
for pid in launched:
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
try:
|
|
lsp.request('shutdown', None, 10)
|
|
lsp.notify('exit', None)
|
|
jdtls.wait(10)
|
|
except Exception:
|
|
pass
|
|
if jdtls.poll() is None:
|
|
jdtls.kill()
|
|
jdtls.wait(10)
|
|
if failures:
|
|
print(f'kept {work} (JDT LS stderr in jdtls.err, log in data/.metadata/.log)', flush=True)
|
|
else:
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
print('FAILED: ' + ', '.join(failures) if failures else 'ALL OK', flush=True)
|
|
sys.exit(1 if failures else 0)
|