diff --git a/src/main/java/net/fabricmc/loom/task/prod/ClientProductionRunTask.java b/src/main/java/net/fabricmc/loom/task/prod/ClientProductionRunTask.java index 01779a857..6a8ff5145 100644 --- a/src/main/java/net/fabricmc/loom/task/prod/ClientProductionRunTask.java +++ b/src/main/java/net/fabricmc/loom/task/prod/ClientProductionRunTask.java @@ -25,13 +25,17 @@ package net.fabricmc.loom.task.prod; import java.io.File; +import java.io.IOException; import javax.inject.Inject; +import org.gradle.api.Action; import org.gradle.api.file.DirectoryProperty; import org.gradle.api.provider.Property; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Nested; +import org.gradle.api.tasks.Optional; import org.gradle.process.ExecSpec; import org.jetbrains.annotations.ApiStatus; @@ -53,6 +57,21 @@ public abstract non-sealed class ClientProductionRunTask extends AbstractProduct @Input public abstract Property getUseXVFB(); + @Nested + @Optional + public abstract Property getTracyCapture(); + + /** + * Configures the tracy profiler to run alongside the game. See @{@link TracyCapture} for more information. + * + * @param action The configuration action. + */ + public void tracy(Action action) { + getTracyCapture().set(getProject().getObjects().newInstance(TracyCapture.class)); + getTracyCapture().finalizeValue(); + action.execute(getTracyCapture().get()); + } + // Internal options @Input protected abstract Property getAssetsIndex(); @@ -86,6 +105,16 @@ public ClientProductionRunTask() { dependsOn("downloadAssets"); } + @Override + public void run() throws IOException { + if (getTracyCapture().isPresent()) { + getTracyCapture().get().runWithTracy(super::run); + return; + } + + super.run(); + } + @Override protected void configureCommand(ExecSpec exec) { if (getUseXVFB().get()) { @@ -120,5 +149,9 @@ protected void configureProgramArgs(ExecSpec exec) { "--assetsDir", getAssetsDir().get().getAsFile().getAbsolutePath(), "--gameDir", getRunDir().get().getAsFile().getAbsolutePath() ); + + if (getTracyCapture().isPresent()) { + exec.args("--tracy"); + } } } diff --git a/src/main/java/net/fabricmc/loom/task/prod/TracyCapture.java b/src/main/java/net/fabricmc/loom/task/prod/TracyCapture.java new file mode 100644 index 000000000..caff218eb --- /dev/null +++ b/src/main/java/net/fabricmc/loom/task/prod/TracyCapture.java @@ -0,0 +1,159 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.task.prod; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.function.Consumer; + +import javax.inject.Inject; + +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.OutputFile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.fabricmc.loom.util.ExceptionUtil; + +public abstract class TracyCapture { + private static final Logger LOGGER = LoggerFactory.getLogger(TracyCapture.class); + + /** + * The path to the tracy-capture executable. + */ + @InputFile + @Optional + public abstract RegularFileProperty getTracyCapture(); + + /** + * The maximum number of seconds to wait for tracy-capture to stop on its own before killing it. + * + *

