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.bus; 018 019import static org.apache.juneau.terminal.report.TerminalViewChecks.*; 020 021import java.util.*; 022import java.util.concurrent.*; 023import java.util.function.*; 024import java.util.logging.*; 025 026import org.apache.juneau.marshall.collections.*; 027import org.apache.juneau.console.bus.*; 028import org.apache.juneau.terminal.region.*; 029 030/** 031 * Tells browser terminal cards that a terminal has new bytes, so they fetch immediately instead of waiting for the 032 * next poll. 033 * 034 * <p> 035 * This is a {@link TerminalNudgePublisher}: hand it to {@link MemoryTerminalSource#nudges(String, TerminalNudgePublisher)} or 036 * {@link TerminalProcess#nudges(String, TerminalNudgePublisher)}, or call {@link #nudge(String, long, boolean)} after a 037 * write of your own. Each call is coalesced to at most one frame per terminal per 250 ms, carrying the latest offset, 038 * and is published on {@code term.nudge:<id>} as <c>{"end": <offset>}</c>, plus <c>"done": true</c> once no more 039 * bytes will arrive. A nudge is only a hint: the card still reads the bytes through the terminal's bytes endpoint, 040 * and keeps polling as a fallback. 041 * 042 * <p> 043 * Wiring. The bus is built from the policy, so it is bound afterwards, once: 044 * <p class='bjava'> 045 * TerminalNudges <jv>nudges</jv> = TerminalNudges.<jsm>create</jsm>(<jv>access</jv>); 046 * ServerBus <jv>bus</jv> = ServerBus.<jsm>create</jsm>(BusPolicy.<jsm>create</jsm>() 047 * .downstream(<js>"term.nudge:*"</js>, <jk>false</jk>) 048 * .build()); 049 * <jv>nudges</jv>.bind(<jv>bus</jv>); 050 * </p> 051 * 052 * <p> 053 * Access. {@link TerminalAccess#canWatch(TerminalCaller, String)} is called for every delivery, which is up to four 054 * times per second per terminal, for each connected session that holds the grant (up to 256 by default). It must be 055 * cheap. Only sessions holding the exact {@code term.nudge:<id>} grant receive a nudge; a session granted only 056 * {@code term.nudge:*} receives none. A revocation that races a delivery can let that one frame through; the next is 057 * denied. 058 * 059 * <p> 060 * Delivery runs on one daemon thread named {@code juneau-terminal-nudges}. {@link #nudge(String, long, boolean)} only 061 * records state and schedules, so it never blocks the writer. 062 */ 063public final class TerminalNudges implements TerminalNudgePublisher, AutoCloseable { 064 065 /** The topic family; a terminal's topic is {@code term.nudge:<id>}. */ 066 public static final String FAMILY = "term.nudge"; 067 068 private static final Logger LOG = Logger.getLogger(TerminalNudges.class.getName()); 069 private static final long WINDOW_MS = 250; 070 private static final long CLEANUP_MS = 5000; 071 private static final long WARN_MS = 60_000; 072 073 /** Runs delayed tasks; replaced in tests. */ 074 interface Scheduler { 075 /** Starts the scheduler's thread, if it has one. */ 076 default void start() {} 077 078 /** Runs a task after a delay. */ 079 void schedule(Runnable r, long delayMs); 080 081 /** Stops the scheduler. */ 082 void close(); 083 } 084 085 private static final class State { 086 long end; 087 long lastSentAt = Long.MIN_VALUE; 088 long lastTouched; 089 boolean done; 090 boolean scheduled; 091 boolean cleanupPending; 092 } 093 094 private final TerminalAccess access; 095 private final Scheduler scheduler; 096 private final LongSupplier clock; 097 private final Map<String,State> states = new HashMap<>(); 098 private final Map<String,Long> lastWarned = new HashMap<>(); 099 private ServerBus bus; 100 private boolean closed; 101 102 TerminalNudges(TerminalAccess access, Scheduler scheduler, LongSupplier clock) { 103 this.access = access; 104 this.scheduler = scheduler; 105 this.clock = clock; 106 } 107 108 /** 109 * Creates a publisher. 110 * 111 * @param access Who may watch. Must not be <jk>null</jk>. 112 * @return A new, unbound publisher. 113 * @throws IllegalArgumentException If {@code access} is <jk>null</jk>. 114 */ 115 public static TerminalNudges create(TerminalAccess access) { 116 if (access == null) 117 throw new IllegalArgumentException("TerminalNudges.create: access must not be null"); 118 return new TerminalNudges(access, new ThreadScheduler(), () -> System.nanoTime() / 1_000_000); 119 } 120 121 /** 122 * The nudge topic of a terminal. 123 * 124 * @param terminalId The terminal id. 125 * @return {@code term.nudge:<id>}. 126 * @throws IllegalArgumentException If the id is not a valid run id. 127 */ 128 public static String topic(String terminalId) { 129 if (terminalId == null || ! isRunId(terminalId)) 130 throw new IllegalArgumentException("TerminalNudges: '" + terminalId + "' is not a valid terminal id"); 131 return FAMILY + ":" + terminalId; 132 } 133 134 /** 135 * Binds the bus the nudges are published on, and starts the scheduler. May be called once. 136 * 137 * @param bus The bus. Its policy must allow {@code term.nudge:*} downstream without retention. 138 * @return This object. 139 * @throws IllegalArgumentException If {@code bus} is <jk>null</jk>. 140 * @throws IllegalStateException If a bus is already bound, or the policy does not allow {@code term.nudge:*} 141 * downstream unretained. 142 */ 143 public TerminalNudges bind(ServerBus bus) { 144 if (bus == null) 145 throw new IllegalArgumentException("TerminalNudges.bind: bus must not be null"); 146 synchronized (this) { 147 if (this.bus != null) 148 throw new IllegalStateException("TerminalNudges.bind: already bound"); 149 var retain = bus.policy().downstreamRetain(FAMILY + ":*"); 150 if (retain.orElse(true)) 151 throw new IllegalStateException("TerminalNudges.bind: the bus policy must allow '" + FAMILY + ":*' downstream without retention"); 152 // Started before the bus is visible: a nudge that sees the bus must find a running scheduler. 153 scheduler.start(); 154 this.bus = bus; 155 } 156 return this; 157 } 158 159 /** 160 * Records that a terminal's bytes now end at an offset; delivery is coalesced and happens on the scheduler thread. 161 * 162 * <p> 163 * Dropped silently before {@link #bind(ServerBus)} and after {@link #close()}. 164 * 165 * @param terminalId The terminal id. 166 * @param end The offset one past the last byte. 167 * @param done <jk>true</jk> if no more bytes will be appended. 168 * @throws IllegalArgumentException If the id is not a valid run id. 169 */ 170 @Override /* TerminalNudgePublisher */ 171 public void nudge(String terminalId, long end, boolean done) { 172 topic(terminalId); 173 synchronized (this) { 174 if (bus == null || closed) 175 return; 176 var now = clock.getAsLong(); 177 var st = states.computeIfAbsent(terminalId, k -> new State()); 178 st.end = Math.max(st.end, end); 179 st.done |= done; 180 st.lastTouched = now; 181 if (! st.scheduled) { 182 st.scheduled = true; 183 var delay = st.lastSentAt == Long.MIN_VALUE ? 0 : Math.max(0, st.lastSentAt + WINDOW_MS - now); 184 scheduler.schedule(guarded(() -> flush(terminalId)), delay); 185 } 186 } 187 } 188 189 /** 190 * Stops the scheduler. Flushes already queued, and later nudges, deliver nothing. Idempotent. 191 */ 192 @Override /* AutoCloseable */ 193 public void close() { 194 synchronized (this) { 195 if (closed) 196 return; 197 closed = true; 198 states.clear(); 199 lastWarned.clear(); 200 } 201 scheduler.close(); 202 } 203 204 /** The number of terminals with state held; for tests. */ 205 synchronized int pendingTerminals() { 206 return states.size(); 207 } 208 209 /** The number of terminals with a rate-limited warning held; for tests. */ 210 synchronized int warnedTerminals() { 211 return lastWarned.size(); 212 } 213 214 private Runnable guarded(Runnable r) { 215 return () -> { 216 try { 217 r.run(); 218 } catch (RuntimeException e) { 219 LOG.log(Level.WARNING, e, () -> "Terminal nudge task failed."); 220 } 221 }; 222 } 223 224 private void flush(String id) { 225 ServerBus b; 226 long end; 227 boolean done; 228 synchronized (this) { 229 if (closed) 230 return; 231 var st = states.get(id); 232 if (st == null) 233 return; 234 // Reset first, so a failure below cannot leave the terminal marked as scheduled forever. 235 st.scheduled = false; 236 st.lastSentAt = clock.getAsLong(); 237 b = bus; 238 end = st.end; 239 done = st.done; 240 st.done = false; 241 } 242 var delivered = false; 243 try { 244 deliver(b, id, end, done); 245 delivered = true; 246 } finally { 247 synchronized (this) { 248 var st = states.get(id); 249 if (st != null && ! closed) { 250 if (! delivered) { 251 if (st.scheduled) { 252 // A later nudge is already queued: it carries the done report. 253 st.done |= done; 254 } else { 255 // No follow-up: drop the state; polling finishes the card. 256 states.remove(id); 257 lastWarned.remove(id); 258 } 259 } else if (done) { 260 // A nudge right after a done delivery starts a fresh state and skips the 250 ms window (allowed). 261 if (! st.scheduled) { 262 states.remove(id); 263 lastWarned.remove(id); 264 } 265 } else if (! st.cleanupPending) { 266 st.cleanupPending = true; 267 scheduler.schedule(guarded(() -> cleanup(id)), CLEANUP_MS); 268 } 269 } 270 } 271 } 272 } 273 274 private void cleanup(String id) { 275 synchronized (this) { 276 var st = states.get(id); 277 if (st == null || closed) 278 return; 279 st.cleanupPending = false; 280 var age = clock.getAsLong() - st.lastTouched; 281 if (st.scheduled) 282 return; 283 if (age >= CLEANUP_MS) { 284 states.remove(id); 285 lastWarned.remove(id); 286 } else { 287 st.cleanupPending = true; 288 scheduler.schedule(guarded(() -> cleanup(id)), CLEANUP_MS - age); 289 } 290 } 291 } 292 293 private void deliver(ServerBus b, String id, long end, boolean done) { 294 var topic = topic(id); 295 var allowed = Collections.newSetFromMap(new IdentityHashMap<BusSession,Boolean>()); 296 for (var s : b.sessions()) { 297 if (! s.isConnected() || ! s.downstream().contains(topic)) 298 continue; 299 try { 300 if (access.canWatch(new SessionCaller(s), id)) 301 allowed.add(s); 302 } catch (RuntimeException e) { 303 warnDenied(id, e); 304 } 305 } 306 if (allowed.isEmpty()) 307 return; 308 var payload = done ? JsonMap.of("end", end, "done", true) : JsonMap.of("end", end); 309 try { 310 b.publishTo(allowed::contains, topic, payload); 311 } catch (IllegalArgumentException e) { 312 LOG.log(Level.WARNING, e, () -> "Terminal nudge for '" + id + "' was refused by the bus."); 313 } 314 } 315 316 private void warnDenied(String id, RuntimeException e) { 317 var now = clock.getAsLong(); 318 synchronized (this) { 319 var last = lastWarned.get(id); 320 if (last != null && now - last < WARN_MS) 321 return; 322 lastWarned.put(id, now); 323 } 324 LOG.log(Level.WARNING, e, () -> "TerminalAccess.canWatch failed for '" + id + "'; the session gets no nudge."); 325 } 326 327 /** The default scheduler: one daemon thread. */ 328 private static final class ThreadScheduler implements Scheduler { 329 private ScheduledExecutorService executor; 330 331 @Override public synchronized void start() { 332 if (executor != null) 333 return; 334 var ex = new ScheduledThreadPoolExecutor(1, r -> { 335 var t = new Thread(r, "juneau-terminal-nudges"); 336 t.setDaemon(true); 337 return t; 338 }); 339 ex.setRemoveOnCancelPolicy(true); 340 ex.prestartAllCoreThreads(); 341 executor = ex; 342 } 343 344 @Override public synchronized void schedule(Runnable r, long delayMs) { 345 if (executor == null) 346 return; 347 try { 348 executor.schedule(r, delayMs, TimeUnit.MILLISECONDS); 349 } catch (RejectedExecutionException e) { 350 // Closed concurrently: the nudge is dropped. 351 } 352 } 353 354 @Override public synchronized void close() { 355 if (executor != null) 356 executor.shutdownNow(); 357 } 358 } 359}