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 org.apache.juneau.commons.utils.Shorts.*;
020import static org.apache.juneau.terminal.report.TerminalViewChecks.*;
021
022import java.io.*;
023import java.nio.*;
024import java.nio.channels.*;
025import java.nio.file.*;
026import java.util.*;
027import java.util.concurrent.atomic.*;
028import java.util.function.*;
029import java.util.logging.*;
030
031import org.apache.juneau.marshall.marshaller.Json;
032
033/**
034 * A {@link TerminalSource} that tails one growing log file of raw terminal bytes.
035 *
036 * <p>
037 * <b>Size.</b> The producer's cols&times;rows come from the sidecar {@code <log>.size}, which holds
038 * {@code {"cols":C,"rows":R}}.  While it is missing or malformed the size is {@link #DEFAULT_COLS}&times;
039 * {@link #DEFAULT_ROWS} and one warning is logged; the first valid read is kept, since the sidecar is written once.
040 *
041 * <p>
042 * <b>Done.</b> The source is done when the {@link Builder#terminal(BooleanSupplier) terminal} supplier says so and
043 * the reader has reached the end.  <b>Gone.</b> A log that existed and then disappears answers a {@code gone},
044 * {@code done}, empty chunk whose {@code next} and {@code end} are the requested offset.
045 *
046 * <p>
047 * <b>Nudges.</b> {@link #nudges(String, TerminalNudgePublisher)} starts a daemon watcher that publishes when the file
048 * grows.  A source has no process exit, so it never publishes a {@code done} nudge: the page's polling already sees
049 * {@code done} through the {@link Builder#terminal(BooleanSupplier) terminal} supplier.  Call {@link #close()} to stop
050 * the watcher.
051 *
052 * <p>
053 * <b>Path safety.</b> The application supplies the {@link Path}; nothing in a request ever becomes a path.
054 *
055 * <h5 class='section'>Example:</h5>
056 * <p class='bjava'>
057 *    FileTerminalSource <jv>src</jv> = FileTerminalSource.<jsm>create</jsm>(<jv>dir</jv>.resolve(<js>"run.log"</js>))
058 *       .terminal(() -&gt; <jv>run</jv>.isFinished())
059 *       .build();
060 * </p>
061 */
062public final class FileTerminalSource implements TerminalSource, AutoCloseable {
063
064   /** The width used while the size sidecar is missing or malformed. */
065   public static final int DEFAULT_COLS = 120;
066
067   /** The height used while the size sidecar is missing or malformed. */
068   public static final int DEFAULT_ROWS = 40;
069
070   /** How often the nudge thread checks the file size, in milliseconds. */
071   static final int NUDGE_POLL_MILLIS = 100;
072
073   /** How long {@link #close()} waits for the nudge thread, in milliseconds. */
074   static final int NUDGE_JOIN_MILLIS = 1000;
075
076   private static final Logger LOG = Logger.getLogger(FileTerminalSource.class.getName());
077
078   /**
079    * Builder for {@link FileTerminalSource}.
080    */
081   public static final class Builder {
082      private final Path file;
083      private BooleanSupplier terminal = () -> false;
084
085      Builder(Path file) {
086         this.file = Objects.requireNonNull(file, "file");
087      }
088
089      /**
090       * Sets the supplier that reports whether the producer will write no more, called once per read before the
091       * file is sized.
092       *
093       * @param value The supplier.
094       * @return This object.
095       */
096      public Builder terminal(BooleanSupplier value) {
097         terminal = Objects.requireNonNull(value, "terminal");
098         return this;
099      }
100
101      /**
102       * Builds the source.
103       *
104       * @return A new source.
105       */
106      public FileTerminalSource build() {
107         return new FileTerminalSource(this);
108      }
109   }
110
111   private final Path file;
112   private final Path sizeFile;
113   private final BooleanSupplier terminal;
114   private final AtomicReference<int[]> sizeRef = new AtomicReference<>();
115   private volatile boolean seen;
116   private volatile boolean warned;
117
118   // Nudges.  nudgeThread, nudgeSet and nudgeClosed are guarded by nudgeLock.
119   private final Object nudgeLock = new Object();
120   private Thread nudgeThread;
121   private boolean nudgeSet;
122   private boolean nudgeClosed;
123   private volatile boolean nudgeStop;
124
125   private FileTerminalSource(Builder b) {
126      file = b.file;
127      sizeFile = file.resolveSibling(file.getFileName() + ".size");
128      terminal = b.terminal;
129   }
130
131   /**
132    * Creates a builder.
133    *
134    * @param file The log file, supplied by the application.
135    * @return A new builder.
136    */
137   public static Builder create(Path file) {
138      return new Builder(file);
139   }
140
141   /**
142    * The log file.
143    *
144    * @return The path given to {@link #create(Path)}.
145    */
146   public Path file() {
147      return file;
148   }
149
150   /**
151    * Reports growth of the file to a publisher.
152    *
153    * <p>
154    * Starts a daemon thread named {@code juneau-terminal-nudge-file-<name>} that checks the file size every 100 ms and
155    * calls the publisher with the new end when it has grown.  A file that is missing or has shrunk publishes nothing
156    * and the watcher keeps going.  A {@link RuntimeException} thrown by the publisher is logged at {@code FINE} and
157    * otherwise ignored.  No {@code done} nudge is ever published, since the source does not know when the producer
158    * finishes; polling covers it.  {@link #close()} stops the thread.
159    *
160    * @param terminalId The terminal id passed to the publisher; must be a valid run id.
161    * @param publisher The publisher.
162    * @return This object.
163    * @throws NullPointerException If an argument is <jk>null</jk>.
164    * @throws IllegalArgumentException If the id is not a valid run id.
165    * @throws IllegalStateException If a publisher is already set, or this source has been closed.
166    */
167   public FileTerminalSource nudges(String terminalId, TerminalNudgePublisher publisher) {
168      Objects.requireNonNull(terminalId, "terminalId");
169      Objects.requireNonNull(publisher, "publisher");
170      if (! isRunId(terminalId))
171         throw iaex("FileTerminalSource nudge id is not a valid run id: '%s'.", terminalId);
172      synchronized (nudgeLock) {
173         if (nudgeClosed)
174            throw new IllegalStateException("FileTerminalSource is closed.");
175         if (nudgeSet)
176            throw new IllegalStateException("FileTerminalSource nudges are already set.");
177         nudgeSet = true;
178         nudgeThread = new Thread(() -> watch(terminalId, publisher), "juneau-terminal-nudge-file-" + file.getFileName());
179         nudgeThread.setDaemon(true);
180         nudgeThread.start();
181      }
182      return this;
183   }
184
185   /** The nudge thread: reports file growth until the thread is stopped. */
186   private void watch(String id, TerminalNudgePublisher publisher) {
187      var last = -1L;
188      while (! nudgeStop) {
189         var size = sizeOrZero(file);
190         if (size > last) {
191            try {
192               publisher.nudge(id, size, false);
193            } catch (RuntimeException e) {
194               LOG.log(Level.FINE, e, () -> "Terminal nudge publisher failed for '" + id + "'.");
195            }
196            last = size;
197         }
198         try {
199            Thread.sleep(NUDGE_POLL_MILLIS);
200         } catch (InterruptedException e) {
201            Thread.currentThread().interrupt();
202            return;
203         }
204      }
205   }
206
207   private static long sizeOrZero(Path p) {
208      try {
209         return Files.size(p);
210      } catch (IOException e) {
211         return 0;
212      }
213   }
214
215   /**
216    * Stops the nudge thread, if any, and waits up to {@value #NUDGE_JOIN_MILLIS} ms for it.
217    *
218    * <p>
219    * Reads keep working after this; only {@link #nudges(String, TerminalNudgePublisher)} is refused.
220    */
221   @Override /* AutoCloseable */
222   public void close() {
223      Thread t;
224      synchronized (nudgeLock) {
225         nudgeClosed = true;
226         nudgeStop = true;
227         t = nudgeThread;
228      }
229      if (t == null)
230         return;
231      t.interrupt();
232      try {
233         t.join(NUDGE_JOIN_MILLIS);
234      } catch (InterruptedException e) {
235         Thread.currentThread().interrupt();
236      }
237      if (t.isAlive())
238         LOG.log(Level.WARNING, () -> "FileTerminalSource nudge thread did not stop for '" + file + "'.");
239   }
240
241   @Override /* TerminalSource */
242   public TerminalChunk read(long fromOffset, int maxBytes) throws IOException {
243      if (fromOffset < 0)
244         throw iaex("fromOffset must be at least 0; got '%s'.", fromOffset);
245      // Read the flag first: bytes written after this point are simply not done yet, and the next read sees them.
246      var done = terminal.getAsBoolean();
247      var sz = size();
248      long end;
249      try {
250         end = Files.size(file);
251      } catch (NoSuchFileException e) {
252         if (seen)
253            return gone(fromOffset, sz);
254         return new TerminalChunk(new byte[0], 0, 0, done, sz[0], sz[1], false, false);
255      }
256      seen = true;
257      if (fromOffset >= end)
258         return new TerminalChunk(new byte[0], end, end, done, sz[0], sz[1], false, false);
259      var len = (int)Math.min(Math.max(0, Math.min(maxBytes, MAX_READ_BYTES)), end - fromOffset);
260      var buf = ByteBuffer.allocate(len);
261      try (var ch = FileChannel.open(file, StandardOpenOption.READ)) {
262         while (buf.hasRemaining()) {
263            if (ch.read(buf, fromOffset + buf.position()) < 0)
264               break;
265         }
266      } catch (NoSuchFileException e) {
267         return gone(fromOffset, sz);
268      }
269      var bytes = Arrays.copyOf(buf.array(), buf.position());
270      var next = fromOffset + bytes.length;
271      return new TerminalChunk(bytes, next, end, done && next == end, sz[0], sz[1], false, false);
272   }
273
274   private static TerminalChunk gone(long fromOffset, int[] sz) {
275      return new TerminalChunk(new byte[0], fromOffset, fromOffset, true, sz[0], sz[1], false, true);
276   }
277
278   private int[] size() {
279      var s = sizeRef.get();
280      if (s != null)
281         return s;
282      s = parseSize(sizeFile);
283      if (s != null) {
284         sizeRef.set(s);
285         return s;
286      }
287      if (! warned) {
288         warned = true;
289         LOG.warning(() -> "Terminal size sidecar '" + sizeFile + "' is missing or malformed; using " + DEFAULT_COLS + "x" + DEFAULT_ROWS + ".");
290      }
291      return new int[] { DEFAULT_COLS, DEFAULT_ROWS };
292   }
293
294   /** Returns {cols, rows} from a {"cols":C,"rows":R} file, or null when it is missing or malformed. */
295   @SuppressWarnings({
296      "java:S1168" // null means the size sidecar is missing or malformed; callers test for it
297   })
298   static int[] parseSize(Path p) {
299      try {
300         var m = Json.to(Files.readString(p), Map.class);
301         if (m.get("cols") instanceof Number c && m.get("rows") instanceof Number r) {
302            var cols = c.doubleValue();
303            var rows = r.doubleValue();
304            if (cols == Math.rint(cols) && rows == Math.rint(rows) && cols >= 1 && cols <= 9999 && rows >= 1 && rows <= 9999)
305               return new int[] { (int)cols, (int)rows };
306         }
307         return null;
308      } catch (Exception e) {
309         return null;
310      }
311   }
312}