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×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}× 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(() -> <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}