Libre threads (#5303)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-01-15 19:14:45 +00:00
committed by GitHub
parent 8afbd0afed
commit 3e061516a5
16 changed files with 1449 additions and 47 deletions
@@ -2,6 +2,9 @@ package stirling.software.common.configuration;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Configuration;
@@ -14,6 +17,8 @@ import stirling.software.common.model.ApplicationProperties.CustomPaths;
import stirling.software.common.model.ApplicationProperties.CustomPaths.Operations;
import stirling.software.common.model.ApplicationProperties.CustomPaths.Pipeline;
import stirling.software.common.model.ApplicationProperties.System;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.UnoServerPool;
@Slf4j
@Configuration
@@ -32,6 +37,8 @@ public class RuntimePathConfig {
// Tesseract data path
private final String tessDataPath;
private final List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> unoServerEndpoints;
// Pipeline paths
private final String pipelineWatchedFoldersPath;
private final String pipelineFinishedFoldersPath;
@@ -108,6 +115,14 @@ public class RuntimePathConfig {
}
log.info("Using Tesseract data path: {}", this.tessDataPath);
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
int libreOfficeLimit = 1;
if (processExecutor != null && processExecutor.getSessionLimit() != null) {
libreOfficeLimit = processExecutor.getSessionLimit().getLibreOfficeSessionLimit();
}
this.unoServerEndpoints = buildUnoServerEndpoints(processExecutor, libreOfficeLimit);
ProcessExecutor.setUnoServerPool(new UnoServerPool(this.unoServerEndpoints));
}
private String resolvePath(String defaultPath, String customPath) {
@@ -117,4 +132,69 @@ public class RuntimePathConfig {
private boolean isRunningInDocker() {
return Files.exists(Path.of("/.dockerenv"));
}
private List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> buildUnoServerEndpoints(
ApplicationProperties.ProcessExecutor processExecutor, int sessionLimit) {
if (processExecutor == null) {
log.warn("ProcessExecutor config missing; defaulting to a single UNO endpoint.");
return Collections.singletonList(
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint());
}
if (!processExecutor.isAutoUnoServer()) {
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> configured =
sanitizeUnoServerEndpoints(processExecutor.getUnoServerEndpoints());
if (!configured.isEmpty()) {
// Warn if manual endpoint count doesn't match sessionLimit
if (configured.size() != sessionLimit) {
log.warn(
"Manual UNO endpoint count ({}) differs from libreOfficeSessionLimit ({}). "
+ "Concurrency will be limited by endpoint count, not sessionLimit.",
configured.size(),
sessionLimit);
}
return configured;
}
log.warn(
"autoUnoServer disabled but no unoServerEndpoints configured; defaulting to 127.0.0.1:2003.");
return Collections.singletonList(
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint());
}
int count = sessionLimit > 0 ? sessionLimit : 1;
return buildAutoUnoServerEndpoints(count);
}
private List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint>
buildAutoUnoServerEndpoints(int count) {
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints = new ArrayList<>();
int basePort = 2003;
for (int i = 0; i < count; i++) {
ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint =
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint();
endpoint.setHost("127.0.0.1");
endpoint.setPort(basePort + (i * 2));
endpoints.add(endpoint);
}
return endpoints;
}
private List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint>
sanitizeUnoServerEndpoints(
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints) {
if (endpoints == null || endpoints.isEmpty()) {
return Collections.emptyList();
}
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> sanitized = new ArrayList<>();
for (ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint : endpoints) {
if (endpoint == null) {
continue;
}
String host = endpoint.getHost();
int port = endpoint.getPort();
if (host == null || host.isBlank() || port <= 0) {
continue;
}
sanitized.add(endpoint);
}
return sanitized;
}
}
@@ -827,6 +827,16 @@ public class ApplicationProperties {
public static class ProcessExecutor {
private SessionLimit sessionLimit = new SessionLimit();
private TimeoutMinutes timeoutMinutes = new TimeoutMinutes();
private boolean autoUnoServer = true;
private List<UnoServerEndpoint> unoServerEndpoints = new ArrayList<>();
@Data
public static class UnoServerEndpoint {
private String host = "127.0.0.1";
private int port = 2003;
private String hostLocation = "auto"; // auto|local|remote
private String protocol = "http"; // http|https
}
@Data
public static class SessionLimit {
@@ -360,8 +360,6 @@ public class PDFToFile {
Path inputFile, Path outputFile, String outputFormat, String libreOfficeFilter) {
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getUnoConvertPath());
command.add("--port");
command.add("2003");
command.add("--convert-to");
command.add(outputFormat);
if (libreOfficeFilter != null && !libreOfficeFilter.isBlank()) {
@@ -6,9 +6,12 @@ import java.io.IOException;
import java.io.InputStreamReader;
import java.io.InterruptedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
@@ -26,11 +29,15 @@ public class ProcessExecutor {
private static final Map<Processes, ProcessExecutor> instances = new ConcurrentHashMap<>();
private static ApplicationProperties applicationProperties = new ApplicationProperties();
private static volatile UnoServerPool unoServerPool;
private final Semaphore semaphore;
private final boolean liveUpdates;
private long timeoutDuration;
private final Processes processType;
private ProcessExecutor(int semaphoreLimit, boolean liveUpdates, long timeout) {
private ProcessExecutor(
Processes processType, int semaphoreLimit, boolean liveUpdates, long timeout) {
this.processType = processType;
this.semaphore = new Semaphore(semaphoreLimit);
this.liveUpdates = liveUpdates;
this.timeoutDuration = timeout;
@@ -173,10 +180,15 @@ public class ProcessExecutor {
.getTimeoutMinutes()
.getFfmpegTimeoutMinutes();
};
return new ProcessExecutor(semaphoreLimit, liveUpdates, timeoutMinutes);
return new ProcessExecutor(
processType, semaphoreLimit, liveUpdates, timeoutMinutes);
});
}
public static void setUnoServerPool(UnoServerPool pool) {
unoServerPool = pool;
}
public ProcessExecutorResult runCommandWithOutputHandling(List<String> command)
throws IOException, InterruptedException {
return runCommandWithOutputHandling(command, null);
@@ -186,11 +198,22 @@ public class ProcessExecutor {
List<String> command, File workingDirectory) throws IOException, InterruptedException {
String messages = "";
int exitCode = 1;
semaphore.acquire();
UnoServerPool.UnoServerLease unoLease = null;
boolean useSemaphore = true;
List<String> commandToRun = command;
if (shouldUseUnoServerPool(command)) {
unoLease = unoServerPool.acquireEndpoint();
commandToRun = applyUnoServerEndpoint(command, unoLease.getEndpoint());
useSemaphore = false;
}
if (useSemaphore) {
semaphore.acquire();
}
try {
log.info("Running command: {}", String.join(" ", command));
ProcessBuilder processBuilder = new ProcessBuilder(command);
validateCommand(commandToRun);
log.info("Running command: {}", String.join(" ", commandToRun));
ProcessBuilder processBuilder = new ProcessBuilder(commandToRun);
// Use the working directory if it's set
if (workingDirectory != null) {
@@ -268,7 +291,9 @@ public class ProcessExecutor {
outputReaderThread.join();
boolean isQpdf =
command != null && !command.isEmpty() && command.get(0).contains("qpdf");
commandToRun != null
&& !commandToRun.isEmpty()
&& commandToRun.get(0).contains("qpdf");
if (!outputLines.isEmpty()) {
String outputMessage = String.join("\n", outputLines);
@@ -309,11 +334,195 @@ public class ProcessExecutor {
}
}
} finally {
semaphore.release();
if (useSemaphore) {
semaphore.release();
}
if (unoLease != null) {
unoLease.close();
}
}
return new ProcessExecutorResult(exitCode, messages);
}
private boolean shouldUseUnoServerPool(List<String> command) {
if (processType != Processes.LIBRE_OFFICE || unoServerPool == null) {
return false;
}
if (unoServerPool.isEmpty()) {
return false;
}
if (command == null || command.isEmpty()) {
return false;
}
// Check if this is a UNO conversion by looking for unoconvert executable
String executable = command.get(0);
if (executable != null) {
// Extract basename from path for matching
String basename = executable;
int lastSlash = Math.max(executable.lastIndexOf('/'), executable.lastIndexOf('\\'));
if (lastSlash >= 0) {
basename = executable.substring(lastSlash + 1);
}
// Strip .exe extension on Windows
if (basename.toLowerCase(java.util.Locale.ROOT).endsWith(".exe")) {
basename = basename.substring(0, basename.length() - 4);
}
// Match common unoconvert variants (but NOT soffice)
String lowerBasename = basename.toLowerCase(java.util.Locale.ROOT);
if (lowerBasename.contains("unoconvert") || lowerBasename.equals("unoconv")) {
return true;
}
}
return false;
}
private List<String> applyUnoServerEndpoint(
List<String> command,
ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint) {
if (endpoint == null || command == null || command.isEmpty()) {
return command;
}
List<String> updated = stripUnoEndpointArgs(command);
String host = endpoint.getHost();
int port = endpoint.getPort();
String hostLocation = endpoint.getHostLocation();
String protocol = endpoint.getProtocol();
// Normalize and validate host
if (host == null || host.isBlank()) {
host = "127.0.0.1";
}
// Normalize and validate port
if (port <= 0) {
port = 2003;
}
// Normalize and validate hostLocation (only auto|local|remote allowed)
if (hostLocation == null) {
hostLocation = "auto";
} else {
hostLocation = hostLocation.trim().toLowerCase(java.util.Locale.ROOT);
if (!Set.of("auto", "local", "remote").contains(hostLocation)) {
log.warn(
"Invalid hostLocation '{}' for endpoint {}:{}, defaulting to 'auto'",
hostLocation,
host,
port);
hostLocation = "auto";
}
}
// Normalize and validate protocol (only http|https allowed)
if (protocol == null) {
protocol = "http";
} else {
protocol = protocol.trim().toLowerCase(java.util.Locale.ROOT);
if (!Set.of("http", "https").contains(protocol)) {
log.warn(
"Invalid protocol '{}' for endpoint {}:{}, defaulting to 'http'",
protocol,
host,
port);
protocol = "http";
}
}
int insertIndex = Math.min(1, updated.size());
updated.add(insertIndex++, "--host");
updated.add(insertIndex++, host);
updated.add(insertIndex++, "--port");
updated.add(insertIndex++, String.valueOf(port));
// Only inject --host-location if non-default (for compatibility with older unoconvert)
if (!"auto".equals(hostLocation)) {
updated.add(insertIndex++, "--host-location");
updated.add(insertIndex++, hostLocation);
}
// Only inject --protocol if non-default (for compatibility with older unoconvert)
if (!"http".equals(protocol)) {
updated.add(insertIndex++, "--protocol");
updated.add(insertIndex, protocol);
}
return updated;
}
private List<String> stripUnoEndpointArgs(List<String> command) {
List<String> stripped = new ArrayList<>(command.size());
for (int i = 0; i < command.size(); i++) {
String arg = command.get(i);
if ("--host".equals(arg)
|| "--port".equals(arg)
|| "--host-location".equals(arg)
|| "--protocol".equals(arg)) {
i++;
continue;
}
if (arg != null
&& (arg.startsWith("--host=")
|| arg.startsWith("--port=")
|| arg.startsWith("--host-location=")
|| arg.startsWith("--protocol="))) {
continue;
}
stripped.add(arg);
}
return stripped;
}
private void validateCommand(List<String> command) {
if (command == null || command.isEmpty()) {
throw new IllegalArgumentException("Command must not be empty");
}
// Validate all arguments for null bytes and newlines (actual security concerns)
for (String arg : command) {
if (arg == null) {
throw new IllegalArgumentException("Command contains null argument");
}
if (arg.indexOf('\0') >= 0 || arg.indexOf('\n') >= 0 || arg.indexOf('\r') >= 0) {
throw new IllegalArgumentException("Command contains invalid characters");
}
}
// Validate executable (first argument)
String executable = command.get(0);
if (executable == null || executable.isBlank()) {
throw new IllegalArgumentException("Command executable must not be empty");
}
// Check for path traversal in executable
if (executable.contains("..")) {
throw new IllegalArgumentException(
"Command executable contains path traversal: " + executable);
}
// For absolute paths, verify the file exists and is executable
if (executable.contains("/") || executable.contains("\\")) {
Path execPath;
try {
execPath = Path.of(executable);
} catch (Exception e) {
throw new IllegalArgumentException("Invalid executable path: " + executable, e);
}
if (!Files.exists(execPath)) {
throw new IllegalArgumentException(
"Command executable does not exist: " + executable);
}
if (!Files.isRegularFile(execPath)) {
throw new IllegalArgumentException(
"Command executable is not a regular file: " + executable);
}
}
// For relative paths, trust that PATH resolution will work or fail appropriately
}
public enum Processes {
LIBRE_OFFICE,
PDFTOHTML,
@@ -0,0 +1,85 @@
package stirling.software.common.util;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import stirling.software.common.model.ApplicationProperties;
public class UnoServerPool {
private final List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints;
private final BlockingQueue<Integer> availableIndices;
public UnoServerPool(List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints) {
if (endpoints == null || endpoints.isEmpty()) {
this.endpoints = Collections.emptyList();
this.availableIndices = new LinkedBlockingQueue<>();
} else {
this.endpoints = new ArrayList<>(endpoints);
this.availableIndices = new LinkedBlockingQueue<>();
// Initialize queue with all endpoint indices
for (int i = 0; i < this.endpoints.size(); i++) {
this.availableIndices.offer(i);
}
}
}
public boolean isEmpty() {
return endpoints.isEmpty();
}
public UnoServerLease acquireEndpoint() throws InterruptedException {
if (endpoints.isEmpty()) {
return new UnoServerLease(defaultEndpoint(), null, this);
}
// Block until an endpoint index becomes available
Integer index = availableIndices.take();
return new UnoServerLease(endpoints.get(index), index, this);
}
private void releaseEndpoint(Integer index) {
if (index != null) {
availableIndices.offer(index);
}
}
private static ApplicationProperties.ProcessExecutor.UnoServerEndpoint defaultEndpoint() {
return new ApplicationProperties.ProcessExecutor.UnoServerEndpoint();
}
public static class UnoServerLease implements AutoCloseable {
private final ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint;
private final Integer index;
private final UnoServerPool pool;
private final AtomicBoolean closed = new AtomicBoolean(false);
public UnoServerLease(
ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint,
Integer index,
UnoServerPool pool) {
this.endpoint = endpoint;
this.index = index;
this.pool = pool;
}
public ApplicationProperties.ProcessExecutor.UnoServerEndpoint getEndpoint() {
return endpoint;
}
@Override
public void close() {
// Idempotent close: only release once even if close() called multiple times
if (!closed.compareAndSet(false, true)) {
return;
}
if (pool != null && index != null) {
pool.releaseEndpoint(index);
}
}
}
}