blob: 90d2ae576a30f8efb1537ab0b5c9a91560aeebd7 [file] [log] [blame]
// Copyright (C) 2025 The Android Open Source Project
//
// Licensed 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 com.google.gerrit.testing;
import static org.junit.Assert.fail;
import com.github.rholder.retry.RetryException;
import com.github.rholder.retry.RetryerBuilder;
import com.github.rholder.retry.StopStrategies;
import com.google.common.base.MoreObjects;
import com.google.common.base.Predicates;
import com.google.common.collect.Sets;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Project;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.git.DelegateRepository;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.RepositoryExistsException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.NavigableSet;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.Repository;
import org.junit.runner.Description;
public class GitRepositoryReferenceCountingManager implements GitRepositoryManager {
private static final int TIMEOUT_WAITING_FOR_CLOSED_REPOSITORIES_SEC = 30;
private final GitRepositoryManager delegate;
private Set<RepositoryTracking> openRepositories;
private final AllUsersName allUsersName;
private final AllProjectsName allProjectsName;
private static class RepositoryTracking extends DelegateRepository {
private final AtomicInteger referenceCounter = new AtomicInteger(1);
private final List<StackTraceElement> openCallerStack;
private List<List<StackTraceElement>> incrementCallersStacks;
private List<List<StackTraceElement>> decrementCallersStacks;
private final String repoName;
private RepositoryTracking(String repoName, Repository repository) {
super(repository);
this.repoName = repoName;
openCallerStack = getCallers();
incrementCallersStacks = new ArrayList<>();
decrementCallersStacks = new ArrayList<>();
}
@Nullable
private static List<StackTraceElement> getCallers() {
StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
return Arrays.stream(stackTrace)
.filter(
stackTraceElement ->
!stackTraceElement
.getClassName()
.startsWith(GitRepositoryReferenceCountingManager.class.getName()))
.filter(stackTraceElement -> !stackTraceElement.getClassName().contains("java.lang"))
.filter(stackTraceElement -> !stackTraceElement.getClassName().contains("jdk.internal"))
.filter(stackTraceElement -> !stackTraceElement.getClassName().contains("org.junit"))
.limit(5)
.toList();
}
@Override
public String toString() {
return "JGit Repository object "
+ repoName
+ "\nwas opened "
+ (incrementCallersStacks.size() + 1)
+ " time(s) but closed only "
+ decrementCallersStacks.size()
+ " time(s), leaving a reference counting of "
+ referenceCounter.get()
+ " instance leaked\n"
+ "------------\n"
+ " opened from "
+ formatCallStack(openCallerStack)
+ (incrementCallersStacks.isEmpty()
? ""
: "\n incrementOpen from " + formatCallers(incrementCallersStacks))
+ (decrementCallersStacks.isEmpty()
? ""
: "\n closed from " + formatCallers(decrementCallersStacks))
+ "\n\n";
}
static String formatCallers(List<List<StackTraceElement>> callers) {
return String.join(
"\n ", callers.stream().map(RepositoryTracking::formatCallStack).toList());
}
static String formatCallStack(List<StackTraceElement> stackTraceElements) {
return String.join(
"\n called by ",
stackTraceElements.stream().map(StackTraceElement::toString).toList());
}
@Override
public void incrementOpen() {
super.incrementOpen();
incrementReferenceCounting();
}
@Override
public synchronized void close() {
super.close();
if (decrementCallersStacks == null) {
return;
}
decrementCallersStacks.add(getCallers());
int unused = referenceCounter.decrementAndGet();
}
synchronized void incrementReferenceCounting() {
if (incrementCallersStacks == null) {
return;
}
incrementCallersStacks.add(getCallers());
int unused = referenceCounter.incrementAndGet();
}
synchronized void clear() {
incrementCallersStacks.clear();
decrementCallersStacks.clear();
incrementCallersStacks = null;
decrementCallersStacks = null;
}
private synchronized Optional<String> reportIfOpen() {
return referenceCounter.get() > 0 ? Optional.of(toString()) : Optional.empty();
}
}
GitRepositoryReferenceCountingManager(
GitRepositoryManager delegate, AllUsersName allUsersName, AllProjectsName allProjectsName) {
this.delegate = delegate;
this.allUsersName = allUsersName;
this.allProjectsName = allProjectsName;
}
public void clear() {
if (openRepositories != null) {
openRepositories.forEach(RepositoryTracking::clear);
openRepositories.clear();
openRepositories = null;
}
}
public void init(Description testDescription) {
if (openRepositories != null) {
clear();
}
if (isDisabled(testDescription)) {
openRepositories = null;
return;
}
openRepositories = Sets.newConcurrentHashSet();
}
@Override
public Repository openRepository(Project.NameKey name)
throws RepositoryNotFoundException, IOException {
return trackRepository(name, delegate.openRepository(name));
}
@Override
public Repository createRepository(Project.NameKey name)
throws RepositoryNotFoundException, RepositoryExistsException, IOException {
return trackRepository(name, delegate.createRepository(name));
}
@Override
public NavigableSet<Project.NameKey> list() {
return delegate.list();
}
@Override
public Boolean canPerformGC() {
return delegate.canPerformGC();
}
public void assertThatAllRepositoriesAreClosed(String testName) {
List<String> repositoriesToReport = waitUntilAllRepositoriesAreClosed();
if (!repositoriesToReport.isEmpty()) {
fail(
"All repositories were expected to be closed at the end of the following test:\n"
+ testName
+ "\n\n"
+ "P.S. Hints to resolve the issue:\n"
+ " See below the tracking information of when the Repository was created,\n"
+ " referenced and closed throughout the test. Look carefully at the\n"
+ " open/close or incrementOpen/close pairs for all the AutoCloseable \n"
+ " objects (either Repository or one of its wrappers, like\n"
+ " RepoView or RepoRefCache) that is not managed properly inside a\n"
+ " try-with-resource enclosure.\n"
+ "\n"
+ "See below more details about the Repository objects created / opened and not"
+ " closed.\n"
+ "------------\n"
+ String.join("\n------------\n", repositoriesToReport));
}
}
private List<String> waitUntilAllRepositoriesAreClosed() {
try {
return RetryerBuilder.<List<String>>newBuilder()
.retryIfResult(Predicates.not(List::isEmpty))
.withStopStrategy(
StopStrategies.stopAfterDelay(
TIMEOUT_WAITING_FOR_CLOSED_REPOSITORIES_SEC, TimeUnit.SECONDS))
.build()
.call(this::getOpenRepositoriesToReport);
} catch (ExecutionException | RetryException e) {
return getOpenRepositoriesToReport();
}
}
private List<String> getOpenRepositoriesToReport() {
return MoreObjects.<Set<RepositoryTracking>>firstNonNull(
openRepositories, Collections.emptySet())
.stream()
.map(RepositoryTracking::reportIfOpen)
.flatMap(Optional::stream)
.toList();
}
@Override
public Status getRepositoryStatus(Project.NameKey name) {
return delegate.getRepositoryStatus(name);
}
private Repository trackRepository(Project.NameKey name, Repository repository) {
if (openRepositories == null || name.equals(allUsersName) || name.equals(allProjectsName)) {
return repository;
}
RepositoryTracking trackedRepository = new RepositoryTracking(name.get(), repository);
openRepositories.add(trackedRepository);
return trackedRepository;
}
private static boolean isDisabled(Description testDescription) {
if (testDescription.getAnnotation(NoGitRepositoryCheckIfClosed.class) != null) {
return true;
}
for (Class<?> clazz = testDescription.getTestClass();
clazz != null;
clazz = clazz.getSuperclass()) {
if (clazz.getAnnotation(NoGitRepositoryCheckIfClosed.class) != null) {
return true;
}
}
return false;
}
}