|
1 | 1 | package mill.scalalib.backgroundwrapper;
|
2 | 2 |
|
| 3 | +import java.io.IOException; |
3 | 4 | import java.io.RandomAccessFile;
|
4 | 5 | import java.nio.channels.FileChannel;
|
5 |
| -import java.nio.file.*; |
| 6 | +import java.nio.file.Files; |
| 7 | +import java.nio.file.Path; |
| 8 | +import java.nio.file.Paths; |
| 9 | +import java.nio.file.StandardOpenOption; |
| 10 | +import java.time.LocalDateTime; |
| 11 | +import java.time.format.DateTimeFormatter; |
| 12 | +import java.util.Arrays; |
| 13 | +import java.util.Optional; |
6 | 14 |
|
| 15 | +@SuppressWarnings("BusyWait") |
7 | 16 | public class MillBackgroundWrapper {
|
| 17 | + private static final long NS_IN_S = 1_000_000_000; |
| 18 | + |
8 | 19 | public static void main(String[] args) throws Exception {
|
9 |
| - Path procUuidPath = Paths.get(args[0]); |
10 |
| - Path procLockfile = Paths.get(args[1]); |
11 |
| - String procUuid = args[2]; |
12 |
| - int lockDelay = Integer.parseInt(args[3]); |
| 20 | + var newestProcessIdPath = Paths.get(args[0]); |
| 21 | + var currentlyRunningProccesIdPath = Paths.get(args[1]); |
| 22 | + var procLockfile = Paths.get(args[2]); |
| 23 | + var logPath = Paths.get(args[3]); |
| 24 | + var realMain = args[4]; |
| 25 | + var realArgs = java.util.Arrays.copyOfRange(args, 5, args.length); |
| 26 | + |
| 27 | + // The following code should handle this scenario, when we have 2 processes contending for the |
| 28 | + // lock. |
| 29 | + // |
| 30 | + // This can happen if a new MillBackgroundWrapper is launched while the previous one is still |
| 31 | + // running due to rapid |
| 32 | + // changes in the source code. |
| 33 | + // |
| 34 | + // Process 1 starts, writes newest_pid=1, claims lock, writes currently_running_pid = 1 |
| 35 | + // Process 2 starts, writes newest_pid=2, tries to claim lock but is blocked |
| 36 | + // Process 3 starts at the same time as process 2, writes newest_pid=3, tries to claim lock but |
| 37 | + // is blocked |
| 38 | + // |
| 39 | + // Process 1 reads newest_pid=3, terminates, releases lock |
| 40 | + // Process 2 claims lock, reads currently_running_pid = 1, waits for process 1 to die, writes |
| 41 | + // currently_running_pid = 2 |
| 42 | + // Process 2 reads newest_pid=3, terminates, releases lock |
| 43 | + // Process 3 claims lock, reads currently_running_pid = 2, waits for process 2 to die, writes |
| 44 | + // currently_running_pid = 3, then starts |
| 45 | + // Process 3 reads newest_pid=3, continues running |
13 | 46 |
|
14 |
| - Files.writeString(procUuidPath, procUuid, StandardOpenOption.CREATE); |
| 47 | + // Indicate to the previous process that we want to take over. |
| 48 | + var myPid = ProcessHandle.current().pid(); |
| 49 | + var myPidStr = "" + myPid; |
| 50 | + log(logPath, myPid, "Starting. Writing my PID to " + newestProcessIdPath); |
| 51 | + Files.writeString( |
| 52 | + newestProcessIdPath, |
| 53 | + myPidStr, |
| 54 | + StandardOpenOption.CREATE, |
| 55 | + StandardOpenOption.TRUNCATE_EXISTING); |
15 | 56 |
|
16 | 57 | // Take a lock on `procLockfile` to ensure that only one
|
17 | 58 | // `runBackground` process is running at any point in time.
|
| 59 | + log(logPath, myPid, "Acquiring lock on " + procLockfile); |
| 60 | + |
| 61 | + //noinspection resource - this is intentional, file is released when process dies. |
18 | 62 | RandomAccessFile raf = new RandomAccessFile(procLockfile.toFile(), "rw");
|
19 | 63 | FileChannel chan = raf.getChannel();
|
20 | 64 | if (chan.tryLock() == null) {
|
21 |
| - System.err.println("Waiting for runBackground lock to be available"); |
| 65 | + var startTimeNanos = System.nanoTime(); |
| 66 | + System.err.println("[mill:runBackground] Waiting for runBackground lock to be available"); |
| 67 | + // this is intentional, lock is released when process dies. |
| 68 | + //noinspection ResultOfMethodCallIgnored |
22 | 69 | chan.lock();
|
| 70 | + var delta = (System.nanoTime() - startTimeNanos) / NS_IN_S; |
| 71 | + System.err.println("[mill:runBackground] Lock acquired after " + delta + "s"); |
23 | 72 | }
|
| 73 | + log(logPath, myPid, "Lock acquired"); |
24 | 74 |
|
25 |
| - // For some reason even after the previous process exits things like sockets |
26 |
| - // may still take time to free, so sleep for a configurable duration before proceeding |
27 |
| - Thread.sleep(lockDelay); |
| 75 | + var oldProcessPid = readPreviousPid(currentlyRunningProccesIdPath); |
| 76 | + log(logPath, myPid, "Old process PID: " + oldProcessPid); |
| 77 | + oldProcessPid.ifPresent((oldPid) -> { |
| 78 | + var startTimeNanos = System.nanoTime(); |
| 79 | + log(logPath, myPid, "Waiting for old process to terminate"); |
| 80 | + var processExisted = waitForPreviousProcessToTerminate(oldPid); |
| 81 | + if (processExisted) { |
| 82 | + log( |
| 83 | + logPath, |
| 84 | + myPid, |
| 85 | + "Old process terminated in " + (System.nanoTime() - startTimeNanos) / NS_IN_S + "s"); |
| 86 | + } else { |
| 87 | + log(logPath, myPid, "Old process was already terminated."); |
| 88 | + } |
| 89 | + }); |
| 90 | + |
| 91 | + log(logPath, myPid, "Writing my PID to " + currentlyRunningProccesIdPath); |
| 92 | + Files.writeString( |
| 93 | + currentlyRunningProccesIdPath, |
| 94 | + myPidStr, |
| 95 | + StandardOpenOption.CREATE, |
| 96 | + StandardOpenOption.TRUNCATE_EXISTING); |
28 | 97 |
|
29 | 98 | // Start the thread to watch for updates on the process marker file,
|
30 | 99 | // so we can exit if it is deleted or replaced
|
31 |
| - long startTime = System.currentTimeMillis(); |
32 |
| - Thread watcher = new Thread(() -> { |
33 |
| - while (true) { |
34 |
| - long delta = (System.currentTimeMillis() - startTime) / 1000; |
35 |
| - try { |
36 |
| - Thread.sleep(1); |
37 |
| - String token = Files.readString(procUuidPath); |
38 |
| - if (!token.equals(procUuid)) { |
39 |
| - System.err.println("runBackground exiting after " + delta + "s"); |
40 |
| - System.exit(0); |
41 |
| - } |
42 |
| - } catch (Exception e) { |
43 |
| - System.err.println("runBackground exiting after " + delta + "s"); |
44 |
| - System.exit(0); |
45 |
| - } |
46 |
| - } |
| 100 | + var startTimeNanos = System.nanoTime(); // use nanoTime as it is monotonic |
| 101 | + checkIfWeStillNeedToBeRunning(logPath, startTimeNanos, newestProcessIdPath, myPid); |
| 102 | + var watcher = new Thread(() -> { |
| 103 | + while (true) |
| 104 | + checkIfWeStillNeedToBeRunning(logPath, startTimeNanos, newestProcessIdPath, myPid); |
47 | 105 | });
|
48 |
| - |
49 | 106 | watcher.setDaemon(true);
|
50 | 107 | watcher.start();
|
51 | 108 |
|
52 | 109 | // Actually start the Java main method we wanted to run in the background
|
53 |
| - String realMain = args[4]; |
54 |
| - String[] realArgs = java.util.Arrays.copyOfRange(args, 5, args.length); |
55 | 110 | if (!realMain.equals("<subprocess>")) {
|
| 111 | + log( |
| 112 | + logPath, |
| 113 | + myPid, |
| 114 | + "Running main method " + realMain + " with args " + Arrays.toString(realArgs)); |
56 | 115 | Class.forName(realMain).getMethod("main", String[].class).invoke(null, (Object) realArgs);
|
57 | 116 | } else {
|
| 117 | + log(logPath, myPid, "Running subprocess with args " + Arrays.toString(realArgs)); |
58 | 118 | Process subprocess = new ProcessBuilder().command(realArgs).inheritIO().start();
|
| 119 | + log( |
| 120 | + logPath, |
| 121 | + myPid, |
| 122 | + "Subprocess started with PID " + subprocess.pid() + ", waiting for it to exit"); |
59 | 123 |
|
60 | 124 | Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
| 125 | + log(logPath, myPid, "Shutdown hook called, terminating subprocess"); |
61 | 126 | subprocess.destroy();
|
62 | 127 |
|
63 | 128 | long now = System.currentTimeMillis();
|
64 | 129 |
|
| 130 | + // If the process does not shut down withing 100ms kill it forcibly. |
65 | 131 | while (subprocess.isAlive() && System.currentTimeMillis() - now < 100) {
|
66 | 132 | try {
|
67 | 133 | Thread.sleep(1);
|
68 | 134 | } catch (InterruptedException e) {
|
69 |
| - } |
70 |
| - if (subprocess.isAlive()) { |
71 |
| - subprocess.destroyForcibly(); |
| 135 | + // do nothing |
72 | 136 | }
|
73 | 137 | }
|
| 138 | + if (subprocess.isAlive()) { |
| 139 | + log(logPath, myPid, "Forcing subprocess termination"); |
| 140 | + subprocess.destroyForcibly(); |
| 141 | + } |
74 | 142 | }));
|
75 |
| - System.exit(subprocess.waitFor()); |
| 143 | + |
| 144 | + log(logPath, myPid, "Waiting for subprocess to terminate"); |
| 145 | + var exitCode = subprocess.waitFor(); |
| 146 | + log(logPath, myPid, "Subprocess terminated with exit code " + exitCode); |
| 147 | + System.exit(exitCode); |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + private static void checkIfWeStillNeedToBeRunning( |
| 152 | + Path logPath, long startTimeNanos, Path newestProcessIdPath, long myPid) { |
| 153 | + var delta = (System.nanoTime() - startTimeNanos) / NS_IN_S; |
| 154 | + var myPidStr = "" + myPid; |
| 155 | + try { |
| 156 | + Thread.sleep(50); |
| 157 | + var token = Files.readString(newestProcessIdPath); |
| 158 | + if (!myPidStr.equals(token)) { |
| 159 | + log( |
| 160 | + logPath, |
| 161 | + myPid, |
| 162 | + "New process started, exiting. Token file (" + newestProcessIdPath + ") contents: \"" |
| 163 | + + token + "\""); |
| 164 | + System.err.println( |
| 165 | + "[mill:runBackground] Background process has been replaced with a new process (PID: " |
| 166 | + + token + "), exiting after " + delta + "s"); |
| 167 | + System.exit(0); |
| 168 | + } |
| 169 | + } catch (Exception e) { |
| 170 | + log(logPath, myPid, "Check if we still need to be running failed, exiting. Exception: " + e); |
| 171 | + System.err.println("[mill:runBackground] Background process exiting after " + delta + "s"); |
| 172 | + System.exit(0); |
| 173 | + } |
| 174 | + } |
| 175 | + |
| 176 | + static Optional<Long> readPreviousPid(Path pidFilePath) { |
| 177 | + try { |
| 178 | + var pidStr = Files.readString(pidFilePath); |
| 179 | + return Optional.of(Long.parseLong(pidStr)); |
| 180 | + } catch (IOException | NumberFormatException e) { |
| 181 | + return Optional.empty(); |
| 182 | + } |
| 183 | + } |
| 184 | + |
| 185 | + /** Returns true if the process with the given PID has terminated, false if the process did not exist. */ |
| 186 | + static boolean waitForPreviousProcessToTerminate(long pid) { |
| 187 | + var maybeOldProcess = ProcessHandle.of(pid); |
| 188 | + if (maybeOldProcess.isEmpty()) return false; |
| 189 | + var oldProcess = maybeOldProcess.get(); |
| 190 | + |
| 191 | + try { |
| 192 | + while (oldProcess.isAlive()) { |
| 193 | + Thread.sleep(50); |
| 194 | + } |
| 195 | + return true; |
| 196 | + } catch (InterruptedException e) { |
| 197 | + throw new RuntimeException(e); |
| 198 | + } |
| 199 | + } |
| 200 | + |
| 201 | + static void log(Path logFile, long myPid, String message) { |
| 202 | + var timestamp = DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.now()); |
| 203 | + try { |
| 204 | + Files.writeString( |
| 205 | + logFile, |
| 206 | + "[" + timestamp + "] " + myPid + ": " + message + "\n", |
| 207 | + StandardOpenOption.CREATE, |
| 208 | + StandardOpenOption.APPEND); |
| 209 | + } catch (IOException e) { |
| 210 | + // do nothing |
76 | 211 | }
|
77 | 212 | }
|
78 | 213 | }
|
0 commit comments