Skip to content

Commit 08594cc

Browse files
committed
First outline of a JVM-attaching interceptor
1 parent 9250e42 commit 08594cc

File tree

3 files changed

+131
-1
lines changed

3 files changed

+131
-1
lines changed

src/interceptors/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { ExistingTerminalInterceptor } from './terminal/existing-terminal-interc
2323
import { AndroidAdbInterceptor } from './android/android-adb-interceptor';
2424
import { addShutdownHandler } from '../shutdown';
2525
import { ElectronInterceptor } from './electron';
26+
import { JvmInterceptor } from './jvm';
2627

2728
export interface Interceptor {
2829
id: string;
@@ -70,7 +71,9 @@ export function buildInterceptors(config: HtkConfig): _.Dictionary<Interceptor>
7071

7172
new ElectronInterceptor(config),
7273

73-
new AndroidAdbInterceptor(config)
74+
new AndroidAdbInterceptor(config),
75+
76+
new JvmInterceptor(config)
7477
];
7578

7679
// When the server exits, try to shut down the interceptors too

src/interceptors/jvm.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import * as _ from 'lodash';
2+
3+
import { Interceptor } from '.';
4+
5+
import { HtkConfig } from '../config';
6+
import { spawnToResult, waitForExit } from '../process-management';
7+
import { OVERRIDE_JAVA_AGENT } from './terminal/terminal-env-overrides';
8+
import { reportError } from '../error-tracking';
9+
import { commandExists } from '../util';
10+
11+
type JvmTarget = { pid: string, name: string, interceptedByProxy: number | undefined };
12+
13+
// Check that Java is present, and that it's possible to run list-targets
14+
const isJavaAvailable = commandExists('java').then(async (isAvailable) => {
15+
if (!isAvailable) return false;
16+
17+
const result = await spawnToResult(
18+
'java', [
19+
'-jar', OVERRIDE_JAVA_AGENT,
20+
'list-targets'
21+
]
22+
);
23+
24+
return result.exitCode === 0;
25+
}).catch((e) => {
26+
// This is expected to happen occasionally, e.g. when using Java 8 (which doesn't support
27+
// the VM attachment APIs we need).
28+
console.log("Error checking for JVM targets", e);
29+
return false;
30+
});
31+
32+
export class JvmInterceptor implements Interceptor {
33+
readonly id = 'attach-jvm';
34+
readonly version = '1.0.0';
35+
36+
private interceptedProcesses: {
37+
[pid: string]: number // PID -> proxy port
38+
} = {};
39+
40+
constructor(private config: HtkConfig) { }
41+
42+
async isActivable(): Promise<boolean> {
43+
return await isJavaAvailable;
44+
}
45+
46+
isActive(proxyPort: number | string) {
47+
return _.some(this.interceptedProcesses, (port) => port === proxyPort);
48+
}
49+
50+
async getMetadata(type: 'summary' | 'detailed'): Promise<{
51+
jvmTargets?: { [pid: string]: JvmTarget }
52+
}> {
53+
// We only poll the targets available when explicitly requested,
54+
// since it's a bit expensive.
55+
if (type === 'summary') return {};
56+
57+
const listTargetsOutput = await spawnToResult(
58+
'java', [
59+
'-jar', OVERRIDE_JAVA_AGENT,
60+
'list-targets'
61+
]
62+
);
63+
64+
if (listTargetsOutput.exitCode !== 0) {
65+
reportError(`JVM target lookup failed with status ${listTargetsOutput.exitCode}`);
66+
return { jvmTargets: {} };
67+
}
68+
69+
const targets = listTargetsOutput.stdout
70+
.split('\n')
71+
.filter(line => line.includes(':'))
72+
.map((line) => {
73+
const nameIndex = line.indexOf(':') + 1;
74+
75+
const pid = line.substring(0, nameIndex - 1);
76+
77+
return {
78+
pid,
79+
name: line.substring(nameIndex),
80+
interceptedByProxy: this.interceptedProcesses[pid]
81+
};
82+
})
83+
.filter((target) =>
84+
// Exclude our own attacher and/or list-target queries from this list
85+
!target.name.includes(`-jar ${OVERRIDE_JAVA_AGENT}`)
86+
);
87+
88+
return {
89+
jvmTargets: _.keyBy(targets, 'pid')
90+
};
91+
}
92+
93+
async activate(proxyPort: number, options: {
94+
targetPid: string
95+
}): Promise<void> {
96+
const interceptionResult = await spawnToResult(
97+
'java', [
98+
'-jar', OVERRIDE_JAVA_AGENT,
99+
options.targetPid,
100+
'127.0.0.1',
101+
proxyPort.toString(),
102+
this.config.https.certPath
103+
],
104+
{},
105+
true // Inherit IO, so we can see output easily, if any
106+
);
107+
108+
if (interceptionResult.exitCode !== 0) {
109+
throw new Error(`Failed to attach to JVM, exit code ${interceptionResult.exitCode}`);
110+
} else {
111+
this.interceptedProcesses[options.targetPid] = proxyPort;
112+
113+
// Poll the status of this pid every 250ms - remove it once it disappears.
114+
waitForExit(parseInt(options.targetPid, 10), Infinity)
115+
.then(() => {
116+
delete this.interceptedProcesses[options.targetPid];
117+
});
118+
}
119+
}
120+
121+
// Nothing we can do to deactivate, unfortunately. In theory the agent could do this, unwriting all
122+
// it's changes, but it's *super* complicated to do for limited benefit.
123+
async deactivate(proxyPort: number | string): Promise<void> {}
124+
async deactivateAll(): Promise<void> {}
125+
126+
}

test/integration-test.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ describe('Integration test', function () {
181181
activable('existing-terminal'),
182182
activable('electron', '1.0.1'),
183183
inactivable('android-adb'),
184+
activable('jvm-attach')
184185
]);
185186
});
186187
});

0 commit comments

Comments
 (0)