From f52243cdb95be1b502156c67900b0bcec037e02d Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Fri, 5 Sep 2025 13:04:31 +0200 Subject: [PATCH 1/2] Add --processes to list running Maven builds. Implements a per-user registry under ~/.m2/.maven/runs (create on start, remove on exit). Pure Java 17 using ProcessHandle; no jps or vendor tools. --- .../org/apache/maven/api/cli/Options.java | 10 ++ .../cling/invoker/CommonsCliOptions.java | 19 ++ .../maven/cling/invoker/LayeredOptions.java | 5 + .../maven/cling/invoker/ProcessRuns.java | 164 ++++++++++++++++++ .../maven/cling/invoker/mvn/MavenInvoker.java | 24 +++ .../cling/invoker/mvn/MavenInvokerTest.java | 45 +++++ 6 files changed, 267 insertions(+) create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/ProcessRuns.java diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java index d2bf596cd916..15787a2be657 100644 --- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java +++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java @@ -220,4 +220,14 @@ default void warnAboutDeprecatedOptions(@Nonnull ParserRequest request, @Nonnull * @param printWriter the string consumer to use for output */ void displayHelp(@Nonnull ParserRequest request, @Nonnull Consumer printWriter); + + /** + * Indicates whether to list running Maven processes and exit. + * Implementations that don't support this option can leave it empty. + * @since 4.0.0 + */ + @Nonnull + default Optional processes() { + return Optional.empty(); + } } diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java index f78bcb0f5216..f3b571a83787 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java @@ -228,6 +228,13 @@ public Optional help() { return Optional.empty(); } + public Optional processes() { + if (commandLine.hasOption(CLIManager.PROCESSES) || commandLine.hasOption(CLIManager.PROCESSES_SHORT)) { + return Optional.of(Boolean.TRUE); + } + return Optional.empty(); + } + @Override public void warnAboutDeprecatedOptions(ParserRequest request, Consumer printWriter) { if (cliManager.getUsedDeprecatedOptions().isEmpty()) { @@ -318,6 +325,10 @@ protected static class CLIManager { public static final String OFFLINE = "o"; public static final String HELP = "h"; + // List running Maven processes (MNG-8608). No short form: -ps already used. + public static final String PROCESSES = "processes"; + public static final String PROCESSES_SHORT = "p"; + // Not an Option: used only for early detection, when CLI args may not be even parsed public static final String SHOW_ERRORS_CLI_ARG = "-" + SHOW_ERRORS; @@ -349,6 +360,14 @@ protected void prepareOptions(org.apache.commons.cli.Options options) { .longOpt("help") .desc("Display help information") .build()); + options.addOption(Option.builder() + .longOpt(PROCESSES) + .desc("List running Maven processes for the current user") + .build()); + options.addOption(Option.builder(PROCESSES_SHORT) + .longOpt(PROCESSES) + .desc("List running Maven processes and exit") + .build()); options.addOption(Option.builder(USER_PROPERTY) .numberOfArgs(2) .valueSeparator('=') diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java index 2f9c367c4786..47ee115d4ef9 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java @@ -150,6 +150,11 @@ public void displayHelp(ParserRequest request, Consumer printWriter) { options.get(0).displayHelp(request, printWriter); } + @Override + public Optional processes() { + return returnFirstPresentOrEmpty(Options::processes); + } + protected Optional returnFirstPresentOrEmpty(Function> getter) { for (O option : options) { Optional o = getter.apply(option); diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/ProcessRuns.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/ProcessRuns.java new file mode 100644 index 000000000000..3b7b2c12fb87 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/ProcessRuns.java @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker; + +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.Comparator; +import java.util.Properties; + +public final class ProcessRuns { + + private static final String RUNS_SUBDIR = ".m2/.maven/runs"; + private static final String FILE_PREFIX = "mvn-"; + private static final String FILE_SUFFIX = ".properties"; + + private static final String KEY_PID = "pid"; + private static final String KEY_VERSION = "version"; + private static final String KEY_WORKDIR = "workDir"; + private static final String KEY_EXECROOT = "execRoot"; + private static final String KEY_STARTED = "started"; + + private static Path runsDir() { + final String home = System.getProperty("user.home", "."); + return Paths.get(home).resolve(RUNS_SUBDIR); + } + + private static Path desc(final long pid) { + return runsDir().resolve(FILE_PREFIX + pid + FILE_SUFFIX); + } + + public static void install(final long pid, final String version, final Path workDir, final Path execRoot) { + try { + Files.createDirectories(runsDir()); + final Properties p = new Properties(); + p.setProperty(KEY_PID, Long.toString(pid)); + p.setProperty(KEY_VERSION, version == null ? "-" : version); + p.setProperty( + KEY_WORKDIR, + workDir == null ? "?" : workDir.toAbsolutePath().toString()); + p.setProperty( + KEY_EXECROOT, + execRoot == null ? "?" : execRoot.toAbsolutePath().toString()); + p.setProperty(KEY_STARTED, Instant.now().toString()); + try (Writer w = Files.newBufferedWriter(desc(pid), StandardCharsets.UTF_8)) { + p.store(w, "mvn run"); + } + } catch (final Exception ignored) { + // best-effort + } + } + + public static void uninstall(final long pid) { + try { + Files.deleteIfExists(desc(pid)); + } catch (final Exception ignored) { + // best-effort + } + } + + private static final class Run { + final long pid; + final String version; + final String workDir; + final String execRoot; + final String started; + + Run(final long pid, final String version, final String workDir, final String execRoot, final String started) { + this.pid = pid; + this.version = version; + this.workDir = workDir; + this.execRoot = execRoot; + this.started = started; + } + } + + public static java.util.List listAlive() { + final Path dir = runsDir(); + if (!Files.isDirectory(dir)) { + return java.util.List.of(); + } + final java.util.List out = new java.util.ArrayList<>(); + try (DirectoryStream ds = Files.newDirectoryStream(dir, FILE_PREFIX + "*" + FILE_SUFFIX)) { + for (final Path f : ds) { + final Properties p = new Properties(); + try (Reader r = Files.newBufferedReader(f, StandardCharsets.UTF_8)) { + p.load(r); + } + final long pid = parseLong(p.getProperty(KEY_PID), -1L); + final boolean alive = pid > 0 + && ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false); + if (alive) { + out.add(new Run( + pid, + p.getProperty(KEY_VERSION, "-"), + p.getProperty(KEY_WORKDIR, "?"), + p.getProperty(KEY_EXECROOT, "?"), + p.getProperty(KEY_STARTED, "?"))); + } else { + try { + Files.deleteIfExists(f); + } catch (final Exception ignored) { + } + } + } + } catch (final Exception ignored) { + // return what we gathered + } + out.sort(Comparator.comparing((Run r) -> r.started).thenComparingLong(r -> r.pid)); + return out; + } + + public static String format(final java.util.List runs) { + final String nl = System.lineSeparator(); + if (runs.isEmpty()) { + return "No running Maven processes." + nl; + } + final StringBuilder sb = new StringBuilder(); + sb.append(String.format( + "%-10s %-12s %-24s %-48s %-48s%s", "PID", "VERSION", "STARTED", "WORKDIR", "EXEC_ROOT", nl)); + for (final Run r : runs) { + sb.append(String.format( + "%-10d %-12s %-24s %-48s %-48s%s", + r.pid, r.version, r.started, ell(r.workDir, 48), ell(r.execRoot, 48), nl)); + } + return sb.toString(); + } + + private static long parseLong(final String s, final long dflt) { + try { + return Long.parseLong(s); + } catch (final Exception e) { + return dflt; + } + } + + private static String ell(final String s, final int max) { + if (s == null || s.length() <= max) { + return s == null ? "?" : s; + } + return "…" + s.substring(Math.max(0, s.length() - (max - 1))); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java index 426e30d8ec1e..23a69bbe9b8f 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java @@ -36,6 +36,7 @@ import org.apache.maven.api.Constants; import org.apache.maven.api.MonotonicClock; import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.cli.InvokerException; import org.apache.maven.api.cli.InvokerRequest; import org.apache.maven.api.cli.Logger; import org.apache.maven.api.cli.mvn.MavenOptions; @@ -51,6 +52,7 @@ import org.apache.maven.cling.invoker.CliUtils; import org.apache.maven.cling.invoker.LookupContext; import org.apache.maven.cling.invoker.LookupInvoker; +import org.apache.maven.cling.invoker.ProcessRuns; import org.apache.maven.cling.transfer.ConsoleMavenTransferListener; import org.apache.maven.cling.transfer.QuietMavenTransferListener; import org.apache.maven.cling.transfer.SimplexTransferListener; @@ -613,4 +615,26 @@ protected void logSummary( logSummary(context, child, references, indent); } } + + @Override + protected void preCommands(final MavenContext context) throws Exception { + super.preCommands(context); + + if (context.options().processes().orElse(false)) { + final String out = ProcessRuns.format(ProcessRuns.listAlive()); + System.out.print(out); + throw new InvokerException.ExitException(0); + } + + try { + final long pid = ProcessHandle.current().pid(); + final String version = String.valueOf(getClass().getPackage().getImplementationVersion()); + final Path workDir = context.cwd.get(); + final Path execRoot = context.invokerRequest.rootDirectory().orElse(context.invokerRequest.topDirectory()); + ProcessRuns.install(pid, version, workDir, execRoot); + context.closeables.add(() -> ProcessRuns.uninstall(pid)); + } catch (final Throwable ignored) { + // best-effort + } + } } diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/MavenInvokerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/MavenInvokerTest.java index eae08feb2d05..f31e27902e73 100644 --- a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/MavenInvokerTest.java +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/MavenInvokerTest.java @@ -29,8 +29,10 @@ import org.apache.maven.api.cli.Invoker; import org.apache.maven.api.cli.InvokerException; import org.apache.maven.api.cli.Parser; +import org.apache.maven.cling.invoker.ProcessRuns; import org.apache.maven.cling.invoker.ProtoLookup; import org.codehaus.plexus.classworlds.ClassWorld; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; @@ -47,6 +49,19 @@ */ @Order(200) public class MavenInvokerTest extends MavenInvokerTestSupport { + + @TempDir + Path tmpHome; + + private String oldHome; + + @AfterEach + void restoreHome() { + if (oldHome != null) { + System.setProperty("user.home", oldHome); + } + } + @Override protected Invoker createInvoker(ClassWorld classWorld) { return new MavenInvoker( @@ -233,4 +248,34 @@ void jimFs() throws Exception { invoke(fs.getPath("/cwd"), fs.getPath("/home"), List.of("verify"), List.of()); } } + + @Test + void listsCurrentPidFromRegistry() { + // point user.home to isolated temp dir + oldHome = System.getProperty("user.home"); + System.setProperty("user.home", tmpHome.toString()); + + final long pid = ProcessHandle.current().pid(); + final Path workDir = tmpHome.resolve("work"); + final Path execRoot = tmpHome.resolve("root"); + + try { + // write descriptor for THIS alive pid + ProcessRuns.install(pid, "TEST", workDir, execRoot); + + // list + format + final var runs = ProcessRuns.listAlive(); + final String table = ProcessRuns.format(runs); + + // assertions + assertFalse( + table.startsWith("No running Maven processes."), + "Expected at least one running Maven process to be listed"); + assertTrue(table.contains(Long.toString(pid)), "Expected table to contain current PID"); + assertTrue(table.contains("TEST"), "Expected table to contain version label"); + } finally { + // cleanup best-effort + ProcessRuns.uninstall(pid); + } + } } From 594ff235b1aba65de61d8bb81ae5eddaa60d539d Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Thu, 25 Sep 2025 16:14:33 +0200 Subject: [PATCH 2/2] add mnvsh command --- .../maven/cling/invoker/ProcessRuns.java | 4 ++-- .../BuiltinShellCommandRegistryFactory.java | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/ProcessRuns.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/ProcessRuns.java index 3b7b2c12fb87..501195ced6ef 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/ProcessRuns.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/ProcessRuns.java @@ -142,7 +142,7 @@ public static String format(final java.util.List runs) { for (final Run r : runs) { sb.append(String.format( "%-10d %-12s %-24s %-48s %-48s%s", - r.pid, r.version, r.started, ell(r.workDir, 48), ell(r.execRoot, 48), nl)); + r.pid, r.version, r.started, truncateEnd(r.workDir, 48), truncateEnd(r.execRoot, 48), nl)); } return sb.toString(); } @@ -155,7 +155,7 @@ private static long parseLong(final String s, final long dflt) { } } - private static String ell(final String s, final int max) { + private static String truncateEnd(final String s, final int max) { if (s == null || s.length() <= max) { return s == null ? "?" : s; } diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java index a221e1b5b152..d07ce237442a 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java @@ -91,6 +91,9 @@ private BuiltinShellCommandRegistry(LookupContext shellContext) { commandExecute.put("mvn", new CommandMethods(this::mvn, this::mvnCompleter)); commandExecute.put("mvnenc", new CommandMethods(this::mvnenc, this::mvnencCompleter)); commandExecute.put("mvnup", new CommandMethods(this::mvnup, this::mvnupCompleter)); + // list running Maven builds (same as `mvn --processes`) + commandExecute.put("ps", new CommandMethods(this::ps, this::defaultCompleter)); + commandExecute.put("processes", new CommandMethods(this::ps, this::defaultCompleter)); registerCommands(commandExecute); } @@ -270,6 +273,22 @@ private List mvnupCompleter(String name) { .lookupMap(org.apache.maven.cling.invoker.mvnup.Goal.class) .keySet()))); } + + /** + * mvnsh command: print running Maven processes (delegates to `mvn --processes`). + */ + private void ps(CommandInput input) { + try { + shellMavenInvoker.invoke(mavenParser.parseInvocation(ParserRequest.mvn( + new String[] {"--processes"}, shellContext.invokerRequest.messageBuilderFactory()) + .cwd(shellContext.cwd.get()) + .build())); + } catch (InvokerException.ExitException e) { + shellContext.logger.error("ps command exited with exit code " + e.getExitCode()); + } catch (Exception e) { + saveException(e); + } + } } private static class StreamGobbler implements Runnable {