@@ -80,6 +80,87 @@ private static int forkHeapPercentage(int numClients) {
8080 return Math .max (1 , FORK_HEAP_BUDGET_PERCENT / numClients );
8181 }
8282
83+ /**
84+ * Explicit heap settings suppress auto-division, so nothing else checks that
85+ * numClients forks at the user's size can actually coexist -- the mistake only
86+ * surfaces later as fork OOMs or the host OOM-killer. Returns a warning when
87+ * the combined explicit ceiling exceeds the fork budget, else null.
88+ * Package-private for tests.
89+ */
90+ static String heapOvercommitWarning (List <String > forkedJvmArgs , int numClients ,
91+ long totalMemBytes ) {
92+ long xmxBytes = -1 ;
93+ double maxRamPct = -1 ;
94+ // last occurrence wins, same as the JVM
95+ for (String arg : forkedJvmArgs ) {
96+ if (arg .startsWith ("-Xmx" )) {
97+ xmxBytes = parseJvmMemArg (arg .substring ("-Xmx" .length ()));
98+ } else if (arg .startsWith ("-XX:MaxRAMPercentage=" )) {
99+ try {
100+ maxRamPct = Double .parseDouble (
101+ arg .substring ("-XX:MaxRAMPercentage=" .length ()));
102+ } catch (NumberFormatException e ) {
103+ maxRamPct = -1 ;
104+ }
105+ }
106+ }
107+ // -Xmx beats MaxRAMPercentage in the JVM, so only judge the percentage alone
108+ if (xmxBytes <= 0 && maxRamPct > 0 && numClients * maxRamPct > FORK_HEAP_BUDGET_PERCENT ) {
109+ return String .format (java .util .Locale .ROOT ,
110+ "numClients=%d x -XX:MaxRAMPercentage=%s commits %.0f%% of memory; " +
111+ "the parent JVM and OS need the remainder (fork budget: %d%%). " +
112+ "Lower numClients or the percentage." ,
113+ numClients , maxRamPct , numClients * maxRamPct , FORK_HEAP_BUDGET_PERCENT );
114+ }
115+ if (xmxBytes > 0 && totalMemBytes > 0
116+ && numClients * (double ) xmxBytes > totalMemBytes * FORK_HEAP_BUDGET_PERCENT / 100.0 ) {
117+ return String .format (java .util .Locale .ROOT ,
118+ "numClients=%d x -Xmx (%,dMB each) commits %,dMB of %,dMB total memory, " +
119+ "over the %d%% fork budget; the parent JVM and OS need the remainder. " +
120+ "Lower numClients or -Xmx." ,
121+ numClients , xmxBytes / MB , numClients * xmxBytes / MB , totalMemBytes / MB ,
122+ FORK_HEAP_BUDGET_PERCENT );
123+ }
124+ return null ;
125+ }
126+
127+ private static final long MB = 1024 * 1024 ;
128+
129+ // "-Xmx" style value: digits with optional k/m/g/t suffix; -1 if unparseable
130+ static long parseJvmMemArg (String value ) {
131+ if (value == null || value .isEmpty ()) {
132+ return -1 ;
133+ }
134+ long multiplier = switch (Character .toLowerCase (value .charAt (value .length () - 1 ))) {
135+ case 'k' -> 1024L ;
136+ case 'm' -> 1024L * 1024 ;
137+ case 'g' -> 1024L * 1024 * 1024 ;
138+ case 't' -> 1024L * 1024 * 1024 * 1024 ;
139+ default -> 1 ;
140+ };
141+ String digits = multiplier == 1 ? value : value .substring (0 , value .length () - 1 );
142+ try {
143+ long n = Long .parseLong (digits );
144+ return n <= 0 ? -1 : Math .multiplyExact (n , multiplier );
145+ } catch (NumberFormatException | ArithmeticException e ) {
146+ return -1 ;
147+ }
148+ }
149+
150+ // Container-aware on JDK 17 (honors cgroup memory limits); -1 if unavailable.
151+ // Read via JMX attribute name: the typed accessor lives on a com.sun
152+ // interface that forbiddenapis bans as non-portable.
153+ private static long totalMemorySize () {
154+ try {
155+ Object value = java .lang .management .ManagementFactory .getPlatformMBeanServer ()
156+ .getAttribute (new javax .management .ObjectName ("java.lang:type=OperatingSystem" ),
157+ "TotalMemorySize" );
158+ return value instanceof Number n ? n .longValue () : -1 ;
159+ } catch (Exception e ) {
160+ return -1 ;
161+ }
162+ }
163+
83164
84165 private final PipesConfig pipesConfig ;
85166 private final Path tikaConfigPath ;
@@ -144,8 +225,6 @@ private void logCpuSizing() {
144225 String capDecision ;
145226 if (userSetCap ) {
146227 capDecision = "user-set in forkedJvmArgs" ;
147- } else if (numClients <= 1 ) {
148- capDecision = "n/a (single fork; not capped)" ;
149228 } else {
150229 int budget = Math .max (1 , hostCores - PARENT_RESERVED_CORES );
151230 int slice = budget / numClients ;
@@ -156,14 +235,18 @@ private void logCpuSizing() {
156235 String heapDecision ;
157236 if (userSetHeap (pipesConfig .getForkedJvmArgs ())) {
158237 heapDecision = "user-set in forkedJvmArgs" ;
159- } else if (numClients <= 1 ) {
160- heapDecision = "n/a (single fork; JVM default)" ;
161238 } else {
162239 heapDecision = "MaxRAMPercentage=" + forkHeapPercentage (numClients );
163240 }
164241 LOG .info ("pipes-cpu-sizing: hostCores={}, numClients={}, parentReserved={}, " +
165242 "autoCap={}, heap={}" , hostCores , numClients , PARENT_RESERVED_CORES ,
166243 capDecision , heapDecision );
244+
245+ String overcommit = heapOvercommitWarning (pipesConfig .getForkedJvmArgs (),
246+ numClients , totalMemorySize ());
247+ if (overcommit != null ) {
248+ LOG .warn ("pipes-cpu-sizing: {}" , overcommit );
249+ }
167250 }
168251
169252 @ Override
@@ -313,7 +396,7 @@ private synchronized void startServer() throws IOException, InterruptedException
313396 LOG .trace ("clientId={}: starting server on port={}" , clientId , port );
314397
315398 tmpDir = Files .createTempDirectory ("pipes-server-" + clientId + "-" );
316- ProcessBuilder pb = new ProcessBuilder (getCommandline ());
399+ ProcessBuilder pb = new ProcessBuilder (getCommandline (tmpDir ));
317400 // Tell the child our PID so it can watch ProcessHandle.onExit() and
318401 // self-terminate promptly if we die. Without this, an orphan child
319402 // can only notice via socket-read timeout (default 60s) and can't
@@ -448,7 +531,9 @@ private void deleteDir(Path dir) {
448531 }
449532 }
450533
451- private String [] getCommandline () throws IOException {
534+ // package-private and tmpDir passed in (rather than read from the field set by
535+ // startServer) so tests can exercise arg injection without forking a JVM
536+ String [] getCommandline (Path tmpDir ) throws IOException {
452537 List <String > configArgs = new ArrayList <>(pipesConfig .getForkedJvmArgs ());
453538 boolean hasClassPath = false ;
454539 boolean hasHeadless = false ;
@@ -500,7 +585,7 @@ private String[] getCommandline() throws IOException {
500585 // host has -- the same "each JVM thinks it owns the machine" problem the
501586 // ActiveProcessorCount cap solves. Give each fork a slice of a fixed budget
502587 // instead, leaving the remainder for the parent and the OS.
503- if (!userSetHeap (configArgs ) && pipesConfig . getNumClients () > 1 ) {
588+ if (!userSetHeap (configArgs )) {
504589 int pct = forkHeapPercentage (pipesConfig .getNumClients ());
505590 configArgs .add ("-XX:MaxRAMPercentage=" + pct );
506591 LOG .debug ("clientId={}: auto-injected -XX:MaxRAMPercentage={} (numClients={})" ,
@@ -517,7 +602,7 @@ private String[] getCommandline() throws IOException {
517602 // Skip the auto-cap when the computed slice would drop below
518603 // MIN_AUTO_CAP_SLICE -- below that, the fork can't keep its socket
519604 // reader responsive and back-pressures the parent.
520- if (!hasActiveProcessorCount && pipesConfig . getNumClients () > 1 ) {
605+ if (!hasActiveProcessorCount ) {
521606 int hostCores = Runtime .getRuntime ().availableProcessors ();
522607 int forkBudget = Math .max (1 , hostCores - PARENT_RESERVED_CORES );
523608 int slice = forkBudget / pipesConfig .getNumClients ();
@@ -530,10 +615,11 @@ private String[] getCommandline() throws IOException {
530615 } else {
531616 LOG .info ("clientId={}: skipping -XX:ActiveProcessorCount auto-cap " +
532617 "(would yield slice={} < MIN_AUTO_CAP_SLICE={}; " +
533- "hostCores={}, parentReserved={}, numClients={}). " +
534- "Consider lowering numClients on this host." ,
618+ "hostCores={}, parentReserved={}, numClients={}).{}" ,
535619 clientId , slice , MIN_AUTO_CAP_SLICE , hostCores ,
536- PARENT_RESERVED_CORES , pipesConfig .getNumClients ());
620+ PARENT_RESERVED_CORES , pipesConfig .getNumClients (),
621+ pipesConfig .getNumClients () > 1
622+ ? " Consider lowering numClients on this host." : "" );
537623 }
538624 }
539625
@@ -547,7 +633,7 @@ private String[] getCommandline() throws IOException {
547633 commandLine .add (ProcessUtils .escapeCommandLine (javaPath ));
548634
549635 if (!hasClassPath ) {
550- Path argFile = writeArgFile ();
636+ Path argFile = writeArgFile (tmpDir );
551637 commandLine .add ("@" + argFile .toAbsolutePath ());
552638 }
553639
@@ -573,7 +659,7 @@ private String[] getCommandline() throws IOException {
573659 return commandLine .toArray (new String [0 ]);
574660 }
575661
576- private Path writeArgFile () throws IOException {
662+ private Path writeArgFile (Path tmpDir ) throws IOException {
577663 Path argFile = tmpDir .resolve ("jvm-args.txt" );
578664 // forward any tika.extras.dir jars to the forked PipesServer
579665 String classpath = TikaExtras .appendJarsToClasspath (System .getProperty ("java.class.path" ));
0 commit comments