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}