mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user