Defaults to 10 seconds. + */ + @Input + public abstract Property getMaxShutdownWaitSeconds(); + + /** + * The path to the output file. + */ + @OutputFile + @Optional + public abstract RegularFileProperty getOutput(); + + @Inject + public TracyCapture() { + getMaxShutdownWaitSeconds().convention(10); + } + + void runWithTracy(IORunnable runnable) throws IOException { + TracyCaptureRunner tracyCaptureRunner = createRunner(); + + boolean success = false; + + try { + runnable.run(); + success = true; + } finally { + try { + tracyCaptureRunner.close(); + } catch (Exception e) { + if (success) { + //noinspection ThrowFromFinallyBlock + throw ExceptionUtil.createDescriptiveWrapper(RuntimeException::new, "Failed to stop tracy capture", e); + } + } + } + } + + private TracyCaptureRunner createRunner() throws IOException { + File tracyCapture = getTracyCapture().getAsFile().get(); + File output = getOutput().getAsFile().get(); + + ProcessBuilder builder = new ProcessBuilder() + .command(tracyCapture.getAbsolutePath(), "-a", "127.0.0.1", "-f", "-o", output.getAbsolutePath()); + Process process = builder.start(); + + captureLog(process.getInputStream(), LOGGER::info); + captureLog(process.getErrorStream(), LOGGER::error); + + LOGGER.info("Tracy capture started"); + + return new TracyCaptureRunner(process, getMaxShutdownWaitSeconds().get()); + } + + private record TracyCaptureRunner(Process process, int shutdownWait) implements AutoCloseable { + @Override + public void close() throws Exception { + // Wait x seconds for tracy to stop on its own + // This allows time for tracy to save the profile to disk + for (int i = 0; i < shutdownWait; i++) { + if (!process.isAlive()) { + break; + } + + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + // If it's still running, kill it + if (process.isAlive()) { + LOGGER.error("Tracy capture did not stop on its own, killing it"); + process.destroy(); + process.waitFor(); + } + + int exitCode = process.exitValue(); + + if (exitCode != 0) { + throw new RuntimeException("Tracy capture failed with exit code " + exitCode); + } + } + } + + private static void captureLog(InputStream inputStream, Consumer lineConsumer) { + new Thread(() -> { + try { + new BufferedReader(new InputStreamReader(inputStream)).lines().forEach(lineConsumer); + } catch (Exception e) { + // Don't really care, this will happen when the stream is closed + } + }).start(); + } + + @FunctionalInterface + public interface IORunnable { + void run() throws IOException; + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/integration/RunConfigTest.groovy b/src/test/groovy/net/fabricmc/loom/test/integration/RunConfigTest.groovy index 95b8f0118..f0f86bfbb 100644 --- a/src/test/groovy/net/fabricmc/loom/test/integration/RunConfigTest.groovy +++ b/src/test/groovy/net/fabricmc/loom/test/integration/RunConfigTest.groovy @@ -32,20 +32,24 @@ import spock.lang.Timeout import spock.lang.Unroll import spock.util.environment.RestoreSystemProperties +import net.fabricmc.loom.test.LoomTestConstants import net.fabricmc.loom.test.util.GradleProjectTestTrait +import net.fabricmc.loom.util.download.Download import static net.fabricmc.loom.test.LoomTestConstants.STANDARD_TEST_VERSIONS import static org.gradle.testkit.runner.TaskOutcome.SUCCESS // This test runs a mod that exits on mod init class RunConfigTest extends Specification implements GradleProjectTestTrait { - private static List tasks = [ + private static final List tasks = [ "runClient", "runServer", "runTestmodClient", "runTestmodServer", "runAutoTestServer" ] + private static final String TRACY_CAPTURE_LINUX = "https://github.com/modmuss50/tracy-utils/releases/download/0.0.2/linux-x86_64-tracy-capture" + @Unroll def "Run config #task (gradle #version)"() { setup: @@ -165,6 +169,9 @@ class RunConfigTest extends Specification implements GradleProjectTestTrait { @IgnoreIf({ !os.linux }) // XVFB is installed on the CI for this test def "prod client (gradle #version)"() { setup: + def tracyCapture = new File(LoomTestConstants.TEST_DIR, "tracy-capture") + Download.create(TRACY_CAPTURE_LINUX).defaultCache().downloadPath(tracyCapture.toPath()) + def gradle = gradleProject(project: "minimalBase", version: version) gradle.buildGradle << ''' configurations { @@ -183,13 +190,25 @@ class RunConfigTest extends Specification implements GradleProjectTestTrait { tasks.register("prodClient", net.fabricmc.loom.task.prod.ClientProductionRunTask) { mods.from(configurations.productionMods) jvmArgs.add("-Dfabric.client.gametest") + + tracy { + tracyCapture = file("tracy-capture") + output = file("profile.tracy") + } } ''' + + // Copy tracy into the project + def projectTracyCapture = new File(gradle.projectDir, "tracy-capture") + projectTracyCapture.bytes = tracyCapture.bytes + projectTracyCapture.setExecutable(true) + when: def result = gradle.run(task: "prodClient") then: result.task(":prodClient").outcome == SUCCESS + new File(gradle.projectDir, "profile.tracy").exists() where: version << STANDARD_TEST_VERSIONS