-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathDaemonLauncher.java
More file actions
360 lines (336 loc) · 15.6 KB
/
Copy pathDaemonLauncher.java
File metadata and controls
360 lines (336 loc) · 15.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
package io.github.randomcodespace.sonarpredict.cli;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.UnixDomainSocketAddress;
import java.nio.channels.SocketChannel;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import io.github.randomcodespace.sonarpredict.cli.setup.Manifest;
import io.github.randomcodespace.sonarpredict.cli.setup.RuntimeLayout;
import io.github.randomcodespace.sonarpredict.protocol.SocketPaths;
/**
* Locates the daemon's runnable fat jar and spawns it as a child JVM, returning
* only once the daemon's Unix domain socket is accepting connections.
*
* <p><b>Jar resolution.</b> {@link #resolveDaemonJar()} first reads the
* {@code sonar.daemon.jar} system property; that is the supported override and
* the mechanism Plan 7's {@code setup} command will use to point at an
* installed runtime. With no property set it falls back to a dev default — the
* newest {@code daemon/target/sonar-predictor-daemon-*.jar} (the shaded jar,
* never the {@code original-} prefixed unshaded one), located relative to the
* working directory or its ancestors.
*
* <p><b>Determinism.</b> {@link #start()} polls {@link #isDaemonRunning()} —
* an actual connect attempt against the socket — until it succeeds or a bounded
* timeout elapses. It never sleeps for a fixed duration guessing the daemon is
* up. A {@code start()} call when the daemon is already running is a no-op:
* the first connect attempt succeeds and no process is spawned. The spawned
* daemon self-deduplicates too (it detects a live pidfile), so a lost race
* still yields a single daemon.
*
* <p><b>Environment.</b> The child JVM inherits this process's environment with
* {@code XDG_RUNTIME_DIR} forced to the directory holding the socket, so the
* daemon's {@link SocketPaths#resolve()} resolves the exact same socket and
* pidfile this launcher expects.
*
* <p><b>Working directory.</b> The daemon resolves its vendored analyzer
* plugins from a {@code plugins/} directory relative to its working directory.
* {@link #resolveDaemonWorkDir()} reads the {@code sonar.daemon.workdir} system
* property, falling back to the {@code daemon/} module root that holds the
* resolved jar (its {@code daemon/plugins/} is the dev-default plugin set).
*
* <p><b>Java runtime.</b> The daemon launches with the {@code java} named by
* {@code -Dsonar.java.exe} when set — the distribution's {@code bin/sonar}
* launcher sets it to a Java 17+ runtime it auto-discovered. With the property
* absent the daemon launches with the current/system {@code java} that started
* the CLI. No JRE is provisioned or bundled.
*/
public final class DaemonLauncher {
/** System property naming the daemon fat jar; overrides the dev default. */
public static final String DAEMON_JAR_PROPERTY = "sonar.daemon.jar";
/** System property naming the daemon working dir; overrides the dev default. */
public static final String DAEMON_WORKDIR_PROPERTY = "sonar.daemon.workdir";
/**
* System property naming the {@code java} executable used to spawn the
* daemon. The distribution's {@code bin/sonar} launcher sets it to the
* Java 17+ runtime it auto-discovered, so the daemon launches on a
* verified-compatible JVM rather than whichever {@code java} happens to be
* on {@code PATH}. Absent, the launcher falls back to the current JVM's
* {@code java.home} (the dev default).
*/
public static final String JAVA_EXE_PROPERTY = "sonar.java.exe";
/**
* The {@code sonar.plugins.dir} property the daemon reads to locate its
* analyzer plugins. The launcher sets it when a provisioned runtime is
* found. Kept as a literal here because the {@code cli} module must not
* depend on the {@code daemon} module — the two ends agree on the name.
*/
public static final String DAEMON_PLUGINS_DIR_PROPERTY = "sonar.plugins.dir";
/** Default bounded wait for the daemon socket to start accepting. */
public static final Duration DEFAULT_STARTUP_TIMEOUT = Duration.ofSeconds(60);
private static final String DAEMON_JAR_PREFIX = "sonar-predictor-daemon-";
private final SocketPaths paths;
private final Duration startupTimeout;
/** Creates a launcher with the {@link #DEFAULT_STARTUP_TIMEOUT}. */
public DaemonLauncher(SocketPaths paths) {
this(paths, DEFAULT_STARTUP_TIMEOUT);
}
/**
* @param paths the socket/pidfile locations the daemon must use
* @param startupTimeout bounded wait for the socket to begin accepting
*/
public DaemonLauncher(SocketPaths paths, Duration startupTimeout) {
this.paths = Objects.requireNonNull(paths, "paths");
this.startupTimeout = Objects.requireNonNull(startupTimeout, "startupTimeout");
}
/**
* Resolves the daemon fat jar: the {@code sonar.daemon.jar} property if set,
* otherwise the dev-default shaded jar under {@code daemon/target/}.
*
* @return the resolved jar path
* @throws IllegalStateException if no jar can be located
*/
public static Path resolveDaemonJar() {
String override = System.getProperty(DAEMON_JAR_PROPERTY);
if (override != null && !override.isBlank()) {
return Path.of(override);
}
Path jar = findDevDefaultJar();
if (jar == null) {
throw new IllegalStateException(
"could not locate the daemon jar; set -D" + DAEMON_JAR_PROPERTY
+ "=<path> or build the daemon module first");
}
return jar;
}
/**
* Resolves the working directory the daemon JVM runs in — the directory the
* daemon resolves its {@code plugins/} subdirectory against. The
* {@code sonar.daemon.workdir} property wins; otherwise the {@code daemon/}
* module root holding the resolved jar is used.
*
* @return the daemon working directory
* @throws IllegalStateException if no directory containing {@code plugins/}
* can be located
*/
public static Path resolveDaemonWorkDir() {
String override = System.getProperty(DAEMON_WORKDIR_PROPERTY);
if (override != null && !override.isBlank()) {
return Path.of(override);
}
// Dev default: the jar lives at <module>/target/<jar>; the module root
// (<module>) holds the plugins/ directory the daemon needs.
Path jar = resolveDaemonJar();
Path target = jar.toAbsolutePath().getParent();
Path moduleRoot = target != null ? target.getParent() : null;
if (moduleRoot != null && Files.isDirectory(moduleRoot.resolve("plugins"))) {
return moduleRoot;
}
throw new IllegalStateException(
"could not locate the daemon working directory (no plugins/ found near "
+ jar + "); set -D" + DAEMON_WORKDIR_PROPERTY + "=<path>");
}
/** Whether the daemon socket currently accepts a connection. */
public boolean isDaemonRunning() {
try (SocketChannel channel =
SocketChannel.open(UnixDomainSocketAddress.of(paths.socket()))) {
return true;
} catch (IOException notRunning) {
return false;
}
}
/**
* Ensures the daemon is running: returns immediately if it already is,
* otherwise spawns the daemon JVM and blocks until its socket is accepting.
*
* @throws IllegalStateException if the socket is not accepting within the
* startup timeout
*/
public void start() {
if (isDaemonRunning()) {
return;
}
Process process = spawn();
awaitSocket(process);
}
private Process spawn() {
Path jar = resolveDaemonJar();
if (!Files.isRegularFile(jar)) {
throw new IllegalStateException("daemon jar does not exist: " + jar);
}
List<String> command = buildSpawnCommand(jar, resolveProvisionedLayout());
ProcessBuilder builder = new ProcessBuilder(command);
// The daemon resolves its plugins/ directory relative to its working
// directory; run it from the module root that holds plugins/.
builder.directory(resolveDaemonWorkDir().toFile());
// The daemon resolves SocketPaths from its environment; force it onto
// the same runtime directory this launcher expects.
builder.environment().put("XDG_RUNTIME_DIR", paths.socket().getParent().toString());
builder.redirectOutput(ProcessBuilder.Redirect.DISCARD);
builder.redirectError(ProcessBuilder.Redirect.DISCARD);
try {
return builder.start();
} catch (IOException e) {
throw new UncheckedIOException("could not spawn the daemon JVM", e);
}
}
/**
* Builds the JVM command line that launches the daemon.
*
* <p><b>Java runtime.</b> When {@code -Dsonar.java.exe} is set — the skill
* bundle's {@code bin/sonar} launcher sets it to the Java 17+ runtime it
* auto-discovered — the daemon spawns with that executable. Absent, the
* daemon launches with the current JVM's {@code java} (the dev default).
*
* <p><b>Plugins directory resolution.</b> The daemon is told where its
* analyzer plugins live via {@code -Dsonar.plugins.dir}. The directory is
* taken, in order, from: an explicit {@code -Dsonar.plugins.dir} on the
* CLI (the distribution's launcher sets this to {@code <bundle>/plugins});
* else a verified provisioned {@code ~/.sonar/<version>/} runtime created
* by {@code sonar setup}. With neither, the dev default applies: the
* daemon resolves a {@code plugins/} directory relative to its working
* directory.
*
* @param jar the daemon fat jar to run
* @param provisioned a fully provisioned runtime layout, or {@code null}
* @return the command line for {@link ProcessBuilder}
*/
static List<String> buildSpawnCommand(Path jar, RuntimeLayout provisioned) {
List<String> command = new ArrayList<>();
command.add(javaExecutable());
String pluginsDir = resolvePluginsDir(provisioned);
if (pluginsDir != null) {
command.add("-D" + DAEMON_PLUGINS_DIR_PROPERTY + "=" + pluginsDir);
}
command.add("-jar");
command.add(jar.toString());
return command;
}
/**
* Resolves the analyzer-plugin directory to hand the daemon, or
* {@code null} when the daemon should use its working-directory default.
*
* <p>An explicit {@code -Dsonar.plugins.dir} on the CLI wins — that is how
* the distribution's {@code bin/sonar} launcher points at {@code
* <bundle>/plugins}. Otherwise a verified provisioned runtime's plugins
* directory is used.
*
* @param provisioned a verified provisioned layout, or {@code null}
* @return the plugins directory path, or {@code null} for the dev default
*/
private static String resolvePluginsDir(RuntimeLayout provisioned) {
String explicit = System.getProperty(DAEMON_PLUGINS_DIR_PROPERTY);
if (explicit != null && !explicit.isBlank()) {
return explicit;
}
if (provisioned != null) {
return provisioned.pluginsDir().toAbsolutePath().toString();
}
return null;
}
/**
* Resolves the provisioned runtime layout if one is fully in place
* <em>and verified</em> against the bundled manifest's checksums.
*
* <p>A layout is only returned when every engine + plugin jar matches the
* manifest's pinned SHA-256 and the full plugin set is present
* ({@link RuntimeLayout#isVerified}). A tampered, partial, or stale runtime
* is rejected — the launcher falls back to the dev default rather than
* launching unverified artifacts.
*
* @return the verified {@link RuntimeLayout}, or {@code null} when no
* trustworthy provisioned runtime exists (the dev-default applies)
*/
static RuntimeLayout resolveProvisionedLayout() {
try {
Manifest manifest = Manifest.bundled();
RuntimeLayout layout = RuntimeLayout.forVersion(manifest.version());
return layout.isVerified(manifest) ? layout : null;
} catch (RuntimeException notAvailable) {
// No bundled manifest or unreadable runtime — fall back to dev.
return null;
}
}
private void awaitSocket(Process process) {
long deadline = System.nanoTime() + startupTimeout.toNanos();
while (System.nanoTime() < deadline) {
if (isDaemonRunning()) {
return;
}
if (!process.isAlive() && !isDaemonRunning()) {
throw new IllegalStateException(
"daemon process exited (code " + process.exitValue()
+ ") before its socket began accepting connections");
}
try {
Thread.sleep(20);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted while waiting for the daemon", e);
}
}
throw new IllegalStateException(
"daemon socket did not start accepting within " + startupTimeout);
}
private static String javaExecutable() {
String configured = System.getProperty(JAVA_EXE_PROPERTY);
if (configured != null && !configured.isBlank()) {
return configured;
}
Path javaHome = Path.of(System.getProperty("java.home"));
Path java = javaHome.resolve("bin").resolve("java");
return Files.isExecutable(java) ? java.toString() : "java";
}
private static Path findDevDefaultJar() {
Path dir = Path.of("").toAbsolutePath();
while (dir != null) {
// Single-module layout: target/sonar-predictor-daemon-*.jar at the
// project root. Legacy multi-module: daemon/target/... — try both
// so a checkout of either generation still resolves.
for (Path candidate : new Path[] {
dir.resolve("target"),
dir.resolve("daemon").resolve("target") }) {
if (Files.isDirectory(candidate)) {
Path jar = newestDaemonJar(candidate);
if (jar != null) {
return jar;
}
}
}
dir = dir.getParent();
}
return null;
}
private static Path newestDaemonJar(Path targetDir) {
List<Path> candidates = new ArrayList<>();
try (DirectoryStream<Path> entries = Files.newDirectoryStream(targetDir, "*.jar")) {
for (Path entry : entries) {
String name = entry.getFileName().toString();
if (name.startsWith(DAEMON_JAR_PREFIX) && !name.startsWith("original-")) {
candidates.add(entry);
}
}
} catch (IOException e) {
return null;
}
Path newest = null;
long newestTime = Long.MIN_VALUE;
for (Path candidate : candidates) {
try {
long modified = Files.getLastModifiedTime(candidate).toMillis();
if (modified > newestTime) {
newestTime = modified;
newest = candidate;
}
} catch (IOException ignored) {
// Skip a jar we cannot stat.
}
}
return newest;
}
}