1 /* 2 * ************************************************************************************************************************************************************* 3 * 4 * TheseFoolishThings: Miscellaneous utilities 5 * http://tidalwave.it/projects/thesefoolishthings 6 * 7 * Copyright (C) 2009 - 2025 by Tidalwave s.a.s. (http://tidalwave.it) 8 * 9 * ************************************************************************************************************************************************************* 10 * 11 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. 12 * You may obtain a copy of the License at 13 * 14 * http://www.apache.org/licenses/LICENSE-2.0 15 * 16 * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 17 * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 18 * 19 * ************************************************************************************************************************************************************* 20 * 21 * git clone https://bitbucket.org/tidalwave/thesefoolishthings-src 22 * git clone https://github.com/tidalwave-it/thesefoolishthings-src 23 * 24 * ************************************************************************************************************************************************************* 25 */ 26 package it.tidalwave.util.spi; 27 28 import javax.annotation.concurrent.ThreadSafe; 29 import jakarta.annotation.Nonnull; 30 import jakarta.annotation.Nullable; 31 import java.util.ArrayList; 32 import java.util.Collections; 33 import java.util.List; 34 import java.util.Scanner; 35 import java.util.concurrent.ExecutorService; 36 import java.util.concurrent.Executors; 37 import java.util.concurrent.atomic.AtomicBoolean; 38 import java.util.concurrent.atomic.AtomicInteger; 39 import java.util.regex.Pattern; 40 import java.io.File; 41 import java.io.IOException; 42 import java.io.InputStream; 43 import java.io.InputStreamReader; 44 import java.io.PrintWriter; 45 import it.tidalwave.util.ProcessExecutor; 46 import lombok.AccessLevel; 47 import lombok.Getter; 48 import lombok.NoArgsConstructor; 49 import lombok.RequiredArgsConstructor; 50 import lombok.Setter; 51 import lombok.extern.slf4j.Slf4j; 52 53 /*************************************************************************************************************************************************************** 54 * 55 * @author Fabrizio Giudici 56 * @since 1.39 57 * 58 **************************************************************************************************************************************************************/ 59 @ThreadSafe @NoArgsConstructor(access=AccessLevel.PRIVATE) @Slf4j 60 public class DefaultProcessExecutor implements ProcessExecutor 61 { 62 private final ExecutorService executorService = Executors.newFixedThreadPool(10); 63 64 /*********************************************************************************************************************************************************** 65 **********************************************************************************************************************************************************/ 66 @RequiredArgsConstructor(access=AccessLevel.PACKAGE) 67 public class DefaultConsoleOutput implements ConsoleOutput 68 { 69 @Nonnull 70 private final String name; 71 72 @Nonnull 73 private final InputStream input; 74 75 @Getter 76 private final List<String> content = Collections.synchronizedList(new ArrayList<>()); 77 78 private volatile String latestLine; 79 80 private final AtomicInteger li = new AtomicInteger(0); 81 82 private final AtomicBoolean started = new AtomicBoolean(); 83 84 @Nullable @Setter @Getter 85 private Listener listener; 86 87 /*************************************************************************************************************** 88 * 89 * 90 ***************************************************************************************************************/ 91 private final Runnable reader = new Runnable() 92 { 93 @Override 94 public void run() 95 { 96 try 97 { 98 read(); 99 } 100 catch (IOException e) 101 { 102 log.warn("while reading from " + name, e); 103 } 104 } 105 }; 106 107 /*************************************************************************************************************** 108 * 109 * 110 ***************************************************************************************************************/ 111 private final Runnable logger = new Runnable() 112 { 113 @Override 114 public void run() 115 { 116 var l = 0; 117 118 for (;;) 119 { 120 try 121 { 122 if ((l != li.get()) && (latestLine != null)) 123 { 124 log.trace(">>>>>>>> {} {}", name, latestLine); 125 } 126 127 l = li.get(); 128 Thread.sleep(500); 129 } 130 catch (Throwable e) 131 { 132 return; 133 } 134 } 135 } 136 }; 137 138 /*************************************************************************************************************** 139 * 140 * Should not be used by the programmer. 141 * 142 * @return - 143 * 144 ***************************************************************************************************************/ 145 @Nonnull 146 public ConsoleOutput start() 147 { 148 if (started.getAndSet(true)) 149 { 150 throw new IllegalStateException("Already started"); 151 } 152 153 log.info("{} - started", name); 154 executorService.submit(reader); 155 executorService.submit(logger); 156 return this; 157 } 158 159 /*************************************************************************************************************** 160 * 161 * {@inheritDoc} 162 * 163 ***************************************************************************************************************/ 164 @Override 165 public boolean latestLineMatches (@Nonnull final String regexp) 166 { 167 String s = null; 168 169 if (latestLine != null) 170 { 171 s = latestLine; 172 } 173 else if (!content.isEmpty()) 174 { 175 s = content.get(content.size() - 1); 176 } 177 178 log.trace(">>>> testing '{}' for '{}'", s, regexp); 179 return (s != null) && Pattern.compile(regexp).matcher(s).matches(); 180 // FIXME: sync 181 } 182 183 /*************************************************************************************************************** 184 * 185 * {@inheritDoc} 186 * 187 ***************************************************************************************************************/ 188 @Override @Nonnull 189 public Scanner filteredAndSplitBy (@Nonnull final String filterRegexp, @Nonnull final String delimiterRegexp) 190 { 191 final var string = filteredBy(filterRegexp).get(0); 192 return new Scanner(string).useDelimiter(Pattern.compile(delimiterRegexp)); 193 } 194 195 /*************************************************************************************************************** 196 * 197 * {@inheritDoc} 198 * 199 ***************************************************************************************************************/ 200 @Override @Nonnull 201 public List<String> filteredBy (@Nonnull final String regexp) 202 { 203 final var pattern = Pattern.compile(regexp); 204 final List<String> result = new ArrayList<>(); 205 final var strings = new ArrayList<>(content); 206 207 // TODO: sync 208 if (latestLine != null) 209 { 210 strings.add(latestLine); 211 } 212 213 for (final var s : strings) 214 { 215 // log.trace(">>>>>>>> matching '{}' with '{}'...", s, filter); 216 final var m = pattern.matcher(s); 217 218 if (m.matches()) 219 { 220 result.add(m.group(1)); 221 } 222 } 223 224 return result; 225 } 226 227 /*************************************************************************************************************** 228 * 229 * {@inheritDoc} 230 * 231 ***************************************************************************************************************/ 232 @Override @Nonnull 233 public ConsoleOutput waitFor (@Nonnull final String regexp) 234 throws InterruptedException, IOException 235 { 236 log.debug("{} - waitFor({})", name, regexp); 237 238 while (filteredBy(regexp).isEmpty()) 239 { 240 try 241 { 242 final var exitValue = process.exitValue(); 243 throw new IOException("Process exited with " + exitValue); 244 } 245 catch (IllegalThreadStateException e) // ok, process not terminated yet 246 { 247 synchronized (this) 248 { 249 wait(50); // FIXME: polls because it doesn't get notified 250 } 251 } 252 } 253 254 return this; 255 } 256 257 /*************************************************************************************************************** 258 * 259 * {@inheritDoc} 260 * 261 ***************************************************************************************************************/ 262 @Override 263 public void clear() 264 { 265 content.clear(); 266 latestLine = null; 267 } 268 269 /*************************************************************************************************************** 270 * 271 * 272 ***************************************************************************************************************/ 273 private void read() 274 throws IOException 275 { 276 try (final var is = new InputStreamReader(input)) 277 { 278 var l = new StringBuilder(); 279 280 for (;;) 281 { 282 final var c = is.read(); 283 284 if (c < 0) 285 { 286 break; 287 } 288 289 // if (c == 10) 290 // { 291 // continue; 292 // } 293 294 if ((c == 13) || (c == 10)) 295 { 296 latestLine = l.toString(); 297 li.incrementAndGet(); 298 content.add(latestLine); 299 l = new StringBuilder(); 300 log.trace(">>>>>>>> {} {}", name, latestLine); 301 302 if (listener != null) 303 { 304 listener.onReceived(latestLine); 305 } 306 } 307 else 308 { 309 l.append((char)c); 310 latestLine = l.toString(); 311 li.incrementAndGet(); 312 } 313 314 synchronized (this) 315 { 316 notifyAll(); 317 } 318 } 319 320 log.debug(">>>>>> {} closed", name); 321 } 322 } 323 } 324 325 private final List<String> arguments = new ArrayList<>(); 326 327 private Process process; 328 329 @Getter 330 private ConsoleOutput stdout; 331 332 @Getter 333 private ConsoleOutput stderr; 334 335 private PrintWriter stdin; 336 337 /*********************************************************************************************************************************************************** 338 * Factory method for associating an executable. It returns an intermediate executor that must be configured and 339 * later started. Under Windows, the '.exe' suffix is automatically appended to the name of the executable. 340 * 341 * @see #start() 342 * 343 * @param executable the executable (with the full path) 344 * @return the executor 345 **********************************************************************************************************************************************************/ 346 @Nonnull 347 public static DefaultProcessExecutor forExecutable (@Nonnull final String executable) 348 { 349 final var executor = new DefaultProcessExecutor(); 350 executor.arguments.add(new File(executable + (isWindows() ? ".exe" : "")).getAbsolutePath()); 351 return executor; 352 } 353 354 // /******************************************************************************************************************* 355 // * 356 // * 357 // ******************************************************************************************************************/ 358 // @Nonnull 359 // private static String findPath (final @Nonnull String executable) 360 // throws NotFoundException 361 // { 362 // for (final String path : System.getenv("PATH").split(File.pathSeparator)) 363 // { 364 // final File file = new File(new File(path), executable); 365 // 366 // if (file.canExecute()) 367 // { 368 // return file.getAbsolutePath(); 369 // } 370 // } 371 // 372 // throw new NotFoundException("Can't find " + executable + " in PATH"); 373 // } 374 375 /*********************************************************************************************************************************************************** 376 * {@inheritDoc} 377 **********************************************************************************************************************************************************/ 378 @Override @Nonnull 379 public DefaultProcessExecutor withArgument (@Nonnull final String argument) 380 { 381 arguments.add(argument); 382 return this; 383 } 384 385 /*********************************************************************************************************************************************************** 386 * {@inheritDoc} 387 **********************************************************************************************************************************************************/ 388 @Override @Nonnull 389 public DefaultProcessExecutor withArguments (@Nonnull final String ... arguments) 390 { 391 this.arguments.addAll(List.of(arguments)); 392 return this; 393 } 394 395 /*********************************************************************************************************************************************************** 396 * {@inheritDoc} 397 **********************************************************************************************************************************************************/ 398 @Override @Nonnull 399 public DefaultProcessExecutor start() 400 throws IOException 401 { 402 log.info(">>>> executing {} ...", arguments); 403 404 final List<String> environment = new ArrayList<>(); 405 406 for (final var e : System.getenv().entrySet()) 407 { 408 environment.add(String.format("%s=%s", e.getKey(), e.getValue())); 409 } 410 411 log.info(">>>> environment: {}", environment); 412 process = Runtime.getRuntime().exec(arguments.toArray(new String[0]), 413 environment.toArray(new String[0])); 414 415 stdout = new DefaultConsoleOutput("out", process.getInputStream()).start(); 416 stderr = new DefaultConsoleOutput("err", process.getErrorStream()).start(); 417 stdin = new PrintWriter(process.getOutputStream(), true); 418 419 return this; 420 } 421 422 /*********************************************************************************************************************************************************** 423 * {@inheritDoc} 424 **********************************************************************************************************************************************************/ 425 @Override 426 public void stop() 427 { 428 log.info("stop()"); 429 process.destroy(); 430 executorService.shutdownNow(); 431 } 432 433 /*********************************************************************************************************************************************************** 434 * {@inheritDoc} 435 **********************************************************************************************************************************************************/ 436 @Override @Nonnull 437 public DefaultProcessExecutor waitForCompletion() 438 throws InterruptedException 439 { 440 if (process.waitFor() != 0) 441 { 442 // throw new IOException("Process exited with " + process.exitValue()); FIXME 443 } 444 445 return this; 446 } 447 448 /*********************************************************************************************************************************************************** 449 * {@inheritDoc} 450 **********************************************************************************************************************************************************/ 451 @Override @Nonnull 452 public DefaultProcessExecutor send (@Nonnull final String string) 453 { 454 log.debug(">>>> sending '{}'...", string.replaceAll("\n", "<CR>")); 455 stdin.print(string); 456 stdin.flush(); 457 return this; 458 } 459 460 /*********************************************************************************************************************************************************** 461 **********************************************************************************************************************************************************/ 462 private static boolean isWindows() 463 { 464 return System.getProperty ("os.name").toLowerCase().startsWith("windows"); 465 } 466 }