View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.helper;
17  
18  import java.io.IOException;
19  import java.io.OutputStream;
20  import java.util.List;
21  import java.util.Set;
22  import java.util.concurrent.ConcurrentHashMap;
23  import java.util.concurrent.CountDownLatch;
24  import java.util.concurrent.TimeUnit;
25  import java.util.function.Consumer;
26  
27  import org.apache.commons.io.IOUtils;
28  import org.apache.logging.log4j.LogManager;
29  import org.apache.logging.log4j.Logger;
30  import org.codelibs.core.io.CloseableUtil;
31  import org.codelibs.fess.Constants;
32  import org.codelibs.fess.exception.JobNotFoundException;
33  import org.codelibs.fess.exception.JobProcessingException;
34  import org.codelibs.fess.util.InputStreamThread;
35  import org.codelibs.fess.util.JobProcess;
36  
37  import jakarta.annotation.PreDestroy;
38  
39  /**
40   * Helper class for managing system processes in Fess.
41   * This class provides functionality to start, stop, and manage external processes
42   * such as crawler processes, with proper resource cleanup and lifecycle management.
43   */
44  public class ProcessHelper {
45      /** Logger instance for this class */
46      private static final Logger logger = LogManager.getLogger(ProcessHelper.class);
47  
48      /** Map of running processes indexed by session ID */
49      protected final ConcurrentHashMap<String, JobProcess> runningProcessMap = new ConcurrentHashMap<>();
50  
51      /** Timeout in seconds for process destruction */
52      protected int processDestroyTimeout = 10;
53  
54      /** Timeout in seconds for stream closing operations */
55      protected int streamCloseTimeout = 10;
56  
57      /**
58       * Default constructor for ProcessHelper.
59       * Initializes the process management system with default timeout values.
60       */
61      public ProcessHelper() {
62          // Default constructor
63      }
64  
65      /**
66       * Cleanup method called when the bean is destroyed.
67       * Stops all running processes and cleans up resources.
68       */
69      @PreDestroy
70      public void destroy() {
71          for (final String sessionId : runningProcessMap.keySet()) {
72              if (logger.isInfoEnabled()) {
73                  logger.info("Stopping process {}", sessionId);
74              }
75              if (destroyProcess(sessionId) == 0 && logger.isInfoEnabled()) {
76                  logger.info("Stopped process {}", sessionId);
77              }
78          }
79      }
80  
81      /**
82       * Starts a new process with the given session ID and command list.
83       * Uses default buffer size and no output callback.
84       *
85       * @param sessionId unique identifier for the process session
86       * @param cmdList list of command and arguments to execute
87       * @param pbCall callback to configure the ProcessBuilder
88       * @return JobProcess representing the started process
89       */
90      public JobProcess startProcess(final String sessionId, final List<String> cmdList, final Consumer<ProcessBuilder> pbCall) {
91          return startProcess(sessionId, cmdList, pbCall, InputStreamThread.MAX_BUFFER_SIZE, null);
92      }
93  
94      /**
95       * Starts a new process with the given session ID, command list, buffer size, and output callback.
96       * This method is synchronized to ensure thread safety when managing processes.
97       *
98       * @param sessionId unique identifier for the process session
99       * @param cmdList list of command and arguments to execute
100      * @param pbCall callback to configure the ProcessBuilder
101      * @param bufferSize size of the buffer for process output
102      * @param outputCallback callback to handle process output lines
103      * @return JobProcess representing the started process
104      * @throws JobProcessingException if the process cannot be started
105      */
106     public synchronized JobProcess startProcess(final String sessionId, final List<String> cmdList, final Consumer<ProcessBuilder> pbCall,
107             final int bufferSize, final Consumer<String> outputCallback) {
108         final ProcessBuilder pb = new ProcessBuilder(cmdList);
109         pbCall.accept(pb);
110 
111         // Remove and destroy any existing process for this session
112         final JobProcess oldProcess = runningProcessMap.remove(sessionId);
113         if (oldProcess != null) {
114             destroyProcess(sessionId, oldProcess);
115         }
116 
117         // Start the new process and add it to the map
118         try {
119             final JobProcess jobProcess = new JobProcess(pb.start(), bufferSize, outputCallback);
120             runningProcessMap.put(sessionId, jobProcess);
121             return jobProcess;
122         } catch (final IOException e) {
123             throw new JobProcessingException("Crawler Process terminated.", e);
124         }
125     }
126 
127     /**
128      * Destroys the process associated with the given session ID.
129      *
130      * @param sessionId unique identifier for the process session
131      * @return exit code of the destroyed process, or -1 if the process was not found
132      */
133     public synchronized int destroyProcess(final String sessionId) {
134         final JobProcess jobProcess = runningProcessMap.remove(sessionId);
135         return destroyProcess(sessionId, jobProcess);
136     }
137 
138     /**
139      * Checks if any processes are currently running.
140      *
141      * @return true if at least one process is running, false otherwise
142      */
143     public boolean isProcessRunning() {
144         return !runningProcessMap.isEmpty();
145     }
146 
147     /**
148      * Checks if the process with the given session ID is currently running.
149      *
150      * @param sessionId unique identifier for the process session
151      * @return true if the process is running, false otherwise
152      */
153     public boolean isProcessRunning(final String sessionId) {
154         final JobProcess jobProcess = runningProcessMap.get(sessionId);
155         return jobProcess != null && jobProcess.getProcess().isAlive();
156     }
157 
158     /**
159      * Internal method to destroy a specific JobProcess.
160      * Handles cleanup of streams, threads, and process termination.
161      *
162      * @param sessionId unique identifier for the process session
163      * @param jobProcess the JobProcess to destroy
164      * @return exit code of the destroyed process, or -1 if the process was null or could not be destroyed
165      */
166     protected int destroyProcess(final String sessionId, final JobProcess jobProcess) {
167         if (jobProcess != null) {
168             final InputStreamThread ist = jobProcess.getInputStreamThread();
169             try {
170                 ist.interrupt();
171             } catch (final Exception e) {
172                 logger.warn("Could not interrupt a thread of an input stream.", e);
173             }
174 
175             final CountDownLatch latch = new CountDownLatch(3);
176             final Process process = jobProcess.getProcess();
177             new Thread(() -> {
178                 try {
179                     CloseableUtil.closeQuietly(process.getInputStream());
180                 } catch (final Exception e) {
181                     logger.warn("Could not close a process input stream.", e);
182                 } finally {
183                     latch.countDown();
184                 }
185             }, "ProcessCloser-input-" + sessionId).start();
186             new Thread(() -> {
187                 try {
188                     CloseableUtil.closeQuietly(process.getErrorStream());
189                 } catch (final Exception e) {
190                     logger.warn("Could not close a process error stream.", e);
191                 } finally {
192                     latch.countDown();
193                 }
194             }, "ProcessCloser-error-" + sessionId).start();
195             new Thread(() -> {
196                 try {
197                     CloseableUtil.closeQuietly(process.getOutputStream());
198                 } catch (final Exception e) {
199                     logger.warn("Could not close a process output stream.", e);
200                 } finally {
201                     latch.countDown();
202                 }
203             }, "ProcessCloser-output-" + sessionId).start();
204 
205             try {
206                 latch.await(streamCloseTimeout, TimeUnit.SECONDS);
207             } catch (final InterruptedException e) {
208                 logger.warn("Interrupted to wait a process.", e);
209             }
210             try {
211                 process.destroyForcibly().waitFor(processDestroyTimeout, TimeUnit.SECONDS);
212                 return process.exitValue();
213             } catch (final Exception e) {
214                 logger.error("Could not destroy a process correctly.", e);
215             }
216         }
217         return -1;
218     }
219 
220     /**
221      * Gets the set of session IDs for all currently running processes.
222      *
223      * @return set of session IDs for running processes
224      */
225     public Set<String> getRunningSessionIdSet() {
226         return runningProcessMap.keySet();
227     }
228 
229     /**
230      * Sets the timeout for process destruction.
231      *
232      * @param processDestroyTimeout timeout in seconds for process destruction
233      */
234     public void setProcessDestroyTimeout(final int processDestroyTimeout) {
235         this.processDestroyTimeout = processDestroyTimeout;
236     }
237 
238     /**
239      * Sets the timeout for stream closing operations.
240      *
241      * @param streamCloseTimeout timeout in seconds for stream closing operations
242      */
243     public void setStreamCloseTimeout(final int streamCloseTimeout) {
244         this.streamCloseTimeout = streamCloseTimeout;
245     }
246 
247     /**
248      * Sends a command to the process associated with the given session ID.
249      * Uses finer-grained locking to avoid blocking other operations during I/O.
250      *
251      * @param sessionId unique identifier for the process session
252      * @param command the command to send to the process
253      * @throws JobNotFoundException if no process is found for the given session ID
254      * @throws JobProcessingException if there's an error sending the command
255      */
256     public void sendCommand(final String sessionId, final String command) {
257         final Process process;
258         synchronized (this) {
259             final JobProcess jobProcess = runningProcessMap.get(sessionId);
260             if (jobProcess == null) {
261                 throw new JobNotFoundException("Job for " + sessionId + " is not found.");
262             }
263             process = jobProcess.getProcess();
264             if (process == null || !process.isAlive()) {
265                 throw new JobNotFoundException("Process for " + sessionId + " is not running.");
266             }
267         }
268 
269         // Perform I/O operations outside synchronized block to avoid blocking other threads
270         try {
271             final OutputStream out = process.getOutputStream();
272             IOUtils.write(command + "\n", out, Constants.CHARSET_UTF_8);
273             out.flush();
274         } catch (final IOException e) {
275             throw new JobProcessingException(e);
276         }
277     }
278 }