Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,24 @@ public void resolveProjectDependencies(

Map<Artifact, File> reactorProjects =
new HashMap<>(session.getProjects().size());
// Build a GAV-keyed map for fallback lookups when exact Artifact.equals() misses
// (e.g. type/classifier mismatch between resolved dependency and reactor artifact).
Map<String, MavenProject> reactorGavMap =
new HashMap<>(session.getProjects().size());
for (MavenProject reactorProject : session.getProjects()) {
reactorProjects.put(
reactorProject.getArtifact(),
reactorProject.getArtifact().getFile());
File file = reactorProject.getArtifact().getFile();
// In the concurrent builder, a reactor project may have compiled (output directory
// exists) but not yet been packaged (artifact file is null). Use the output
// directory as a fallback so that downstream projects see the compiled classes.
if (file == null) {
File outputDir = new File(reactorProject.getBuild().getOutputDirectory());
if (outputDir.isDirectory()) {
file = outputDir;
}
}
reactorProjects.put(reactorProject.getArtifact(), file);
String gavKey = ArtifactUtils.key(reactorProject.getArtifact());
reactorGavMap.put(gavKey, reactorProject);
}

Map<String, Artifact> map = new HashMap<>();
Expand All @@ -146,6 +160,38 @@ public void resolveProjectDependencies(
File reactorProjectFile = reactorProjects.get(artifact);
if (reactorProjectFile != null) {
artifact.setFile(reactorProjectFile);
} else if (artifact.getFile() == null) {
// Fallback: try matching reactor projects by GAV (handles type/classifier
// mismatches) or look up the artifact in the local repository.
String gavKey = ArtifactUtils.key(artifact);
MavenProject reactorProject = reactorGavMap.get(gavKey);
if (reactorProject != null) {
File fallback = reactorProject.getArtifact().getFile();
if (fallback == null) {
File outputDir = new File(reactorProject.getBuild().getOutputDirectory());
if (outputDir.isDirectory()) {
fallback = outputDir;
}
}
if (fallback != null) {
artifact.setFile(fallback);
}
} else {
// Non-reactor artifact with null file — the Aether resolver may not have
// resolved it (e.g. it was only collected, or a race condition in the
// concurrent builder prevented the file from being set). Try looking
// up the file in the local repository.
org.eclipse.aether.artifact.Artifact aetherArtifact = RepositoryUtils.toArtifact(artifact);
org.eclipse.aether.repository.LocalRepositoryManager lrm =
session.getRepositorySession().getLocalRepositoryManager();
if (lrm != null) {
String path = lrm.getPathForLocalArtifact(aetherArtifact);
File localFile = new File(lrm.getRepository().getBasedir(), path);
if (localFile.isFile()) {
artifact.setFile(localFile);
}
}
}
}

map.put(artifact.getDependencyConflictId(), artifact);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,11 @@ public void ensureDependenciesAreResolved(
}

ArtifactFilter artifactFilter = getArtifactFilter(mojoDescriptor);
// Use the project captured in the dependency context — session.getCurrentProject()
// is shared mutable state that can be overwritten by concurrent threads in the
// concurrent builder, causing the filter to be applied to the wrong project.
List<MavenProject> projectsToResolve = LifecycleDependencyResolver.getProjects(
session.getCurrentProject(), session, mojoDescriptor.isAggregator());
dependencyContext.getProject(), session, mojoDescriptor.isAggregator());
for (MavenProject projectToResolve : projectsToResolve) {
projectToResolve.setArtifactFilter(artifactFilter);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@

import org.apache.maven.api.Lifecycle;
import org.apache.maven.api.MonotonicClock;
import org.apache.maven.api.feature.Features;
import org.apache.maven.api.plugin.descriptor.AfterLink;
import org.apache.maven.api.services.LifecycleRegistry;
import org.apache.maven.api.services.MavenException;
Expand Down Expand Up @@ -95,6 +96,8 @@
import static org.apache.maven.api.Lifecycle.BEFORE;
import static org.apache.maven.api.Lifecycle.Phase.PACKAGE;
import static org.apache.maven.api.Lifecycle.Phase.READY;
import static org.apache.maven.api.Lifecycle.Phase.RESOURCES;
import static org.apache.maven.api.Lifecycle.Phase.SOURCES;
import static org.apache.maven.lifecycle.internal.concurrent.BuildStep.CREATED;
import static org.apache.maven.lifecycle.internal.concurrent.BuildStep.EXECUTED;
import static org.apache.maven.lifecycle.internal.concurrent.BuildStep.FAILED;
Expand Down Expand Up @@ -375,14 +378,19 @@ private void processStep(BuildStep step) {
// Check if all predecessors are executed successfully
boolean allPredecessorsExecuted = step.predecessors.stream().allMatch(s -> s.status.get() == EXECUTED);

// Special case for after:* steps - they should run if their corresponding before:* step ran
// Special case for after:* steps - they should run if their corresponding phase step ran
if (isAfterStep) {
String phaseName = step.name.substring(AFTER.length());
// Always process after:* steps for cleanup if their before:* step ran
shouldExecute = plan.step(step.project, BEFORE + phaseName)
// Run after:* steps for cleanup if their phase step itself ran or failed.
// Check the phase step (not before:*) because before:* is an empty lifecycle
// setup step that completes early — it doesn't indicate the phase's mojos ran.
// If the phase was SKIPPED (e.g. due to a dependency cascade), the after:*
// step should also be skipped to prevent downstream steps from executing
// out of order.
shouldExecute = plan.step(step.project, phaseName)
.map(s -> {
int stepStatus = s.status.get();
return stepStatus == EXECUTED;
return stepStatus == EXECUTED || stepStatus == FAILED;
})
.orElse(false);

Expand Down Expand Up @@ -562,14 +570,30 @@ private void executeStep(BuildStep step) throws IOException, LifecycleExecutionE
List<MojoExecution> executions = step.executions().toList();
if (!executions.isEmpty()) {
attachToThread(step);
// Set a thread-local project reference so the concurrent
// MojoExecutor can build its DependencyContext with the
// correct project. session.getCurrentProject() is racy —
// another thread can overwrite it between attachToThread
// and mojoExecutor.execute.
org.apache.maven.lifecycle.internal.concurrent.MojoExecutor.setThreadProject(step.project);
clock.start();
try {
executions.forEach(mojoExecution -> {
mojoExecutionConfigurator(mojoExecution).configure(step.project, mojoExecution, true);
finalizeMojoConfiguration(mojoExecution);
});
mojoExecutor.execute(session, executions);
// Record lifecycle phase on the correct project directly.
// The base MojoExecutor's PhaseRecorder uses session.getCurrentProject()
// which is overwritten by concurrent threads in parallel builds,
// causing phases to be recorded on the wrong project. This ensures
// ReactorReader.determineBuildOutputDirectoryForArtifact() can find
// compiled output via project.hasLifecyclePhase().
if (!step.name.startsWith(BEFORE) && !step.name.startsWith(AFTER)) {
step.project.addLifecyclePhase(step.name);
}
} finally {
org.apache.maven.lifecycle.internal.concurrent.MojoExecutor.clearThreadProject();
clock.stop();
}
}
Expand Down Expand Up @@ -708,8 +732,16 @@ private void applyAfterLinks(MojoDescriptor mojoDescriptor, MavenProject project
* matching purposes. Note that this does <em>not</em> perform path-scope
* resolution — for example, filtering by "compile" will not include
* "provided"-scoped dependencies even though they contribute to
* {@code PathScope.MAIN_COMPILE}. This keeps the filter simple and predictable;
* broader scope-aware filtering can be added in a follow-up if needed.
* {@code PathScope.MAIN_COMPILE}.
* <p>
* The scope parameter is expanded to match the Maven dependency resolution semantics:
* <ul>
* <li>"compile" matches compile, provided, and system scoped dependencies</li>
* <li>"runtime" matches compile and runtime scoped dependencies</li>
* <li>"test" matches all scopes</li>
* </ul>
* This ensures that provided-scope reactor dependencies are properly ordered in the
* build plan when needed for compilation.
*
* @param project the project whose dependencies to check
* @param upstreamProjects the list of upstream reactor projects
Expand All @@ -721,14 +753,32 @@ static List<MavenProject> filterByScope(
if (scope == null || scope.isEmpty()) {
return upstreamProjects;
}
Set<String> matchingScopes = expandScope(scope);
return upstreamProjects.stream()
.filter(dep -> project.getDependencies().stream()
.anyMatch(d -> dep.getGroupId().equals(d.getGroupId())
&& dep.getArtifactId().equals(d.getArtifactId())
&& scope.equals(d.getScope() != null ? d.getScope() : "compile")))
&& matchingScopes.contains(d.getScope() != null ? d.getScope() : "compile")))
.collect(Collectors.toList());
}

/**
* Expands a dependency scope identifier to the set of artifact scopes that are included
* in dependency resolution for that scope. This mirrors the semantics of
* {@link org.apache.maven.lifecycle.internal.MojoExecutor#toScopes(String)}.
*/
private static Set<String> expandScope(String scope) {
return switch (scope) {
case "compile" -> Set.of("compile", "provided", "system");
case "runtime" -> Set.of("compile", "runtime");
case "compile+runtime" -> Set.of("compile", "provided", "system", "runtime");
case "runtime+system" -> Set.of("compile", "system", "runtime");
case "test" -> Set.of("compile", "provided", "system", "runtime", "test");
case "test-only" -> Set.of("test");
default -> Set.of(scope);
};
}

protected BuildPlan computeForkPlan(BuildStep step, MojoExecution execution, BuildPlan buildPlan) {
MojoDescriptor mojoDescriptor = execution.getMojoDescriptor();
PluginDescriptor pluginDescriptor = mojoDescriptor.getPluginDescriptor();
Expand Down Expand Up @@ -1020,6 +1070,22 @@ public BuildPlan calculateLifecycleMappings(
});
});

// Maven 3 personality: enforce sequential SOURCES → RESOURCES ordering.
// In Maven 3, generate-resources always ran after process-sources, so plugins
// bound to resource phases could rely on source generation being complete.
// The V4 lifecycle allows them to run in parallel; V4-native plugins should
// use @After(phase="sources") to declare the dependency explicitly.
@SuppressWarnings("unchecked")
Map<String, Object> userProps =
session != null ? (Map<String, Object>) (Map<?, ?>) session.getUserProperties() : null;
if (Features.mavenMaven3Personality(userProps)) {
BuildStep afterSources = steps.get(AFTER + SOURCES);
BuildStep beforeResources = steps.get(BEFORE + RESOURCES);
if (afterSources != null && beforeResources != null) {
beforeResources.executeAfter(afterSources);
}
}

// Only keep mojo executions before the end phase
String endPhase = lifecyclePhase.startsWith(BEFORE) || lifecyclePhase.startsWith(AFTER)
? lifecyclePhase
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,35 @@
import javax.inject.Provider;
import javax.inject.Singleton;

import java.util.List;

import org.apache.maven.api.services.MessageBuilderFactory;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.lifecycle.internal.DependencyContext;
import org.apache.maven.lifecycle.internal.ExecutionEventCatapult;
import org.apache.maven.lifecycle.internal.LifecycleDependencyResolver;
import org.apache.maven.plugin.BuildPluginManager;
import org.apache.maven.plugin.MavenPluginManager;
import org.apache.maven.plugin.MojoExecution;
import org.apache.maven.plugin.MojosExecutionStrategy;
import org.apache.maven.project.MavenProject;

/**
* Concurrent-builder variant of the mojo executor.
* <p>
* In the concurrent builder, multiple build steps for different projects
* execute in parallel. The shared {@code MavenSession.currentProject} field
* is therefore unreliable — another thread can overwrite it between the
* moment a step attaches to its thread and the moment the mojo executor
* reads it. This subclass uses a {@link ThreadLocal} to provide each
* executor thread with its own, stable reference to the project being built.
*/
@Named("concurrent")
@Singleton
public class MojoExecutor extends org.apache.maven.lifecycle.internal.MojoExecutor {

private static final ThreadLocal<MavenProject> THREAD_PROJECT = new ThreadLocal<>();

@Inject
public MojoExecutor(
BuildPluginManager pluginManager,
Expand All @@ -56,4 +73,40 @@ public MojoExecutor(
protected boolean useProjectLock(MavenSession session) {
return false;
}

/**
* Set the project for the current executor thread. Called by
* {@link BuildPlanExecutor} before invoking
* {@link #execute(MavenSession, List)} so that
* {@link #newDependencyContext} picks up the correct project
* regardless of what {@code session.getCurrentProject()} returns.
*/
static void setThreadProject(MavenProject project) {
THREAD_PROJECT.set(project);
}

/** Clear the thread-local project reference after execution. */
static void clearThreadProject() {
THREAD_PROJECT.remove();
}

/**
* Override to use the thread-local project instead of
* {@code session.getCurrentProject()}, which is racy in the
* concurrent builder.
*/
@Override
public DependencyContext newDependencyContext(MavenSession session, List<MojoExecution> mojoExecutions) {
DependencyContext ctx = super.newDependencyContext(session, mojoExecutions);
MavenProject threadProject = THREAD_PROJECT.get();
if (threadProject != null && threadProject != ctx.getProject()) {
// The super method captured the wrong project from the racy
// session.currentProject — rebuild with the correct one.
return new DependencyContext(
threadProject,
ctx.getScopesToCollectForCurrentProject(),
ctx.getScopesToResolveForCurrentProject());
}
return ctx;
}
}
Loading
Loading