001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.terminal.region; 018 019import static java.nio.charset.StandardCharsets.*; 020import static java.nio.file.StandardOpenOption.*; 021import static org.apache.juneau.commons.utils.Shorts.*; 022import static org.apache.juneau.terminal.report.TerminalViewChecks.*; 023 024import java.io.*; 025import java.nio.*; 026import java.nio.channels.*; 027import java.nio.file.*; 028import java.util.*; 029import java.util.concurrent.*; 030import java.util.concurrent.atomic.*; 031import java.util.concurrent.locks.*; 032import java.util.logging.*; 033 034import org.apache.juneau.marshall.json.JsonSerializer; 035import org.apache.juneau.marshall.marshaller.Json; 036import org.apache.juneau.terminal.report.*; 037import org.apache.juneau.console.views.*; 038 039/** 040 * Runs one tool under a pseudo-terminal through {@code juneau_terminal.py --pty} and exposes its output as a 041 * {@link FileTerminalSource} and its steps as a {@link FileTerminalViewSource}. 042 * 043 * <p> 044 * The command line is 045 * {@code python3 <script> --console none --pty --size <cols>x<rows> --full-log <log> --events <events> <tool> -- <cmd...>}, 046 * with the log ({@value #LOG_NAME}), its {@code .size} sidecar and the events file ({@value #EVENTS_NAME}) in the 047 * given run directory. The script is found through the system property {@value #SCRIPT_PROPERTY}, then the 048 * environment variable {@value #SCRIPT_ENV}. 049 * 050 * <p> 051 * The run directory is single-use. It is also the tool's working directory, and the runner's own stdout and stderr 052 * go to {@code runner.out} there, in append mode and uncapped; that file is diagnostic only. 053 * 054 * <p> 055 * Both sources share one done flag, set once the process has exited. If the runner exits without writing a 056 * {@code done} event, a warning {@code note} and {@code done {status:"fail"}} are appended first. Only the last 057 * {@value #TAIL_BYTES} bytes of the events file are scanned for that {@code done} event, because the protocol puts it 058 * last. 059 * 060 * <p> 061 * The exit hook runs on one daemon thread per process, named {@code juneau-terminal-exit-<pid>}. Its executor is 062 * shut down once the hook has run, and {@link #close()} waits for that. 063 * 064 * <p> 065 * Started with input on, the runner also gets {@code --input stdin} (right after the events pair) and the process is a 066 * {@link TerminalSink}: {@link #write(byte[])} queues the bytes in a {@value #INPUT_LIMIT}-byte pending buffer and never 067 * blocks, and one daemon thread per process, named {@code juneau-terminal-input-<pid>}, copies them to the runner's 068 * stdin, flushing after each chunk. A closed or broken pipe turns {@link #acceptsInput()} false and discards the 069 * buffer. With input off the command line and the streams are exactly as above, no writer thread starts, and 070 * {@link #acceptsInput()} is always false. 071 * 072 * <p> 073 * Started with a {@link TerminalPrompts}, the runner gets {@code --prompts stdio} and its stdout and stdin become the 074 * prompt channel: one daemon thread per process, named {@code terminal-prompts-<dir name>}, reads the stdout lines (at 075 * most {@value #PROMPT_LINE_LIMIT} bytes each; a longer one is dropped) and hands them to 076 * {@link TerminalPrompts#onLine(String)}, then calls {@link TerminalPrompts#onEnd()} once. The runner's stderr goes to 077 * {@code runner.out}. {@link #answer(String)} writes one line back. Neither a prompt line nor an answer is ever 078 * logged. Input and prompts are mutually exclusive. 079 * 080 * <p> 081 * POSIX only: there is no Windows pseudo-terminal support. 082 * 083 * <h5 class='section'>Example:</h5> 084 * <p class='bjava'> 085 * TerminalProcess <jv>p</jv> = TerminalProcess.<jsm>start</jsm>(<js>"maven"</js>, List.<jsm>of</jsm>(<js>"mvn"</js>, <js>"test"</js>), 120, 40, <jv>runDir</jv>); 086 * <jv>terminals</jv>.put(<jv>id</jv>, <jv>p</jv>.terminal()); 087 * <jv>terminalViews</jv>.put(<jv>id</jv>, <jv>p</jv>.events()); 088 * </p> 089 */ 090@SuppressWarnings({ 091 "java:S3776" // Start-up validation and shutdown are linear sequences of independent steps. 092}) 093public final class TerminalProcess implements AutoCloseable, TerminalSink { 094 095 /** The system property naming {@code juneau_terminal.py}. */ 096 public static final String SCRIPT_PROPERTY = "juneau.terminal.script"; 097 098 /** The environment variable naming {@code juneau_terminal.py}, read when the property is not set. */ 099 public static final String SCRIPT_ENV = "JUNEAU_TERMINAL_SCRIPT"; 100 101 /** The byte log's file name in the run directory. */ 102 public static final String LOG_NAME = "terminal.log"; 103 104 /** The events file's name in the run directory. */ 105 public static final String EVENTS_NAME = "terminal.events.jsonl"; 106 107 /** How much of the end of the events file is scanned for a {@code done} event. */ 108 static final int TAIL_BYTES = 64 * 1024; 109 110 /** How long {@link #close()} waits for the exit hook. */ 111 static final int CLOSE_WAIT_SECONDS = 5; 112 113 /** The most input bytes waiting for the writer thread: queued bytes only, not the chunk being written. */ 114 static final int INPUT_LIMIT = 64 * 1024; 115 116 /** The longest prompt line read from the runner, in bytes. */ 117 static final int PROMPT_LINE_LIMIT = 1 << 20; 118 119 /** How long {@link #close()} lets a prompt-channel runner end by itself after its stdin closes, in milliseconds. */ 120 static final int PROMPTS_CLOSE_GRACE_MS = 3000; 121 122 /** How long {@link #close()} waits for the prompt reader thread, in milliseconds. */ 123 static final int PROMPTS_JOIN_MILLIS = 2000; 124 125 /** How often the nudge thread checks the log size, in milliseconds. */ 126 static final int NUDGE_POLL_MILLIS = 100; 127 128 /** How long {@link #close()} waits for the nudge thread, in milliseconds. */ 129 static final int NUDGE_JOIN_MILLIS = 1000; 130 131 /** How long {@link #close()} waits for the input writer thread, in milliseconds. */ 132 static final int WRITER_JOIN_MILLIS = 1000; 133 134 private static final Set<String> TOOLS = Set.of("maven", "pytest", "playwright", "generic"); 135 private static final Logger LOG = Logger.getLogger(TerminalProcess.class.getName()); 136 137 /** What the launcher reads from its environment; replaced in tests. */ 138 record Host(String osName, String scriptProperty, String scriptEnv, String path) { 139 static Host system() { 140 return new Host(System.getProperty("os.name", ""), System.getProperty(SCRIPT_PROPERTY), System.getenv(SCRIPT_ENV), System.getenv("PATH")); 141 } 142 } 143 144 private final Process process; 145 private final Path logFile; 146 private final Path events; 147 private final AtomicBoolean done = new AtomicBoolean(); 148 private final FileTerminalSource terminal; 149 private final FileTerminalViewSource terminalView; 150 private final ExecutorService executor; 151 private final CompletableFuture<Integer> exit; 152 private final AtomicReference<Thread> exitThread = new AtomicReference<>(); 153 154 // Input mode. pending, pendingBytes, writing, inputFailed and closed are guarded by inLock. 155 private final boolean input; 156 private final Object inLock = new Object(); 157 private final ArrayDeque<byte[]> pending = new ArrayDeque<>(); 158 private int pendingBytes; // Queued bytes only; a chunk leaves the count when the writer takes it, before it is written. 159 private boolean writing; 160 private boolean inputFailed; 161 private boolean closed; 162 private final Thread writer; 163 164 // Prompt channel. answerLock serializes answers; answersClosed is set once the channel can take no more. 165 private final TerminalPrompts prompts; 166 private final ReentrantLock answerLock = new ReentrantLock(); 167 private volatile boolean answersClosed; 168 private final Thread promptThread; 169 170 // Nudges. nudgeThread, nudgeSet and nudgeClosed are guarded by nudgeLock. 171 private final Object nudgeLock = new Object(); 172 private Thread nudgeThread; 173 private boolean nudgeSet; 174 private boolean nudgeClosed; 175 private volatile boolean nudgeStop; 176 177 /** Appends the missing done event; replaced in tests. */ 178 @FunctionalInterface 179 interface DoneWriter { 180 void ensureDone(Path events, int exitCode) throws IOException; 181 } 182 183 private TerminalProcess(Process process, Path log, Path events, DoneWriter doneWriter, boolean input, TerminalPrompts prompts, String name) { 184 this.process = process; 185 this.input = input; 186 this.prompts = prompts; 187 this.logFile = log; 188 this.events = events; 189 terminal = FileTerminalSource.create(log).terminal(done::get).build(); 190 terminalView = FileTerminalViewSource.create(events).terminal(done::get).build(); 191 executor = Executors.newSingleThreadExecutor(r -> { 192 var t = new Thread(r, "juneau-terminal-exit-" + process.pid()); 193 t.setDaemon(true); 194 return t; 195 }); 196 exit = process.onExit().thenApplyAsync(p -> { 197 exitThread.set(Thread.currentThread()); 198 try { 199 doneWriter.ensureDone(events, p.exitValue()); 200 } catch (Exception e) { 201 LOG.log(Level.WARNING, e, () -> "Could not append the done event to '" + events + "'."); 202 } finally { 203 done.set(true); 204 executor.shutdown(); 205 } 206 return p.exitValue(); 207 }, executor); 208 if (input) { 209 // Every field the writer thread and the onExit callback read is assigned above; keep that order. 210 writer = new Thread(this::pump, "juneau-terminal-input-" + process.pid()); 211 writer.setDaemon(true); 212 process.onExit().thenRun(() -> { 213 synchronized (inLock) { 214 inLock.notifyAll(); 215 } 216 }); 217 writer.start(); 218 } else { 219 writer = null; 220 } 221 if (prompts != null) { 222 promptThread = new Thread(this::readPrompts, "terminal-prompts-" + name); 223 promptThread.setDaemon(true); 224 promptThread.start(); 225 } else { 226 promptThread = null; 227 } 228 } 229 230 /** The prompt reader thread: hands each stdout line to the prompts, then reports the end exactly once. */ 231 private void readPrompts() { 232 try (var in = new BufferedInputStream(process.getInputStream())) { 233 var line = new ByteArrayOutputStream(); 234 var over = false; 235 for (var b = in.read(); b >= 0; b = in.read()) { 236 if (b == '\n') { 237 if (over) 238 LOG.log(Level.WARNING, "prompt line over limit dropped"); 239 else 240 prompts.onLine(line.toString(UTF_8)); 241 line.reset(); 242 over = false; 243 } else if (! over) { 244 if (line.size() >= PROMPT_LINE_LIMIT) { 245 over = true; 246 line.reset(); 247 } else { 248 line.write(b); 249 } 250 } 251 } 252 } catch (IOException e) { 253 // A closed pipe ends the channel; the exception text could hold a prompt, so it is not logged. 254 LOG.log(Level.FINE, () -> "TerminalProcess prompt channel closed for '" + events + "'."); 255 } finally { 256 answersClosed = true; 257 prompts.onEnd(); 258 } 259 } 260 261 /** The writer thread: copies pending chunks to stdin, never holding {@link #inLock} while writing. */ 262 @SuppressWarnings({ 263 "java:S3626" // The early return is what ends the endless writer loop after a failed write. 264 }) 265 private void pump() { 266 var out = process.getOutputStream(); 267 try { 268 while (true) { 269 byte[] chunk; 270 synchronized (inLock) { 271 while (pending.isEmpty() && ! closed && process.isAlive()) 272 inLock.wait(); 273 if (closed || pending.isEmpty()) 274 return; 275 chunk = pending.poll(); 276 pendingBytes -= chunk.length; 277 writing = true; 278 } 279 if (! writeChunk(out, chunk)) 280 return; 281 } 282 } catch (InterruptedException e) { 283 Thread.currentThread().interrupt(); 284 } finally { 285 closeQuietly(out); 286 } 287 } 288 289 // Writes one chunk to stdin; false once the pipe has failed and the pending input has been dropped. 290 private boolean writeChunk(OutputStream out, byte[] chunk) { 291 try { 292 out.write(chunk); 293 out.flush(); 294 return true; 295 } catch (IOException e) { 296 synchronized (inLock) { 297 inputFailed = true; 298 pending.clear(); 299 pendingBytes = 0; 300 } 301 LOG.log(Level.FINE, e, () -> "TerminalProcess input pipe failed for '" + events + "'."); 302 return false; 303 } finally { 304 synchronized (inLock) { 305 writing = false; 306 } 307 } 308 } 309 310 private static void closeQuietly(OutputStream out) { 311 try { 312 out.close(); 313 } catch (IOException e) { 314 // A broken pipe has nothing left to close. 315 } 316 } 317 318 /** 319 * Starts a tool under a pseudo-terminal. 320 * 321 * @param tool The juneau_terminal tool parser: {@code maven}, {@code pytest}, {@code playwright} or {@code generic}. 322 * @param cmd The command and its arguments; at least one element. 323 * It is a token list: each element reaches the tool as one argument, through {@link ProcessBuilder}. It is never 324 * joined into a string or given to a shell, so shell metacharacters in an element are literal. 325 * @param cols The terminal width, 1-9999. 326 * @param rows The terminal height, 1-9999. 327 * @param dir The run directory; created if missing. Each run needs its own: the log and events files must be 328 * missing or empty. It is also the tool's working directory, and the runner's own stdout and stderr go to 329 * {@code runner.out} in it, in append mode and uncapped; that file is diagnostic only. 330 * @return The running process. 331 * @throws IOException If the directory could not be prepared or the process could not start. 332 * @throws IllegalArgumentException If an argument is out of range. 333 * @throws IllegalStateException If the script is not configured, {@code python3} is not on the {@code PATH}, or 334 * the run directory already holds a run. 335 * @throws UnsupportedOperationException On Windows. 336 */ 337 public static TerminalProcess start(String tool, List<String> cmd, int cols, int rows, Path dir) throws IOException { 338 return start(tool, cmd, cols, rows, dir, false); 339 } 340 341 /** 342 * Starts a tool under a pseudo-terminal, optionally with input. 343 * 344 * <p> 345 * Same as {@link #start(String, List, int, int, Path)}, except that with {@code input} on the runner gets 346 * {@code --input stdin} and the returned process accepts {@link #write(byte[])}. 347 * 348 * @param tool The juneau_terminal tool parser. 349 * @param cmd The command and its arguments; at least one element. 350 * @param cols The terminal width, 1-9999. 351 * @param rows The terminal height, 1-9999. 352 * @param dir The run directory; see {@link #start(String, List, int, int, Path)}. 353 * @param input Whether the job takes input through {@link #write(byte[])}. 354 * @return The running process. 355 * @throws IOException If the directory could not be prepared or the process could not start. 356 * @throws IllegalArgumentException If an argument is out of range. 357 * @throws IllegalStateException If the script is not configured, {@code python3} is not on the {@code PATH}, or 358 * the run directory already holds a run. 359 * @throws UnsupportedOperationException On Windows. 360 */ 361 public static TerminalProcess start(String tool, List<String> cmd, int cols, int rows, Path dir, boolean input) throws IOException { 362 return start(tool, cmd, cols, rows, dir, Host.system(), input); 363 } 364 365 /** 366 * Starts a tool under a pseudo-terminal with a prompt channel. 367 * 368 * <p> 369 * Same as {@link #start(String, List, int, int, Path)}, except that the runner gets {@code --prompts stdio}, the 370 * script's prompt lines go to {@code prompts} and {@link #answer(String)} sends the replies. 371 * 372 * @param tool The juneau_terminal tool parser. 373 * @param cmd The command and its arguments; at least one element. 374 * @param cols The terminal width, 1-9999. 375 * @param rows The terminal height, 1-9999. 376 * @param dir The run directory; see {@link #start(String, List, int, int, Path)}. 377 * @param prompts Receives the prompt lines; not <jk>null</jk>. 378 * @return The running process. 379 * @throws IOException If the directory could not be prepared or the process could not start. 380 * @throws IllegalArgumentException If an argument is out of range. 381 * @throws IllegalStateException If the script is not configured, {@code python3} is not on the {@code PATH}, or 382 * the run directory already holds a run. 383 * @throws UnsupportedOperationException On Windows. 384 */ 385 public static TerminalProcess start(String tool, List<String> cmd, int cols, int rows, Path dir, TerminalPrompts prompts) throws IOException { 386 Objects.requireNonNull(prompts, "prompts"); 387 return start(tool, cmd, cols, rows, dir, Host.system(), TerminalProcess::ensureDone, false, prompts); 388 } 389 390 static TerminalProcess start(String tool, List<String> cmd, int cols, int rows, Path dir, Host host) throws IOException { 391 return start(tool, cmd, cols, rows, dir, host, false); 392 } 393 394 static TerminalProcess start(String tool, List<String> cmd, int cols, int rows, Path dir, Host host, boolean input) throws IOException { 395 return start(tool, cmd, cols, rows, dir, host, TerminalProcess::ensureDone, input); 396 } 397 398 static TerminalProcess start(String tool, List<String> cmd, int cols, int rows, Path dir, Host host, DoneWriter doneWriter) throws IOException { 399 return start(tool, cmd, cols, rows, dir, host, doneWriter, false); 400 } 401 402 @SuppressWarnings({ 403 "java:S107" // internal entry point; the parameters are the process configuration 404 }) 405 static TerminalProcess start(String tool, List<String> cmd, int cols, int rows, Path dir, Host host, DoneWriter doneWriter, boolean input) throws IOException { 406 return start(tool, cmd, cols, rows, dir, host, doneWriter, input, null); 407 } 408 409 @SuppressWarnings({ 410 "java:S107" // internal entry point; the parameters are the process configuration 411 }) 412 static TerminalProcess start(String tool, List<String> cmd, int cols, int rows, Path dir, Host host, DoneWriter doneWriter, boolean input, TerminalPrompts prompts) throws IOException { 413 if (input && prompts != null) 414 throw new IllegalArgumentException("input and prompts are mutually exclusive"); 415 if (host.osName().toLowerCase(Locale.ROOT).startsWith("windows")) 416 throw new UnsupportedOperationException("TerminalProcess needs a POSIX pseudo-terminal; Windows is not supported."); 417 if (! TOOLS.contains(tool)) 418 throw iaex("TerminalProcess tool must be one of %s; got '%s'.", new TreeSet<>(TOOLS), tool); 419 if (ie(cmd)) 420 throw iaex("TerminalProcess cmd must have at least one element."); 421 if (cols < 1 || cols > 9999 || rows < 1 || rows > 9999) 422 throw iaex("TerminalProcess size must be 1-9999 x 1-9999; got '%sx%s'.", cols, rows); 423 var script = host.scriptProperty() != null && ! host.scriptProperty().isBlank() ? host.scriptProperty() : host.scriptEnv(); 424 if (ib(script)) 425 throw new IllegalStateException("TerminalProcess cannot find juneau_terminal.py: set the system property " + SCRIPT_PROPERTY + " or the environment variable " + SCRIPT_ENV + "."); 426 var python = findOnPath("python3", host.path()); 427 if (python == null) 428 throw new IllegalStateException("TerminalProcess cannot find python3 on the PATH."); 429 Files.createDirectories(dir); 430 var log = dir.resolve(LOG_NAME); 431 var events = dir.resolve(EVENTS_NAME); 432 for (var f : List.of(log, events)) 433 if (Files.exists(f) && Files.size(f) > 0) 434 throw new IllegalStateException("TerminalProcess run directory '" + dir + "' already holds a run: '" + f.getFileName() + "' is not empty."); 435 // Written here as well as by juneau_terminal, so a request that arrives before the child starts sees the real size. 436 Files.writeString(log.resolveSibling(LOG_NAME + ".size"), "{\"cols\":" + cols + ",\"rows\":" + rows + "}"); 437 var argv = new ArrayList<String>(); 438 argv.addAll(List.of(python.toString(), script, "--console", "none", "--pty", "--size", cols + "x" + rows, 439 "--full-log", log.toString(), "--events", events.toString())); 440 if (input) 441 argv.addAll(List.of("--input", "stdin")); 442 if (prompts != null) 443 argv.addAll(List.of("--prompts", "stdio")); 444 argv.addAll(List.of(tool, "--")); 445 argv.addAll(cmd); 446 var runnerOut = ProcessBuilder.Redirect.appendTo(dir.resolve("runner.out").toFile()); 447 var pb = new ProcessBuilder(argv).directory(dir.toFile()); 448 if (prompts == null) 449 pb.redirectErrorStream(true).redirectOutput(runnerOut); 450 else 451 pb.redirectError(runnerOut); // stdout stays a pipe: it is the prompt channel. 452 return new TerminalProcess(pb.start(), log, events, doneWriter, input, prompts, String.valueOf(dir.getFileName())); 453 } 454 455 /** Returns the first executable {@code name} in the {@code PATH} directories, or <jk>null</jk>. */ 456 static Path findOnPath(String name, String path) { 457 if (path == null) 458 return null; 459 for (var d : path.split(File.pathSeparator)) { 460 if (d.isEmpty()) 461 continue; 462 var p = Paths.get(d, name); 463 if (Files.isRegularFile(p) && Files.isExecutable(p)) 464 return p; 465 } 466 return null; 467 } 468 469 /** Appends a warning note and a failing done event when the last {@value #TAIL_BYTES} bytes of the events file hold no done event. */ 470 static void ensureDone(Path events, int exitCode) throws IOException { 471 var text = ""; 472 if (Files.exists(events)) { 473 try (var ch = FileChannel.open(events, StandardOpenOption.READ)) { 474 var from = Math.max(0, ch.size() - TAIL_BYTES); 475 var buf = ByteBuffer.allocate((int)(ch.size() - from)); 476 while (buf.hasRemaining() && ch.read(buf, from + buf.position()) >= 0) { 477 // The condition does the reading; the loop has no body. 478 } 479 text = new String(buf.array(), 0, buf.position(), UTF_8); 480 } 481 } 482 // A fragment at the start of the tail never parses, so it is never taken for a done event. 483 for (var line : text.split("\n")) { 484 if (isDone(line)) 485 return; 486 } 487 var sb = new StringBuilder(); 488 if (! text.isEmpty() && ! text.endsWith("\n")) 489 sb.append('\n'); 490 sb.append(JsonSerializer.DEFAULT.toString(TerminalEvent.note(TerminalEvent.Level.WARN, "runner exited without done, exit " + exitCode).toContractMap())).append('\n'); 491 sb.append(JsonSerializer.DEFAULT.toString(TerminalEvent.done(TerminalEvent.DoneStatus.FAIL).toContractMap())).append('\n'); 492 Files.writeString(events, sb, UTF_8, CREATE, APPEND); 493 } 494 495 private static boolean isDone(String line) { 496 if (line.isBlank()) 497 return false; 498 try { 499 return TerminalEvent.fromMap(Json.to(line, Map.class)).kind() == TerminalEvent.Kind.DONE; 500 } catch (Exception e) { 501 return false; 502 } 503 } 504 505 /** 506 * The raw byte source over the log. 507 * 508 * @return The source. 509 */ 510 public FileTerminalSource terminal() { 511 return terminal; 512 } 513 514 /** 515 * The step events source over the events file. 516 * 517 * @return The source. 518 */ 519 public FileTerminalViewSource events() { 520 return terminalView; 521 } 522 523 /** 524 * The byte log. 525 * 526 * @return The log path. 527 */ 528 public Path log() { 529 return logFile; 530 } 531 532 /** 533 * The events file. 534 * 535 * @return The events path. 536 */ 537 public Path eventsFile() { 538 return events; 539 } 540 541 /** 542 * Whether the process has exited and the events file ends with a {@code done} event. 543 * 544 * @return <jk>true</jk> once finished. 545 */ 546 public boolean isDone() { 547 return done.get(); 548 } 549 550 /** 551 * Waits for the process to exit and for the done event to be in place. 552 * 553 * @param timeout The longest wait. 554 * @param unit The unit of {@code timeout}. 555 * @return The runner's exit code. 556 * @throws InterruptedException If interrupted. 557 * @throws TimeoutException If the process is still running. 558 */ 559 public int waitFor(long timeout, TimeUnit unit) throws InterruptedException, TimeoutException { 560 try { 561 return exit.get(timeout, unit); 562 } catch (ExecutionException e) { 563 throw new IllegalStateException(e.getCause()); 564 } 565 } 566 567 /** 568 * Whether input is accepted right now. 569 * 570 * @return <jk>true</jk> only when input is on, the process is alive, no write has failed and the process has not been 571 * closed. 572 */ 573 @Override /* TerminalSink */ 574 public boolean acceptsInput() { 575 synchronized (inLock) { 576 // HTT: close() waits for the process to exit, so isAlive() is already false afterwards. The closed term 577 // guards the window inside close(); it is not observable without a test seam. 578 return input && ! closed && ! inputFailed && process.isAlive(); 579 } 580 } 581 582 /** 583 * Queues the bytes for the runner's stdin; never blocks. 584 * 585 * @param bytes The bytes to send; copied. 586 * @return <jk>false</jk> when input is not accepted, or when the bytes do not fit in the {@value #INPUT_LIMIT}-byte 587 * pending buffer. An empty array returns {@link #acceptsInput()}. 588 */ 589 @Override /* TerminalSink */ 590 public boolean write(byte[] bytes) { 591 synchronized (inLock) { 592 if (! acceptsInput()) 593 return false; 594 if (bytes.length == 0) 595 return true; 596 if (pendingBytes + bytes.length > INPUT_LIMIT) 597 return false; 598 pending.add(bytes.clone()); 599 pendingBytes += bytes.length; 600 inLock.notifyAll(); 601 return true; 602 } 603 } 604 605 /** 606 * Whether an answer can be sent right now. 607 * 608 * @return <jk>true</jk> only when a prompt channel is on, the process is alive and the channel has not ended or failed. 609 */ 610 public boolean acceptsAnswers() { 611 return prompts != null && ! answersClosed && process.isAlive(); 612 } 613 614 /** 615 * Writes one answer line to the script's prompt channel; never blocks behind another answer and never logs the line. 616 * 617 * @param jsonLine The answer as one JSON line, without a newline. 618 * @return <jk>false</jk> when there is no channel, it has ended, the line holds a newline, another answer is being 619 * written, or the write failed. 620 */ 621 public boolean answer(String jsonLine) { 622 if (jsonLine.indexOf('\n') >= 0 || ! acceptsAnswers() || ! answerLock.tryLock()) 623 return false; 624 try { 625 var out = process.getOutputStream(); 626 out.write((jsonLine + "\n").getBytes(UTF_8)); 627 out.flush(); 628 return true; 629 } catch (IOException e) { 630 answersClosed = true; 631 return false; 632 } finally { 633 answerLock.unlock(); 634 } 635 } 636 637 /** 638 * Polls the log's size every 100 ms and reports growth, then a final done, to a publisher. 639 * 640 * <p> 641 * The runner writes the log in another process, so there is no in-process append hook to use. The publisher runs on 642 * a daemon thread named {@code juneau-terminal-nudge-<pid>}; a {@link RuntimeException} it throws is logged at 643 * {@code FINE} and otherwise ignored. The thread ends after the done report, or when {@link #close()} is called. 644 * 645 * @param terminalId The terminal id passed to the publisher; must be a valid run id. 646 * @param publisher The publisher. 647 * @return This object. 648 * @throws NullPointerException If an argument is <jk>null</jk>. 649 * @throws IllegalArgumentException If the id is not a valid run id. 650 * @throws IllegalStateException If a publisher is already set, or this process has been closed. 651 */ 652 public TerminalProcess nudges(String terminalId, TerminalNudgePublisher publisher) { 653 Objects.requireNonNull(terminalId, "terminalId"); 654 Objects.requireNonNull(publisher, "publisher"); 655 if (! isRunId(terminalId)) 656 throw iaex("TerminalProcess nudge id is not a valid run id: '%s'.", terminalId); 657 synchronized (nudgeLock) { 658 if (nudgeClosed) 659 throw new IllegalStateException("TerminalProcess is closed."); 660 if (nudgeSet) 661 throw new IllegalStateException("TerminalProcess nudges are already set."); 662 nudgeSet = true; 663 nudgeThread = new Thread(() -> watch(terminalId, publisher), "juneau-terminal-nudge-" + process.pid()); 664 nudgeThread.setDaemon(true); 665 nudgeThread.start(); 666 } 667 return this; 668 } 669 670 /** The nudge thread: reports log growth until the process is done or the thread is stopped. */ 671 private void watch(String id, TerminalNudgePublisher publisher) { 672 var last = -1L; 673 while (! nudgeStop) { 674 var fin = isDone(); 675 var size = sizeOrZero(logFile); 676 if (size > last || fin) { 677 try { 678 publisher.nudge(id, size, fin); 679 } catch (RuntimeException e) { 680 LOG.log(Level.FINE, e, () -> "Terminal nudge publisher failed for '" + id + "'."); 681 } 682 last = size; 683 } 684 if (fin) 685 return; 686 try { 687 Thread.sleep(NUDGE_POLL_MILLIS); 688 } catch (InterruptedException e) { 689 Thread.currentThread().interrupt(); 690 return; 691 } 692 } 693 } 694 695 private static long sizeOrZero(Path p) { 696 try { 697 return Files.size(p); 698 } catch (IOException e) { 699 return 0; 700 } 701 } 702 703 /** The exit hook's executor; for tests. */ 704 ExecutorService executor() { 705 return executor; 706 } 707 708 /** The thread the exit hook ran on, or <jk>null</jk> before it has run; for tests. */ 709 Thread exitThread() { 710 return exitThread.get(); 711 } 712 713 /** 714 * Kills the runner and its tool if they are still running, then waits up to {@value #CLOSE_WAIT_SECONDS} seconds for 715 * the exit hook. 716 * 717 * <p> 718 * The hook writes the done event and shuts its executor down, so once this returns normally both have happened. The 719 * executor is not shut down here: a hook submitted after that would be refused, and the done flag would never be set. 720 * 721 * <p> 722 * With input on, it first stops input: pending bytes are dropped, stdin is closed and the writer thread is told to 723 * stop. If the writer thread is blocked writing to a full pipe, it closes stdin itself once the kill breaks the pipe. 724 * Afterwards this waits up to {@value #WRITER_JOIN_MILLIS} ms for the writer thread. The nudge thread, if any, is 725 * stopped first of all and joined for up to {@value #NUDGE_JOIN_MILLIS} ms. With input off, it only kills. 726 */ 727 @Override /* AutoCloseable */ 728 public void close() { 729 Thread nudger; 730 synchronized (nudgeLock) { 731 nudgeClosed = true; 732 nudgeStop = true; 733 nudger = nudgeThread; 734 } 735 if (nudger != null) 736 nudger.interrupt(); 737 if (prompts != null) { 738 // Closing stdin tells the runner the host is gone; it ends the job and writes done itself. 739 answersClosed = true; 740 closeQuietly(process.getOutputStream()); 741 try { 742 process.waitFor(PROMPTS_CLOSE_GRACE_MS, TimeUnit.MILLISECONDS); 743 } catch (InterruptedException e) { 744 Thread.currentThread().interrupt(); 745 } 746 } 747 if (input) { 748 synchronized (inLock) { 749 closed = true; 750 pending.clear(); 751 pendingBytes = 0; 752 inLock.notifyAll(); 753 // Closing flushes the stream, which waits for a write in progress; the writer flushes after every chunk, so 754 // with no write in progress there is nothing to flush and this cannot block. 755 if (! writing) 756 closeQuietly(process.getOutputStream()); 757 } 758 } 759 process.descendants().forEach(ProcessHandle::destroyForcibly); 760 process.destroyForcibly(); 761 try { 762 exit.get(CLOSE_WAIT_SECONDS, TimeUnit.SECONDS); 763 } catch (InterruptedException e) { 764 Thread.currentThread().interrupt(); 765 } catch (ExecutionException | TimeoutException e) { 766 LOG.log(Level.WARNING, e, () -> "TerminalProcess exit hook did not finish for '" + events + "'."); 767 } 768 if (nudger != null) { 769 try { 770 nudger.join(NUDGE_JOIN_MILLIS); 771 } catch (InterruptedException e) { 772 Thread.currentThread().interrupt(); 773 } 774 if (nudger.isAlive()) 775 LOG.log(Level.WARNING, () -> "TerminalProcess nudge thread did not stop for '" + events + "'."); 776 } 777 if (promptThread != null) { 778 try { 779 promptThread.join(PROMPTS_JOIN_MILLIS); 780 } catch (InterruptedException e) { 781 Thread.currentThread().interrupt(); 782 } 783 if (promptThread.isAlive()) 784 LOG.log(Level.WARNING, () -> "TerminalProcess prompt thread did not stop for '" + events + "'."); 785 } 786 if (writer != null) { 787 try { 788 writer.join(WRITER_JOIN_MILLIS); 789 } catch (InterruptedException e) { 790 Thread.currentThread().interrupt(); 791 } 792 if (writer.isAlive()) 793 LOG.log(Level.WARNING, () -> "TerminalProcess input writer thread did not stop for '" + events + "'."); 794 } 795 } 796}