feat(debugger): memory view for a paused JVM (#2648)

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>
This commit is contained in:
ntbang0901
2026-11-11 17:10:21 +08:00
committed by GitHub
parent 0ff3f188de
commit a3d1e57f3e
125 changed files with 16949 additions and 307 deletions
+9
View File
@@ -0,0 +1,9 @@
Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: OpenIDEA JVM inspect
Bundle-SymbolicName: ai.openidea.jvminspect;singleton:=true
Bundle-Version: 1.0.0
Bundle-RequiredExecutionEnvironment: JavaSE-21
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.core.runtime,
org.eclipse.equinox.common,org.eclipse.jdt.debug
+101
View File
@@ -0,0 +1,101 @@
# JVM inspection inside java-debug
An OSGi bundle loaded into JDT LS next to microsoft java-debug. While a Java debug session is
suspended, it lists the instances of a class, the fields of an object and the objects that refer to
it, and counts the instances of every loaded class. It works on java-debug's own JDI connection to
the debuggee, so no second debugger connects to the JVM.
## Commands
Every command but `openidea.jvm.vms` takes `target` first:
`{"pid": n|null, "host": s|null, "port": n|null, "generation": n}`. A launch session is found by
`pid` (the process java-debug started); an attach session by the remote port of java-debug's socket,
then by `host` when several VMs share that port. Two JVMs running the same program have the same
thread ids and names, so threads are never used to tell sessions apart.
| Command | Arguments after `target` | `ok` |
| ------------------------ | ------------------------ | ---------------------------------------------------------------------------------- |
| `openidea.jvm.vms` | none (no `target`) | `[{pid, remoteHost, remotePort, localPort, version, canGetInstanceInfo, timeout}]` |
| `openidea.jvm.classes` | none | `[{name, instances}]`, classes with no instances left out |
| `openidea.jvm.instances` | `className`, `limit` | `[JvmValue]`, at most 1000 |
| `openidea.jvm.fields` | `objectId` | `[{name, value}]`; the first 100 elements, as `"[i]"`, of an array |
| `openidea.jvm.referrers` | `objectId`, `limit` | `[{value, via}]`, at most 200; `via` lists the fields or `"[i]"` slots |
A reply is `{"ok": payload}` or `{"error": code, "detail": text}`, `code` being one of
`unsupported`, `vmNotFound`, `stale`, `collected`, `classNotLoaded`, `notSuspended`, `timedOut`.
Anything else (a malformed argument, a JDI error the bundle does not expect) is an ordinary
`executeCommand` error. Values have the JSON shape of `ide_jvm_inspect::JvmValue`. JDT LS writes
replies with Gson, which leaves out `null` members: an attached VM has no `pid`, and a long string
no `text`.
- Data is read only while some thread of the VM is suspended (`notSuspended` otherwise), one command
per VM at a time.
- An `objectId` is valid only in the `generation` (the session's stop) it was returned in; any other
is `stale`. Each VM keeps a table of the objects the bundle handed out, emptied when the generation
changes or when it grows past 100 000 entries.
- Previews follow the JDWP link's rules: arrays give only their length, boxed types the primitive in
their `value` field, a `String` the length of its backing array (by `coder`) and its text only up
to 1000 characters. Nothing is invoked: no `toString`, no `invokeMethod`.
- A referrer array longer than 10 000 elements is not searched for the slot (`via` is empty).
- An object collected before its preview is left out of `instances` and `referrers`.
`classes` and `referrers` can take seconds on a large heap. Eclipse JDI fails a request after
`getRequestTimeout()` (3000 ms by default), and the timeout is per VM: java-debug's own requests
waiting behind the command time out too. These two commands raise it to at least 60 000 ms and,
when done, put back the old value unless something else changed it meanwhile (java-debug applies its
timeout setting to every VM when it changes).
## Internals it depends on
java-debug is not pinned: the app installs the latest vscode-java-debug. The bundle reaches the VMs
through these non-API members, by reflection:
- `com.microsoft.java.debug.plugin.internal.JdtVirtualMachineManagerProvider#getVirtualMachineManager()`
- `AdvancedVirtualMachineManager.connectedVMs` (a synchronized list)
- `org.eclipse.jdi.internal.VirtualMachineImpl#packetReceiveManager()`
- `org.eclipse.jdi.internal.connect.PacketManager.fConnection`
- `org.eclipse.jdi.internal.connect.SocketConnection.fSocket`
and, as API of `org.eclipse.jdt.debug`, `org.eclipse.jdi.VirtualMachine#getRequestTimeout` /
`#setRequestTimeout` and `org.eclipse.jdi.TimeoutException`. When any of these is missing or
changes shape, every command answers `unsupported`; nothing throws into java-debug.
Checked with JDT LS 1.63.0.202610191322 (`org.eclipse.jdt.debug` 3.27.0) and java-debug plugin
0.53.2 (vscode-java-debug 0.60.0).
## Rebuild
Build dependency: [JDT LS 1.63.0 build 202610191322](https://download.eclipse.org/jdtls/milestones/1.63.0/jdt-language-server-1.63.0-202610191322.tar.gz),
the same as `java/java-usages`. Check the extracted distribution from its root
(`sha256sum -c /path/to/build-dependencies.sha256`), then with Java 25 on `PATH`:
```sh
python3 build.py /path/to/jdtls # writes openidea-jvm-inspect.jar
python3 build.py /path/to/jdtls --test # compiles tests/SelfTest.java with the sources and runs it
```
The build uses the distribution's ECJ and plugin jars plus the `jdimodel.jar` nested in
`plugins/org.eclipse.jdt.debug_*.jar`, sorted inputs and fixed zip timestamps; bytecode targets
Java 21. Build twice and compare: two builds of the checked-in jar gave
```
ab53d0e6b9e492b5c3be1179c793c879db94ba44a421bdbd4f2f33319e03464a openidea-jvm-inspect.jar
```
Change `Bundle-Version` whenever the jar changes: OSGi caches bundles by version.
## Real JDT LS check
`tests/inspect.py` starts JDT LS with java-debug and this jar, debugs a small `Heap` program (a launch
and two attaches to the same program) and prints `OK`/`FAIL`/`SKIP` per check:
```sh
PATH=/path/to/jdk25/bin:$PATH \
OPENIDEA_TEST_JAVA=/path/to/jdk11/bin/java:/path/to/jdk21/bin/java \
python3 -I tests/inspect.py /path/to/jdtls /path/to/com.microsoft.java.debug.plugin-0.53.2.jar --heap 10000000
```
`OPENIDEA_TEST_JAVA` lists debuggee JDKs (`:`-separated; `;` on Windows). The attaches use the
first; `classes` is checked on the JDK 11 one. With `--heap N`, a JDK 21 JVM holds N objects and a
DAP `evaluate` sent 0.5 s after `classes` starts must succeed, with the timeout back to 3000 after.
It stops every process it started.
+110
View File
@@ -0,0 +1,110 @@
beebede8db065fe1b72909ecc66bb49acda618901635d86c25fce14a5915e37e plugins/ch.qos.logback.classic_1.6.3.jar
a6967a28c8b086dee75a694d2f6fb8830c71153539866d7d8af065c9b6df91e0 plugins/ch.qos.logback.core_1.6.3.jar
2cbd119bf1961c28788310963dc80ba65f58cdeec1dd139c8bdb1240faa2c36f plugins/com.google.gson_2.14.0.jar
cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb plugins/com.google.guava.failureaccess_1.0.3.jar
796d8e28ac64e83a47c4c5935a8fecc4682650a04bbdead738ef0f5a3a0e6c46 plugins/com.google.guava_33.7.1.jre.jar
3b3864f5b449e9c3c24b16861524b622b086563f44e0cd8384c8efc5a6052f82 plugins/com.sun.jna.platform_5.19.1.jar
467b2990d4f406e7d41f7a989f89df257767ca11d566f844560e2ab8d09f6433 plugins/com.sun.jna_5.19.1.v20260612-1000.jar
85fb03fc054cdf4efca8efd9b6712bbb418e1ab98241c4539c8585bbc23e1b8a plugins/jakarta.annotation-api_1.3.5.jar
a852b0971c589491e24334bd77e92760c048d2c102fc4c68e582e1cd53130f84 plugins/jakarta.inject.jakarta.inject-api_1.0.5.jar
2506fb25a748d4a5e5ec549d8bb063889d0f213e9cfbd6fd6fcbdc93b4f35bbb plugins/jakarta.servlet-api_6.2.0.M2.jar
cbf2aa09c14441d2ddea7096f60b738b3aee7f08fbf98f5f91e2ca48b08fff4a plugins/org.apache.ant_1.10.18.v20260906-1000.jar
8f7f8605d68e15bf32db61ec94eac6fdafc51b1bdbe1e0e0802b57d23f387792 plugins/org.apache.commons.cli_1.11.0.jar
78a5d732fbd715e2d10bd7150d2f8030bae57267f8aacc5c88f642cb6c2e5d3f plugins/org.apache.commons.commons-codec_1.22.1.jar
69e5c9fa35da7a51a5fd2099dfe56a2d8d32cf233e2f6d770e796146440263f4 plugins/org.apache.commons.lang3_3.20.0.jar
c5bd887c8fd5ec4cd563249e7552bb1a1a1ca57e05a1f1ef8425a7b7b7bd2fbe plugins/org.apache.felix.scr_2.2.18.jar
c375b6d5b71efe179e5763a6d83fcf10e303c0bf82031310a46769727ac90da0 plugins/org.commonmark.ext-gfm-tables_0.30.1.jar
9b0d20064975a17e876fcb4b599c6b3667f46a7739ed61e2f06c96f44b50629f plugins/org.commonmark_0.30.1.jar
a6a16f8f2b6977a10f951ab4ff46b155e4e20dd8c2fcd2cb835b74d90f30b456 plugins/org.eclipse.ant.core_3.7.1100.v20260831-0701.jar
61c479b6544db7eeebb5ba796051c092032d03a5136295baabd72a233baef29c plugins/org.eclipse.buildship.compat_3.1.13.v20260923-0232-s.jar
213baab808816e0661acd61a5382a8f120923288212defbd189b4ee6540ecce1 plugins/org.eclipse.buildship.core_3.1.13.v20260923-0232-s.jar
e75b07863128a076989d15e815d63a49cab772a338f1f0444d007bef3fa94601 plugins/org.eclipse.compare.core_3.8.900.v20260731-0809.jar
01425989976dccd56d348b207e11239b3d8bf572f7b7ef12dbd29eb39c40205e plugins/org.eclipse.core.commands_3.13.100.v20260902-1129.jar
a63b488fb9d5382fbb3b8bae0951f1da5fd3a20211aa6814c7e8c1e21d2ffc78 plugins/org.eclipse.core.contenttype_3.9.1000.v20260921-1824.jar
06a18236ae181659e1e5499d912d68de527720686f1f51dab632d3e298e32b78 plugins/org.eclipse.core.expressions_3.9.700.v20260710-0925.jar
422a33041ea01954f48ff3969e2c6d96e64831b3e372202a4d2a914282e3ffd9 plugins/org.eclipse.core.filebuffers_3.9.0.v20261010-1449.jar
d0f5e3fb7cd17bb8daebc684311198138e380e089292477f12d2dfbb4c8a30a8 plugins/org.eclipse.core.filesystem_1.12.0.v20260927-0940.jar
941b3c5feeba4ec0a949a8a94ff10283b2505813fa736c9e6345f146ae1b7e8a plugins/org.eclipse.core.jobs_3.15.900.v20260604-0622.jar
6e7a8db7d87a44bb35924bb345ddf0d9372413e3fee2edd842b50ed630ec254f plugins/org.eclipse.core.net_1.5.900.v20260507-0401.jar
e22d47e2e68713a74bfacd134dc01b1aa904a1b429769241483e058de166acea plugins/org.eclipse.core.resources_3.25.0.v20261010-1447.jar
0947f29f5c5b7d234e3b735de2d00d33ae6e3ee428a61bb818f9c0fe2b809735 plugins/org.eclipse.core.runtime_3.35.100.v20260910-1515.jar
553f09a59f2662fa4a3bbc94e84bd83221c1c6fb532d81b57010162fb47460a6 plugins/org.eclipse.core.variables_3.6.900.v20260602-0702.jar
a4e87f5e883e6c864783dc8148e9f915c3e7a9e3e0e26acf6b82210d1134ed79 plugins/org.eclipse.debug.core_3.24.100.v20261009-1915.jar
fd353398835107608f554779a6e91891725efb94e0f57559d0ab3cea4b6156c7 plugins/org.eclipse.equinox.app_1.7.600.v20251211-1038.jar
28749ec64dac9d0e20e00becc3d5f9daccb5637455c9c546ab07be324011ef84 plugins/org.eclipse.equinox.common_3.21.100.v20260829-1451.jar
9beeb1098a3da89a470c547aa840dea0d240d144138a5ba7b9ceedb84ed301ab plugins/org.eclipse.equinox.frameworkadmin.equinox_1.3.500.v20260928-0506.jar
f2b956930b8383d708f1fe9df92938bffbe2e129082d4fd0b3d5f59c20a5c9f0 plugins/org.eclipse.equinox.frameworkadmin_2.3.600.v20260306-0554.jar
8e889f92718fb8c612dd358265da2c94738bbc08b46473b81cad36f1177a8235 plugins/org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.1700.v20260913-1737.jar
347674eb3d3d4000c184573618241141297fb9586c8f65ebdbacace59b8c81a2 plugins/org.eclipse.equinox.launcher.cocoa.macosx.x86_64_1.2.1700.v20260913-1737.jar
57e8a4b46906751e214dfa8fab1e02462c40ad4e51f2226d9ef70af5f0941ccb plugins/org.eclipse.equinox.launcher.gtk.linux.aarch64_1.2.1700.v20260913-1737.jar
d0e7418232627c53288e566728d24d1578650514152992b87f82a5d982429606 plugins/org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.1700.v20260913-1737.jar
aa8c31a082b25b87f75008548f6544379174c98091934fb8399e3f917a0445f1 plugins/org.eclipse.equinox.launcher.win32.win32.x86_64_1.3.200.v20260913-1737.jar
9b3b1da60813ac5fa09dd5c198f1884be7076d0a5daec54faa3f351a787e34b7 plugins/org.eclipse.equinox.launcher_1.8.0.v20260804-1928.jar
fd04e81a28ab32b6bb073286eb293c44b760176fcf1e35ef15614d62df820f86 plugins/org.eclipse.equinox.preferences_3.12.100.v20251111-0704.jar
9b149d3367d33fc1f9a19d3f87c425ecf9fc235ee748b3cf200ec387169b042b plugins/org.eclipse.equinox.registry_3.12.700.v20260830-1519.jar
60388d163f70329c39a9cf4e32389ec4fba8dc80ae5518d97cd0be2ebb971510 plugins/org.eclipse.equinox.security.linux_1.2.0.v20260914-1851.jar
a8408d4bcdc70e019d2d89ed8b1b0413eb74ea73e74fa3d064b7401643539797 plugins/org.eclipse.equinox.security.macosx_1.102.500.v20250521-0414.jar
cf9668c1d1e481059366abc2719468aa52e1276475cf9c32d4d10f57b34d7182 plugins/org.eclipse.equinox.security.win32_1.3.0.v20240419-2334.jar
91b74719f9c1a130193407808fe5daab3b92431efbf30b8f886f1ff5e97f468f plugins/org.eclipse.equinox.security_1.4.700.v20250622-1644.jar
1a24a3c0fb01ad71f97432d5c2b364d9868b5a5ebd99a1308aacb1a2c03c47da plugins/org.eclipse.equinox.simpleconfigurator.manipulator_2.3.700.v20260306-0556.jar
d250938d3715529a9822fcdf46ad2f5de39720558d7195b5c1a8713af00bcd13 plugins/org.eclipse.equinox.simpleconfigurator_1.5.700.v20251111-1031.jar
db9a4e856bb4728a2719df544b0f2ccef52b54ebf4e643a417fdb471f85703c1 plugins/org.eclipse.equinox.spi_1.0.0.v20261009-1945.jar
7615ee7abdd14695957a52864c57869018dc10ddaac80d3cd24b6a97e05d0d9f plugins/org.eclipse.jdt.apt.core_3.8.1000.v20261012-1009.jar
ea365808514a22c2e5a1a3fe0b9c07b88a371094d5d5716d31906a8818d56b2a plugins/org.eclipse.jdt.apt.pluggable.core_1.4.800.v20260930-1106.jar
f667bb3666d9875a2309fa2447a19e6cdd960281473bd7125a22c5a435246d21 plugins/org.eclipse.jdt.core.compiler.batch_3.46.200.v20261008-1115.jar
a6d0f8fabd64c248c37c463789a1a13f801252b68b0a4f4808c207677b5cae17 plugins/org.eclipse.jdt.core.javac_1.0.0.z20260430-1959.jar
8ea98fcbecbd89f311849e448f4c4274a2aba5e15cfcdcad29951330c40e0ee8 plugins/org.eclipse.jdt.core.manipulation_1.26.0.v20261009-1526.jar
70b4bb168aab096ee7f33da7b72f470f4172fe3e18e80289dd89cf344937ed22 plugins/org.eclipse.jdt.core_3.48.0.v20261007-1246.jar
12113df750bb22f7ef0dd3f044f7c334feecd55ddb56b9340529899c55f5893b plugins/org.eclipse.jdt.debug_3.27.0.v20261012-1250.jar
f140fad97dc5c452af0cb0f87ed3c6c83ff518f1dda49a43b8b9bec07f17d0fd plugins/org.eclipse.jdt.junit.core_3.16.0.v20261008-2002.jar
4ea3fe4a12a0e1e8b10b7ce3f577a0ff4f5115c6a5e40364c53b8b329a8505b0 plugins/org.eclipse.jdt.junit.runtime_3.8.200.v20260911-1643.jar
8b928263817f5dd0a9210a8842682903f779dc83253fea501591e150e5303095 plugins/org.eclipse.jdt.launching_3.25.0.v20261010-0421.jar
6e5e5e39d82e9ac04925e8b3f432ef6fe81284441e99952d2f720779c9e2831b plugins/org.eclipse.jdt.ls.core_1.63.0.202610191322.jar
666cc544f8a63e41d2dc8e6f3626071214027e4f698dea5bf36137f85477bba4 plugins/org.eclipse.jdt.ls.filesystem_1.63.0.202610191322.jar
792f7df214d4ab5e4915ac1805922d44de61ebb880f1fc4881ecd4ecfdca6aca plugins/org.eclipse.jdt.ls.logback.appender_1.63.0.202610191322.jar
9647feb0524bf763c878e12ab878a684102b81cccb3f77feecbec709d54f9bbb plugins/org.eclipse.lsp4j.jsonrpc_1.0.0.v20260209-1721.jar
ccd78893facc6bfcc359d56cba05d3d5b85eb41e4c40d4b4215ca45db5f416d9 plugins/org.eclipse.lsp4j_1.0.0.v20260209-1721.jar
a5d6bdc3e6f5017f19375697c7a5929fbc83f5734f35d5ed4540c7b2c843d4bb plugins/org.eclipse.ltk.core.refactoring_3.17.0.v20261010-1449.jar
69d1b41401686fd99337e1ddd8aeec309255ae464393ca19a8025c85bfae6601 plugins/org.eclipse.m2e.apt.core_2.3.200.20260815-0704.jar
34872f7712dffca0b39804708c0c1b58f2f2576e27cb7176b92a1ea437d384e6 plugins/org.eclipse.m2e.core_2.8.0.20260815-0704.jar
8c3fa36fe9f62ace897b0196df8e6bba6a50610c186f6689ada5f386a8b223b1 plugins/org.eclipse.m2e.jdt_2.5.300.20260813-1359.jar
5c5b419a101210127f21fa7d37bd2605b90f96efe9d3e91ea31099fb243dec8e plugins/org.eclipse.m2e.maven.runtime_3.9.1601.20260722-2149.jar
53376fa2d7bfd2d5166806e74af9576ee3f8f1c1ac916e2cb4d2bb0c3e72c02f plugins/org.eclipse.m2e.workspace.cli_0.4.0.jar
20e09b8f88845480b6ad0b89ac001679b6713592cd0ee4bd78cdb326172eee62 plugins/org.eclipse.osgi.compatibility.state_1.3.100.v20260909-1123.jar
fe66fd0b071bebc7e1a122be571ed8ecde373d2966fa6b1e161a899b3c7a054d plugins/org.eclipse.osgi_3.25.0.v20261008-2211.jar
2714e0c7548a0c23ab99d4e84b68d3e67419bbd6b295efda15d881e7cb173a1c plugins/org.eclipse.search.core_3.17.0.v20261010-1449.jar
0ca3b552c867619285b8ae36c7defdd6c0f013e692643d433fd111796da55f19 plugins/org.eclipse.text_3.14.900.v20261012-1310.jar
c6c2b8593cd39c279e74e7055cbd146d39dc6f16011961b4928b92728d32d00d plugins/org.eclipse.xtext.xbase.lib_2.44.0.v20260824-1228.jar
ea7a53f8b7966039c6f7d54f462cbbaaad38a5e313a4e7d9125fa39640988725 plugins/org.gradle.toolingapi_9.7.1.v20260923-0232-s.jar
5d66b6a4a680755cb6ed7cb104fa7835ef644667586ff0737adeb977c39ecdbc plugins/org.hamcrest_3.0.0.jar
e9d8c856856680427f096d156f02a414289985c930998fcb519ce5be39b42003 plugins/org.jsoup_1.23.2.jar
3efac657139b0b8024748e937e3e1dba71fbdf69fb526c60f504a3fb5490381b plugins/org.junit_4.13.2.v20240929-1000.jar
ed825d10ab1399c8c0cb669e688cf0c8c82629b4c8399b58352b68e92ca10fcb plugins/org.objectweb.asm_9.10.1.jar
0653852deab5c6f6e9c84e3aa8f72e72b9ce7dfaf7a6431f3d80f6f9d7a433ad plugins/org.osgi.service.component_1.5.1.202212101352.jar
43c7c870710e363405d422da653cce0d798a4537f76e4930f79bceadd3a55345 plugins/org.osgi.service.prefs_1.1.2.202109301733.jar
208819c7c71690c15a6bb8b187474e7f9d0147946b680182a62b9f222ae014ec plugins/org.osgi.util.function_1.2.0.202109301733.jar
7053c57e7d7d88fec6b90979a3af125e1d2bb847268a328a2f1ed65ad0a4c185 plugins/org.osgi.util.promise_1.3.0.202212101352.jar
7e1446499b359675d8aabe6af2de86b85e72ae8a2a932c0b40d0e5ba57097439 plugins/slf4j.api_2.0.20.jar
3970dab20b4deb8380ed9656392f289f3f3c2ee882d8c3942f1b5b486284620d plugins/wrapped.com.jetbrains.intellij.java.java-decompiler-engine_253.29346.240.jar
65d87386b7e926c0b6fb330a8d2eb6a987f5949634f7574731c0a3c2329d5629 plugins/wrapped.com.vladsch.flexmark.flexmark-ext-emoji_0.64.8.jar
7a350f43f858da8f4a230552607ced119aeaf97484ca11ce4cd792e1e41e5bf7 plugins/wrapped.com.vladsch.flexmark.flexmark-ext-gfm-strikethrough_0.64.8.jar
7a424dae7df0ee86200a08e163d9d1d8f63cd7a1a0c980b84ccbec029571fb4e plugins/wrapped.com.vladsch.flexmark.flexmark-ext-ins_0.64.8.jar
75a2e7168145f799a039832708aa0bc277a954c4a367f58711ccd6b9b7e7d04e plugins/wrapped.com.vladsch.flexmark.flexmark-ext-superscript_0.64.8.jar
dbe6355ea9e04731d534d3d4151870f83684c7a44c87d541a2c53ef5ee2c4fa4 plugins/wrapped.com.vladsch.flexmark.flexmark-ext-tables_0.64.8.jar
91ae2f77e832d20607fad5b7f407d438ca3f16ac032b4f14956e4e7eb3a60931 plugins/wrapped.com.vladsch.flexmark.flexmark-ext-wikilink_0.64.8.jar
0b650a4b47d29ffe0a08802c5d58e2270f733ce5443420c0e8904cfa1605964f plugins/wrapped.com.vladsch.flexmark.flexmark-html2md-converter_0.64.8.jar
74bab2d767f48f930471c9a72a0c3b89e9073549ad3754e6b7f83f09585463bc plugins/wrapped.com.vladsch.flexmark.flexmark-jira-converter_0.64.8.jar
1c7d205afeef618760a5fa907d579835df625ee258cbebd49813f14c2254a535 plugins/wrapped.com.vladsch.flexmark.flexmark-util-ast_0.64.8.jar
9179310212700753a4ecdc7aa057a04591b9cfdb089787a248e8269c34740310 plugins/wrapped.com.vladsch.flexmark.flexmark-util-builder_0.64.8.jar
796b9acec9af0fa290725a14be8a3fc1d6fe7a9cb518a13f67124b5172a3485b plugins/wrapped.com.vladsch.flexmark.flexmark-util-collection_0.64.8.jar
20458c20bc72808e1718ff8f1652272bbb32f62ddaecb2e9a0ddc84d77fd7283 plugins/wrapped.com.vladsch.flexmark.flexmark-util-data_0.64.8.jar
b59fbc922e3604442ff6aae1c6b83220245ba4c3f89d38870bc28c982b69d01d plugins/wrapped.com.vladsch.flexmark.flexmark-util-dependency_0.64.8.jar
b6073ee177aa2476d64787f9ec813fdb881943d402c195df0631ba672bfb1311 plugins/wrapped.com.vladsch.flexmark.flexmark-util-format_0.64.8.jar
c892e6cdbba715c55462decbe1c83cec432548cf2877cfd0715975c44dfab55c plugins/wrapped.com.vladsch.flexmark.flexmark-util-html_0.64.8.jar
1726c33ee95f77cfadc58c8ad27949ed72653f4045fbc399aff62827b44c7fcd plugins/wrapped.com.vladsch.flexmark.flexmark-util-misc_0.64.8.jar
cd0f9212530aa73f67de94d080018b2c3a3a55d41050d3abb4be8afb69989be0 plugins/wrapped.com.vladsch.flexmark.flexmark-util-options_0.64.8.jar
340167af15da00e3ae5fb89d6b44751abfd648951bb6d2e05c48287d9f9e70c9 plugins/wrapped.com.vladsch.flexmark.flexmark-util-sequence_0.64.8.jar
a8fad4a006411d020624c7539e6145e97920067844acddc10b019a178a680948 plugins/wrapped.com.vladsch.flexmark.flexmark-util-visitor_0.64.8.jar
792c13bc5536704fc0bfba9120e5bac712843a7ae6da6104b1392b382feaf63d plugins/wrapped.com.vladsch.flexmark.flexmark-util_0.64.8.jar
a2ec63056bcfe8c4babc62f13f5f49b502fccccf7c011b5d4ba7dd93f4d09393 plugins/wrapped.com.vladsch.flexmark.flexmark_0.64.8.jar
482b6aade7b0320e28db530a22f1382b3d30addf26a5ffd55a347f5c32f6dfc2 plugins/wrapped.org.jetbrains.annotations_24.0.1.jar
+39
View File
@@ -0,0 +1,39 @@
"""Reproducible bundle build: python3 build.py /path/to/jdtls [--test] (JDT LS 1.63.0, Java 25; target Java 21).
--test compiles tests/SelfTest.java with the sources and runs it; it does not write the jar."""
import os
import pathlib
import subprocess
import sys
import tempfile
import zipfile
def run(command):
code = subprocess.run(command).returncode
if code:
sys.exit(f'{command[-1]} failed with exit status {code}')
root = pathlib.Path(__file__).resolve().parent
test = '--test' in sys.argv[1:]
plugins = pathlib.Path(next(a for a in sys.argv[1:] if a != '--test')).resolve() / 'plugins'
with tempfile.TemporaryDirectory() as tmp:
tmp = pathlib.Path(tmp)
# org.eclipse.jdt.debug ships the JDI model as a nested jar, which javac cannot read in place.
with zipfile.ZipFile(next(plugins.glob('org.eclipse.jdt.debug_*.jar'))) as debug:
(tmp / 'jdimodel.jar').write_bytes(debug.read('jdimodel.jar'))
classpath = [str(p) for p in sorted(plugins.glob('*.jar'))] + [str(tmp / 'jdimodel.jar')]
out = tmp / 'classes'
sources = sorted((root / 'src').rglob('*.java')) + ([root / 'tests' / 'SelfTest.java'] if test else [])
run(['java', '-cp', str(plugins / '*'),
'org.eclipse.jdt.internal.compiler.batch.Main', '-21', '-proc:none',
'-classpath', os.pathsep.join(classpath),
'-d', str(out), *[str(p) for p in sources]])
if test:
run(['java', '-cp', os.pathsep.join([str(out), *classpath]), 'ai.openidea.jvminspect.SelfTest'])
sys.exit(0)
with zipfile.ZipFile(root / 'openidea-jvm-inspect.jar', 'w', zipfile.ZIP_DEFLATED) as jar:
files = {'META-INF/MANIFEST.MF': root / 'MANIFEST.MF', 'plugin.xml': root / 'plugin.xml'}
files.update({p.relative_to(out).as_posix(): p for p in out.rglob('*.class')})
for name, path in sorted(files.items()):
jar.writestr(zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)), path.read_bytes())
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<plugin>
<extension point="org.eclipse.jdt.ls.core.delegateCommandHandler">
<delegateCommandHandler class="ai.openidea.jvminspect.InspectCommand">
<command id="openidea.jvm.vms"/>
<command id="openidea.jvm.classes"/>
<command id="openidea.jvm.instances"/>
<command id="openidea.jvm.fields"/>
<command id="openidea.jvm.referrers"/>
</delegateCommandHandler>
</extension>
</plugin>
@@ -0,0 +1,72 @@
package ai.openidea.jvminspect;
import com.sun.jdi.VirtualMachine;
import java.lang.reflect.Field;
import java.net.Socket;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
/**
* java-debug's live VMs, read through its internals. Nothing here is API: every step that no
* longer resolves is {@link Unsupported}, never a crash.
*/
final class ConnectedVms {
static final String PROVIDER = "com.microsoft.java.debug.plugin.internal.JdtVirtualMachineManagerProvider";
static final class Unsupported extends Exception {
private static final long serialVersionUID = 1L;
Unsupported(String detail) {
super(detail);
}
}
private ConnectedVms() {}
/**
* @param loader loads a class of the java-debug bundle by name; {@code null} when it is not there
* @return a copy of {@code AdvancedVirtualMachineManager.connectedVMs}
*/
static List<VirtualMachine> read(Function<String, Class<?>> loader) throws Unsupported {
Class<?> provider = loader.apply(PROVIDER);
if (provider == null) throw new Unsupported(PROVIDER + " not found");
try {
// The provider keeps the manager in a static field, so a new provider returns java-debug's one.
Object manager = provider.getMethod("getVirtualMachineManager").invoke(provider.getDeclaredConstructor().newInstance());
List<?> vms = (List<?>) field(manager.getClass(), "connectedVMs").get(manager);
List<VirtualMachine> out = new ArrayList<>();
// a Collections.synchronizedList that java-debug changes as sessions start and end
synchronized (vms) {
for (Object vm : vms) out.add((VirtualMachine) vm);
}
return out;
} catch (ReflectiveOperationException | RuntimeException e) {
throw new Unsupported(e.toString());
}
}
/** {@code VirtualMachineImpl.packetReceiveManager()} → {@code PacketManager.fConnection} → {@code SocketConnection.fSocket}. */
static Socket socket(VirtualMachine vm) throws Unsupported {
try {
Object receiver = vm.getClass().getMethod("packetReceiveManager").invoke(vm);
Object connection = field(receiver.getClass(), "fConnection").get(receiver);
return (Socket) field(connection.getClass(), "fSocket").get(connection);
} catch (ReflectiveOperationException | RuntimeException e) {
throw new Unsupported(e.toString());
}
}
private static Field field(Class<?> type, String name) throws NoSuchFieldException {
for (Class<?> k = type; k != null; k = k.getSuperclass()) {
try {
Field f = k.getDeclaredField(name);
f.setAccessible(true);
return f;
} catch (NoSuchFieldException e) {
// declared further up
}
}
throw new NoSuchFieldException(type.getName() + "." + name);
}
}
@@ -0,0 +1,42 @@
package ai.openidea.jvminspect;
import java.util.HashMap;
import java.util.Map;
/**
* The objects of one VM that the bundle has handed out, by JDWP id. An id is valid only within the
* stop ({@code generation}) it was returned in.
*/
final class Handles<T> {
static final int MAX = 100_000;
static final class Stale extends Exception {
private static final long serialVersionUID = 1L;
Stale(long id) {
super("object " + id + " is not from this stop");
}
}
private final Map<Long, T> objects = new HashMap<>();
private Long generation;
/**
* Starts a command. A new generation, or a table over {@link #MAX}, starts an empty table; the
* limit is checked here so that one command's own objects never go stale while it runs.
*/
void begin(long generation) {
if (this.generation == null || this.generation != generation || objects.size() > MAX) objects.clear();
this.generation = generation;
}
void put(long id, T object) {
objects.put(id, object);
}
T get(long id) throws Stale {
T object = objects.get(id);
if (object == null) throw new Stale(id);
return object;
}
}
@@ -0,0 +1,231 @@
package ai.openidea.jvminspect;
import ai.openidea.jvminspect.VmSelector.Target;
import ai.openidea.jvminspect.VmSelector.VmInfo;
import com.sun.jdi.ArrayReference;
import com.sun.jdi.ObjectCollectedException;
import com.sun.jdi.ObjectReference;
import com.sun.jdi.ReferenceType;
import com.sun.jdi.ThreadReference;
import com.sun.jdi.VMDisconnectedException;
import com.sun.jdi.VirtualMachine;
import java.net.Socket;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.Platform;
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
import org.osgi.framework.Bundle;
/**
* Lists the instances, fields and referrers of objects in a VM java-debug is connected to. Replies
* are {@code {"ok": payload}} or {@code {"error": code, "detail": text}}; the codes are matched by
* the Rust client.
*/
public final class InspectCommand implements IDelegateCommandHandler {
private static final String JAVA_DEBUG = "com.microsoft.java.debug.plugin";
/** For the commands that can take seconds on a large heap. */
private static final int SLOW_REQUEST_MS = 60_000;
private static final int MAX_INSTANCES = 1000;
private static final int MAX_REFERRERS = 200;
private static final int ARRAY_PREVIEW = 100;
private static final int REFERRER_ARRAY_SCAN = 10_000;
/** One per connected VM; also the lock that runs one command per VM at a time. */
private static final Map<VirtualMachine, Handles<ObjectReference>> HANDLES = new IdentityHashMap<>();
private static final class Failure extends Exception {
private static final long serialVersionUID = 1L;
final String code;
Failure(String code, String detail) {
super(detail);
this.code = code;
}
}
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) {
try {
List<VmInfo> vms = describe(ConnectedVms.read(InspectCommand::loadJavaDebug));
if (command.equals("openidea.jvm.vms")) return ok(summaries(vms));
Target target = Target.of(arguments.get(0));
VirtualMachine vm = VmSelector.select(vms, target)
.orElseThrow(() -> new Failure("vmNotFound", "no single VM matches " + target)).vm();
Handles<ObjectReference> handles = handles(vm);
synchronized (handles) {
if (!vm.canGetInstanceInfo()) throw new Failure("unsupported", "the VM cannot list instances");
if (vm.allThreads().stream().noneMatch(ThreadReference::isSuspended)) {
throw new Failure("notSuspended", "no thread is suspended");
}
handles.begin(target.generation());
return ok(switch (command) {
case "openidea.jvm.classes" -> classes(vm);
case "openidea.jvm.instances" -> instances(vm, handles, (String) arguments.get(1), number(arguments.get(2)).intValue());
case "openidea.jvm.fields" -> fields(handles, handles.get(number(arguments.get(1)).longValue()));
case "openidea.jvm.referrers" -> referrers(vm, handles, handles.get(number(arguments.get(1)).longValue()),
number(arguments.get(2)).intValue());
default -> throw new IllegalArgumentException("unknown command " + command);
});
}
} catch (Failure e) {
return error(e.code, e.getMessage());
} catch (ConnectedVms.Unsupported e) {
return error("unsupported", e.getMessage());
} catch (Handles.Stale e) {
return error("stale", e.getMessage());
} catch (ObjectCollectedException e) {
return error("collected", String.valueOf(e.getMessage()));
} catch (org.eclipse.jdi.TimeoutException e) {
return error("timedOut", String.valueOf(e.getMessage()));
} catch (VMDisconnectedException e) {
return error("vmNotFound", "the VM disconnected");
}
}
private static Class<?> loadJavaDebug(String name) {
Bundle bundle = Platform.getBundle(JAVA_DEBUG);
if (bundle == null) return null;
try {
return bundle.loadClass(name);
} catch (ClassNotFoundException e) {
return null;
}
}
private static List<VmInfo> describe(List<VirtualMachine> vms) throws ConnectedVms.Unsupported {
List<VmInfo> out = new ArrayList<>();
for (VirtualMachine vm : vms) {
Socket socket = ConnectedVms.socket(vm);
out.add(new VmInfo(vm, pid(vm), socket.getInetAddress().getHostAddress(), socket.getPort(), socket.getLocalPort()));
}
Set<VirtualMachine> live = Collections.newSetFromMap(new IdentityHashMap<>());
live.addAll(vms);
synchronized (HANDLES) {
HANDLES.keySet().retainAll(live);
}
return out;
}
/** Null for an attached VM, which java-debug did not start. */
private static Long pid(VirtualMachine vm) {
try {
Process process = vm.process();
return process == null ? null : process.pid();
} catch (UnsupportedOperationException e) {
return null;
}
}
private static List<Map<String, Object>> summaries(List<VmInfo> vms) throws ConnectedVms.Unsupported {
List<Map<String, Object>> out = new ArrayList<>();
for (VmInfo v : vms) {
out.add(Values.json("pid", v.pid(), "remoteHost", v.remoteHost(), "remotePort", v.remotePort(), "localPort", v.localPort(),
"version", v.vm().version(), "canGetInstanceInfo", v.vm().canGetInstanceInfo(),
"timeout", RequestTimeout.Knob.of(v.vm()).get()));
}
return out;
}
private static Handles<ObjectReference> handles(VirtualMachine vm) {
synchronized (HANDLES) {
return HANDLES.computeIfAbsent(vm, k -> new Handles<>());
}
}
/** Every loaded class with at least one instance, from one {@code InstanceCounts} for all of them. */
private static List<Map<String, Object>> classes(VirtualMachine vm) throws ConnectedVms.Unsupported {
try (RequestTimeout.Raised raised = RequestTimeout.raise(RequestTimeout.Knob.of(vm), SLOW_REQUEST_MS)) {
List<ReferenceType> all = vm.allClasses();
long[] counts = vm.instanceCounts(all);
List<Map<String, Object>> out = new ArrayList<>();
for (int i = 0; i < counts.length; i++) {
if (counts[i] > 0) out.add(Values.json("name", all.get(i).name(), "instances", counts[i]));
}
return out;
}
}
/** One that was collected before its preview is left out. */
private static List<Map<String, Object>> instances(VirtualMachine vm, Handles<ObjectReference> handles, String className, int limit)
throws Failure {
List<ReferenceType> types = vm.classesByName(className);
if (types.isEmpty()) throw new Failure("classNotLoaded", className);
int wanted = Math.min(limit, MAX_INSTANCES);
List<ObjectReference> objects = new ArrayList<>();
for (ReferenceType type : types) {
int remaining = wanted - objects.size();
// JDI reads a maximum of 0 as "all of them".
if (remaining <= 0) break;
List<ObjectReference> found = type.instances(remaining);
objects.addAll(found.subList(0, Math.min(remaining, found.size())));
}
List<Map<String, Object>> out = new ArrayList<>(objects.size());
for (ObjectReference object : objects) {
try {
out.add(preview(handles, object));
} catch (ObjectCollectedException e) {
// gone between the listing and its preview
}
}
return out;
}
/** The fields, or {@code "[i]"} for the first {@link #ARRAY_PREVIEW} elements of an array. */
private static List<Map<String, Object>> fields(Handles<ObjectReference> handles, ObjectReference object) {
List<Values.Slot> slots = object instanceof ArrayReference array
? Values.elements(array, Math.min(array.length(), ARRAY_PREVIEW))
: Values.instanceFields(object);
List<Map<String, Object>> out = new ArrayList<>(slots.size());
for (Values.Slot slot : slots) out.add(Values.json("name", slot.name(), "value", preview(handles, slot.value())));
return out;
}
/** With the field names or array slots that point at {@code target}; a collected referrer is left out. */
private static List<Map<String, Object>> referrers(VirtualMachine vm, Handles<ObjectReference> handles, ObjectReference target, int limit)
throws ConnectedVms.Unsupported {
int wanted = Math.min(limit, MAX_REFERRERS);
// JDI reads a maximum of 0 as "all of them".
if (wanted <= 0) return List.of();
try (RequestTimeout.Raised raised = RequestTimeout.raise(RequestTimeout.Knob.of(vm), SLOW_REQUEST_MS)) {
List<ObjectReference> found = target.referringObjects(wanted);
List<Map<String, Object>> out = new ArrayList<>();
for (ObjectReference referrer : found.subList(0, Math.min(wanted, found.size()))) {
try {
Map<String, Object> value = preview(handles, referrer);
List<Values.Slot> slots = referrer instanceof ArrayReference array
? (array.length() <= REFERRER_ARRAY_SCAN ? Values.elements(array, array.length()) : List.of())
: Values.instanceFields(referrer);
List<String> via = slots.stream().filter(s -> target.equals(s.value())).map(Values.Slot::name).toList();
out.add(Values.json("value", value, "via", via));
} catch (ObjectCollectedException e) {
// gone before it could be read
}
}
return out;
}
}
private static Map<String, Object> preview(Handles<ObjectReference> handles, com.sun.jdi.Value value) {
return Values.preview(value, object -> {
long id = object.uniqueID();
handles.put(id, object);
return id;
});
}
private static Number number(Object argument) {
return (Number) argument;
}
private static Map<String, Object> ok(Object payload) {
return Values.json("ok", payload);
}
private static Map<String, Object> error(String code, String detail) {
return Values.json("error", code, "detail", detail);
}
}
@@ -0,0 +1,53 @@
package ai.openidea.jvminspect;
import com.sun.jdi.VirtualMachine;
/**
* Raises Eclipse JDI's reply timeout for a slow command. The timeout is per VM, so java-debug's own
* requests that wait behind the command get it too, and do not fail either.
*/
final class RequestTimeout {
interface Knob {
int get();
void set(int ms);
static Knob of(VirtualMachine vm) throws ConnectedVms.Unsupported {
if (!(vm instanceof org.eclipse.jdi.VirtualMachine eclipse)) {
throw new ConnectedVms.Unsupported(vm.getClass().getName() + " is not an Eclipse JDI VM");
}
return new Knob() {
@Override
public int get() {
return eclipse.getRequestTimeout();
}
@Override
public void set(int ms) {
eclipse.setRequestTimeout(ms);
}
};
}
}
interface Raised extends AutoCloseable {
@Override
void close();
}
private RequestTimeout() {}
/**
* Sets {@code max(current, ms)}. Closing restores the old value only if the timeout is still the
* raised one: java-debug sets it on every VM when its timeout setting changes.
*/
static Raised raise(Knob knob, int ms) {
int old = knob.get();
int raised = Math.max(old, ms);
if (raised == old) return () -> {};
knob.set(raised);
return () -> {
if (knob.get() == raised) knob.set(old);
};
}
}
@@ -0,0 +1,139 @@
package ai.openidea.jvminspect;
import com.sun.jdi.ArrayReference;
import com.sun.jdi.BooleanValue;
import com.sun.jdi.ByteValue;
import com.sun.jdi.CharValue;
import com.sun.jdi.ClassType;
import com.sun.jdi.DoubleValue;
import com.sun.jdi.Field;
import com.sun.jdi.FloatValue;
import com.sun.jdi.IntegerValue;
import com.sun.jdi.LongValue;
import com.sun.jdi.ObjectReference;
import com.sun.jdi.PrimitiveValue;
import com.sun.jdi.ReferenceType;
import com.sun.jdi.ShortValue;
import com.sun.jdi.StringReference;
import com.sun.jdi.Value;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import java.util.function.ToLongFunction;
/**
* One-line previews in the JSON shape of {@code ide_jvm_inspect::JvmValue}, by the rules of the
* JDWP link's preview: they only read, never invoke ({@code toString} or otherwise).
*/
final class Values {
static final int STRING_PREVIEW_CHARS = 1000;
private static final String STRING = "java.lang.String";
private static final Set<String> BOXED = Set.of("java.lang.Integer", "java.lang.Long", "java.lang.Short",
"java.lang.Byte", "java.lang.Character", "java.lang.Boolean", "java.lang.Float", "java.lang.Double");
/** {@code text} is null when {@code length} exceeds {@link #STRING_PREVIEW_CHARS}. */
record StringPreview(int length, String text) {}
/** A field by name, or an array slot as {@code "[i]"}; {@code value} is null for a null reference. */
record Slot(String name, Value value) {}
private Values() {}
/**
* @param arrayLength length of {@code String.value}
* @param coder {@code String.coder} (0 = Latin-1, 1 = UTF-16); null on JDK 8, one char per element
* @param text read only when the string is short
*/
static StringPreview stringPreview(int arrayLength, Integer coder, Supplier<String> text) {
int length = coder == null ? arrayLength : arrayLength >>> Math.min(coder & 0xff, 31);
return new StringPreview(length, length <= STRING_PREVIEW_CHARS ? text.get() : null);
}
/** @param handle registers an object the preview hands out and returns its id */
static Map<String, Object> preview(Value value, ToLongFunction<ObjectReference> handle) {
if (value == null) return json("kind", "null");
if (value instanceof PrimitiveValue p) return json("kind", "primitive", "typeName", p.type().name(), "text", text(p));
ObjectReference object = (ObjectReference) value;
long id = handle.applyAsLong(object);
String type = object.referenceType().name();
if (object instanceof ArrayReference array) return json("kind", "array", "id", id, "typeName", type, "length", array.length());
if (type.equals(STRING)) {
StringPreview s = string(object);
return json("kind", "string", "id", id, "text", s.text(), "length", s.length());
}
if (BOXED.contains(type)) return json("kind", "boxed", "id", id, "typeName", type, "text", boxedText(object));
return json("kind", "object", "id", id, "typeName", type);
}
/** Every instance field, the object's own class first, then each superclass; static fields are skipped. */
static List<Slot> instanceFields(ObjectReference object) {
List<Field> fields = new ArrayList<>();
for (ReferenceType t = object.referenceType(); t != null; t = t instanceof ClassType c ? c.superclass() : null) {
for (Field f : t.fields()) {
if (!f.isStatic()) fields.add(f);
}
}
if (fields.isEmpty()) return List.of();
Map<Field, Value> values = object.getValues(fields);
return fields.stream().map(f -> new Slot(f.name(), values.get(f))).toList();
}
/** The first {@code count} elements, named {@code "[i]"}. */
static List<Slot> elements(ArrayReference array, int count) {
if (count == 0) return List.of();
List<Value> values = array.getValues(0, count);
List<Slot> out = new ArrayList<>(values.size());
for (int i = 0; i < values.size(); i++) out.add(new Slot("[" + i + "]", values.get(i)));
return out;
}
static Map<String, Object> json(Object... keysAndValues) {
Map<String, Object> m = new LinkedHashMap<>();
for (int i = 0; i < keysAndValues.length; i += 2) m.put((String) keysAndValues[i], keysAndValues[i + 1]);
return m;
}
/** Reads the length of the backing array, and the text only when it is short. */
private static StringPreview string(ObjectReference object) {
ClassType type = (ClassType) object.referenceType();
Field value = instanceField(type, "value");
if (value == null) throw new IllegalStateException(STRING + " has no value field");
Field coder = instanceField(type, "coder");
Map<Field, Value> read = object.getValues(coder == null ? List.of(value) : List.of(value, coder));
int arrayLength = read.get(value) instanceof ArrayReference array ? array.length() : 0;
Integer c = coder == null ? null : (int) ((ByteValue) read.get(coder)).value();
return stringPreview(arrayLength, c, () -> ((StringReference) object).value());
}
private static String boxedText(ObjectReference object) {
Field value = instanceField((ClassType) object.referenceType(), "value");
if (value == null) throw new IllegalStateException("a boxed type has no value field");
return text((PrimitiveValue) object.getValue(value));
}
/** Declared in {@code type} itself, not inherited. */
private static Field instanceField(ReferenceType type, String name) {
for (Field f : type.fields()) {
if (f.name().equals(name) && !f.isStatic()) return f;
}
return null;
}
/** As Java prints it; a lone surrogate becomes U+FFFD like the JDWP link's preview. */
private static String text(PrimitiveValue p) {
return switch (p) {
case BooleanValue v -> Boolean.toString(v.value());
case ByteValue v -> Byte.toString(v.value());
case CharValue v -> Character.isSurrogate(v.value()) ? "�" :String.valueOf(v.value());
case ShortValue v -> Short.toString(v.value());
case IntegerValue v -> Integer.toString(v.value());
case LongValue v -> Long.toString(v.value());
case FloatValue v -> Float.toString(v.value());
case DoubleValue v -> Double.toString(v.value());
default -> p.toString();
};
}
}
@@ -0,0 +1,67 @@
package ai.openidea.jvminspect;
import com.sun.jdi.VirtualMachine;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* Finds the VM of one debug session. Thread lists cannot tell two attach sessions apart: two JVMs
* running the same program have the same thread ids and names.
*/
final class VmSelector {
/** The JSON {@code {"pid", "host", "port", "generation"}} the client sends first; pid for launch, host and port for attach. */
record Target(Long pid, String host, Integer port, long generation) {
static Target of(Object json) {
Map<?, ?> m = (Map<?, ?>) json;
Number pid = (Number) m.get("pid");
Number port = (Number) m.get("port");
return new Target(pid == null ? null : pid.longValue(), (String) m.get("host"),
port == null ? null : port.intValue(), ((Number) m.get("generation")).longValue());
}
}
/** {@code vm} is null in tests. */
record VmInfo(VirtualMachine vm, Long pid, String remoteHost, Integer remotePort, Integer localPort) {}
private VmSelector() {}
/** Empty unless exactly one VM matches. */
static Optional<VmInfo> select(List<VmInfo> vms, Target target) {
List<VmInfo> found;
if (target.pid() != null) {
found = vms.stream().filter(v -> target.pid().equals(v.pid())).toList();
} else if (target.port() != null) {
found = vms.stream().filter(v -> target.port().equals(v.remotePort())).toList();
if (found.size() > 1 && target.host() != null) {
Set<InetAddress> addresses = resolve(target.host());
if (addresses != null) found = found.stream().filter(v -> isOneOf(v.remoteHost(), addresses)).toList();
}
} else {
found = List.of();
}
return found.size() == 1 ? Optional.of(found.get(0)) : Optional.empty();
}
/** {@code null} when the name does not resolve, so the port alone decides. */
private static Set<InetAddress> resolve(String host) {
try {
return Set.of(InetAddress.getAllByName(host));
} catch (UnknownHostException | SecurityException e) {
return null;
}
}
/** The socket's remote address is an IP literal, which parses without a lookup. */
private static boolean isOneOf(String address, Set<InetAddress> addresses) {
if (address == null) return false;
try {
return addresses.contains(InetAddress.getByName(address));
} catch (UnknownHostException e) {
return false;
}
}
}
+164
View File
@@ -0,0 +1,164 @@
package ai.openidea.jvminspect;
import ai.openidea.jvminspect.VmSelector.Target;
import ai.openidea.jvminspect.VmSelector.VmInfo;
import java.util.List;
/** The JDI-free logic of the bundle; run with {@code build.py <jdtls> --test}. */
public final class SelfTest {
public static void main(String[] args) throws Exception {
selector_matches_launch_pid();
selector_matches_attach_port_not_threads();
selector_filters_same_port_by_host();
selector_reports_vm_not_found();
connected_vms_missing_field_is_unsupported();
request_timeout_raised_and_restored();
handles_cleared_on_new_generation();
handles_cleared_when_full();
string_preview_rule();
}
/** Stands in for java-debug's provider: its manager has no {@code connectedVMs}. */
public static final class FakeProvider {
public Object getVirtualMachineManager() {
return new Object();
}
}
static void selector_matches_launch_pid() {
List<VmInfo> vms = List.of(vm(10L, "127.0.0.1", 50001), vm(11L, "127.0.0.1", 50002));
check(VmSelector.select(vms, launch(11)).orElseThrow() == vms.get(1), "pid 11 selects the second VM");
pass("selector_matches_launch_pid");
}
static void selector_matches_attach_port_not_threads() {
// G1: two JVMs running the same program have identical thread lists; only the port tells them apart.
List<VmInfo> vms = List.of(vm(null, "127.0.0.1", 59246), vm(null, "127.0.0.1", 59247));
check(VmSelector.select(vms, attach("127.0.0.1", 59247)).orElseThrow() == vms.get(1), "port 59247 selects the second VM");
pass("selector_matches_attach_port_not_threads");
}
static void selector_filters_same_port_by_host() {
List<VmInfo> vms = List.of(vm(null, "127.0.0.1", 5005), vm(null, "10.0.0.5", 5005));
check(VmSelector.select(vms, attach("localhost", 5005)).orElseThrow() == vms.get(0), "localhost selects 127.0.0.1");
pass("selector_filters_same_port_by_host");
}
static void selector_reports_vm_not_found() {
List<VmInfo> distinct = List.of(vm(10L, "127.0.0.1", 5005), vm(null, "127.0.0.1", 5006));
check(VmSelector.select(distinct, launch(12)).isEmpty(), "unknown pid");
check(VmSelector.select(distinct, attach("127.0.0.1", 5007)).isEmpty(), "unknown port");
check(VmSelector.select(distinct, new Target(null, null, null, 1)).isEmpty(), "no pid and no port");
List<VmInfo> twins = List.of(vm(null, "127.0.0.1", 5005), vm(null, "127.0.0.1", 5005));
check(VmSelector.select(twins, attach("localhost", 5005)).isEmpty(), "same port and host is ambiguous");
pass("selector_reports_vm_not_found");
}
static void connected_vms_missing_field_is_unsupported() {
try {
ConnectedVms.read(name -> FakeProvider.class);
throw new AssertionError("a manager without connectedVMs must be unsupported");
} catch (ConnectedVms.Unsupported e) {
check(e.getMessage().contains("connectedVMs"), "detail names the field: " + e.getMessage());
}
try {
ConnectedVms.read(name -> null);
throw new AssertionError("a missing java-debug must be unsupported");
} catch (ConnectedVms.Unsupported e) {
check(e.getMessage().contains(ConnectedVms.PROVIDER), "detail names the class: " + e.getMessage());
}
pass("connected_vms_missing_field_is_unsupported");
}
static void request_timeout_raised_and_restored() {
int[] value = {3000};
RequestTimeout.Knob knob = new RequestTimeout.Knob() {
@Override
public int get() {
return value[0];
}
@Override
public void set(int ms) {
value[0] = ms;
}
};
try (RequestTimeout.Raised raised = RequestTimeout.raise(knob, 60_000)) {
check(value[0] == 60_000, "raised to 60000, was " + value[0]);
}
check(value[0] == 3000, "restored to 3000, was " + value[0]);
try (RequestTimeout.Raised raised = RequestTimeout.raise(knob, 60_000)) {
value[0] = 5000; // java-debug applied a changed setting meanwhile
}
check(value[0] == 5000, "a value changed meanwhile is kept, was " + value[0]);
value[0] = 90_000;
try (RequestTimeout.Raised raised = RequestTimeout.raise(knob, 60_000)) {
check(value[0] == 90_000, "a larger timeout is not lowered, was " + value[0]);
}
check(value[0] == 90_000, "a larger timeout stays, was " + value[0]);
pass("request_timeout_raised_and_restored");
}
static void handles_cleared_on_new_generation() throws Handles.Stale {
Handles<String> handles = new Handles<>();
handles.begin(1);
handles.put(7, "seven");
check("seven".equals(handles.get(7)), "id 7 is known in generation 1");
handles.begin(2);
expectStale(handles, 7, "id 7 from generation 1 in generation 2");
pass("handles_cleared_on_new_generation");
}
static void handles_cleared_when_full() throws Handles.Stale {
Handles<String> handles = new Handles<>();
handles.begin(1);
for (long id = 0; id <= Handles.MAX; id++) handles.put(id, "o");
check("o".equals(handles.get(0)), "a command's own objects stay known while it runs");
handles.begin(1);
expectStale(handles, 0, "a table over the limit is cleared at the next command");
pass("handles_cleared_when_full");
}
static void string_preview_rule() {
Values.StringPreview shortText = Values.stringPreview(2000, 1, () -> "text");
check(shortText.length() == 1000 && "text".equals(shortText.text()), "2000 UTF-16 bytes: 1000 chars, read: " + shortText);
Values.StringPreview longText = Values.stringPreview(2002, 1, () -> {
throw new AssertionError("the text of a long string must not be read");
});
check(longText.length() == 1001 && longText.text() == null, "2002 UTF-16 bytes: 1001 chars, not read: " + longText);
Values.StringPreview latin1 = Values.stringPreview(1000, 0, () -> "latin");
check(latin1.length() == 1000 && "latin".equals(latin1.text()), "Latin-1: one byte per char: " + latin1);
Values.StringPreview jdk8 = Values.stringPreview(5, null, () -> "chars");
check(jdk8.length() == 5 && "chars".equals(jdk8.text()), "no coder (JDK 8): one char per element: " + jdk8);
pass("string_preview_rule");
}
private static VmInfo vm(Long pid, String host, Integer port) {
return new VmInfo(null, pid, host, port, null);
}
private static Target launch(long pid) {
return new Target(pid, null, null, 1);
}
private static Target attach(String host, int port) {
return new Target(null, host, port, 1);
}
private static void expectStale(Handles<String> handles, long id, String what) {
try {
handles.get(id);
throw new AssertionError(what + ": expected stale");
} catch (Handles.Stale expected) {
// the id belongs to an earlier table
}
}
private static void check(boolean ok, String what) {
if (!ok) throw new AssertionError(what);
}
private static void pass(String name) {
System.out.println("PASS " + name);
}
}
+446
View File
@@ -0,0 +1,446 @@
"""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)