diff --git a/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/FabricApiAutoTestClient.java b/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/FabricApiAutoTestClient.java index 2829b397e5..60de59e2a3 100644 --- a/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/FabricApiAutoTestClient.java +++ b/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/FabricApiAutoTestClient.java @@ -18,15 +18,16 @@ import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.clickScreenButton; import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.closeScreen; +import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.computeOnClient; import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.connectToServer; import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.enableDebugHud; import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.openGameMenu; import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.openInventory; import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.setPerspective; -import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.submitAndWait; import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.takeScreenshot; import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.waitForLoadingComplete; import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.waitForScreen; +import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.waitForServerStop; import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.waitForTitleScreenFade; import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.waitForWorldTicks; @@ -35,7 +36,6 @@ import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Duration; import com.mojang.authlib.GameProfile; import org.spongepowered.asm.mixin.MixinEnvironment; @@ -54,28 +54,21 @@ import net.fabricmc.loader.api.FabricLoader; public class FabricApiAutoTestClient implements ClientModInitializer { + public static final boolean IS_AUTO_TEST = System.getProperty("fabric.autoTest") != null; + @Override public void onInitializeClient() { - if (System.getProperty("fabric.autoTest") == null) { + if (!IS_AUTO_TEST) { return; } - var thread = new Thread(() -> { - try { - runTest(); - } catch (Throwable t) { - t.printStackTrace(); - System.exit(1); - } - }); - thread.setName("Fabric Auto Test"); - thread.start(); + ThreadingImpl.runTestThread(this::runTest); } private void runTest() { waitForLoadingComplete(); - final boolean onboardAccessibility = submitAndWait(client -> client.options.onboardAccessibility); + final boolean onboardAccessibility = computeOnClient(client -> client.options.onboardAccessibility); if (onboardAccessibility) { waitForScreen(AccessibilityOnboardingScreen.class); @@ -86,7 +79,7 @@ private void runTest() { { waitForScreen(TitleScreen.class); waitForTitleScreenFade(); - takeScreenshot("title_screen", Duration.ZERO); + takeScreenshot("title_screen", 0); clickScreenButton("menu.singleplayer"); } @@ -113,7 +106,7 @@ private void runTest() { { enableDebugHud(); waitForWorldTicks(200); - takeScreenshot("in_game_overworld", Duration.ZERO); + takeScreenshot("in_game_overworld", 0); } MixinEnvironment.getCurrentEnvironment().audit(); @@ -136,18 +129,19 @@ private void runTest() { takeScreenshot("game_menu"); clickScreenButton("menu.returnToMenu"); waitForScreen(TitleScreen.class); + waitForServerStop(); } try (var server = new TestDedicatedServer()) { connectToServer(server); waitForWorldTicks(5); - final GameProfile profile = submitAndWait(MinecraftClient::getGameProfile); + final GameProfile profile = computeOnClient(MinecraftClient::getGameProfile); server.runCommand("op " + profile.getName()); server.runCommand("gamemode creative " + profile.getName()); waitForWorldTicks(20); - takeScreenshot("server_in_game", Duration.ZERO); + takeScreenshot("server_in_game", 0); { // Test that we can enter and exit configuration server.runCommand("debugconfig config " + profile.getName()); diff --git a/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/FabricClientTestHelper.java b/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/FabricClientTestHelper.java index 3d8c4a0743..79e194fb77 100644 --- a/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/FabricClientTestHelper.java +++ b/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/FabricClientTestHelper.java @@ -16,13 +16,15 @@ package net.fabricmc.fabric.test.base.client; -import java.time.Duration; -import java.time.LocalDateTime; import java.util.Objects; -import java.util.concurrent.CompletableFuture; import java.util.function.Function; import java.util.function.Predicate; +import org.apache.commons.lang3.function.FailableConsumer; +import org.apache.commons.lang3.function.FailableFunction; +import org.apache.commons.lang3.mutable.MutableObject; + +import net.minecraft.SharedConstants; import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.Drawable; import net.minecraft.client.gui.screen.GameMenuScreen; @@ -48,10 +50,21 @@ import net.fabricmc.fabric.test.base.client.mixin.TitleScreenAccessor; import net.fabricmc.loader.api.FabricLoader; -// Provides thread safe utils for interacting with a running game. public final class FabricClientTestHelper { public static void waitForLoadingComplete() { - waitFor("Loading to complete", client -> client.getOverlay() == null, Duration.ofMinutes(5)); + // client is not ticking and can't accept tasks, waitFor doesn't work so we'll do this until then + while (!ThreadingImpl.clientCanAcceptTasks) { + runTick(); + + try { + //noinspection BusyWait + Thread.sleep(50); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + waitFor("Loading to complete", client -> client.getOverlay() == null, 5 * SharedConstants.TICKS_PER_MINUTE); } public static void waitForScreen(Class screenClass) { @@ -66,7 +79,7 @@ public static void openGameMenu() { public static void openInventory() { setScreen((client) -> new InventoryScreen(Objects.requireNonNull(client.player))); - boolean creative = submitAndWait(client -> Objects.requireNonNull(client.player).isCreative()); + boolean creative = computeOnClient(client -> Objects.requireNonNull(client.player).isCreative()); waitForScreen(creative ? CreativeInventoryScreen.class : InventoryScreen.class); } @@ -75,24 +88,20 @@ public static void closeScreen() { } private static void setScreen(Function screenSupplier) { - submit(client -> { - client.setScreen(screenSupplier.apply(client)); - return null; - }); + runOnClient(client -> client.setScreen(screenSupplier.apply(client))); } public static void takeScreenshot(String name) { - takeScreenshot(name, Duration.ofMillis(50)); + takeScreenshot(name, 1); } - public static void takeScreenshot(String name, Duration delay) { + public static void takeScreenshot(String name, int delayTicks) { // Allow time for any screens to open - waitFor(delay); + runTicks(delayTicks); - submitAndWait(client -> { + runOnClient(client -> { ScreenshotRecorder.saveScreenshot(FabricLoader.getInstance().getGameDir().toFile(), name + ".png", client.getFramebuffer(), (message) -> { }); - return null; }); } @@ -145,30 +154,23 @@ private static boolean pressMatchingButton(ClickableWidget widget, String text) public static void waitForWorldTicks(long ticks) { // Wait for the world to be loaded and get the start ticks - waitFor("World load", client -> client.world != null && !(client.currentScreen instanceof LevelLoadingScreen), Duration.ofMinutes(30)); - final long startTicks = submitAndWait(client -> client.world.getTime()); - waitFor("World load", client -> Objects.requireNonNull(client.world).getTime() > startTicks + ticks, Duration.ofMinutes(10)); + waitFor("World load", client -> client.world != null && !(client.currentScreen instanceof LevelLoadingScreen), 30 * SharedConstants.TICKS_PER_MINUTE); + final long startTicks = computeOnClient(client -> client.world.getTime()); + waitFor("World load", client -> Objects.requireNonNull(client.world).getTime() > startTicks + ticks, 10 * SharedConstants.TICKS_PER_MINUTE); } public static void enableDebugHud() { - submitAndWait(client -> { - client.inGameHud.getDebugHud().toggleDebugHud(); - return null; - }); + runOnClient(client -> client.inGameHud.getDebugHud().toggleDebugHud()); } public static void setPerspective(Perspective perspective) { - submitAndWait(client -> { - client.options.setPerspective(perspective); - return null; - }); + runOnClient(client -> client.options.setPerspective(perspective)); } public static void connectToServer(TestDedicatedServer server) { - submitAndWait(client -> { + runOnClient(client -> { final var serverInfo = new ServerInfo("localhost", server.getConnectionAddress(), ServerInfo.ServerType.OTHER); ConnectScreen.connect(client.currentScreen, client, ServerAddress.parse(server.getConnectionAddress()), serverInfo, false, null); - return null; }); } @@ -182,41 +184,43 @@ public static void waitForTitleScreenFade() { }); } - private static void waitFor(String what, Predicate predicate) { - waitFor(what, predicate, Duration.ofSeconds(10)); + public static void waitForServerStop() { + waitFor("Server stop", client -> !ThreadingImpl.isServerRunning, SharedConstants.TICKS_PER_MINUTE); } - private static void waitFor(String what, Predicate predicate, Duration timeout) { - final LocalDateTime end = LocalDateTime.now().plus(timeout); - - while (true) { - boolean result = submitAndWait(predicate::test); + private static void waitFor(String what, Predicate predicate) { + waitFor(what, predicate, 10 * SharedConstants.TICKS_PER_SECOND); + } - if (result) { - break; - } + private static void waitFor(String what, Predicate predicate, int timeoutTicks) { + int tickCount; - if (LocalDateTime.now().isAfter(end)) { - throw new RuntimeException("Timed out waiting for " + what); - } + for (tickCount = 0; tickCount < timeoutTicks && !computeOnClient(predicate::test); tickCount++) { + runTick(); + } - waitFor(Duration.ofMillis(50)); + if (tickCount == timeoutTicks && !computeOnClient(predicate::test)) { + throw new RuntimeException("Timed out waiting for " + what); } } - private static void waitFor(Duration duration) { - try { - Thread.sleep(duration.toMillis()); - } catch (InterruptedException e) { - throw new RuntimeException(e); + public static void runTicks(int ticks) { + for (int i = 0; i < ticks; i++) { + runTick(); } } - private static CompletableFuture submit(Function function) { - return MinecraftClient.getInstance().submit(() -> function.apply(MinecraftClient.getInstance())); + public static void runTick() { + ThreadingImpl.runTick(); + } + + public static void runOnClient(FailableConsumer action) throws E { + ThreadingImpl.runOnClient(() -> action.accept(MinecraftClient.getInstance())); } - public static T submitAndWait(Function function) { - return submit(function).join(); + public static T computeOnClient(FailableFunction action) throws E { + MutableObject result = new MutableObject<>(); + runOnClient(client -> result.setValue(action.apply(client))); + return result.getValue(); } } diff --git a/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/TestDedicatedServer.java b/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/TestDedicatedServer.java index 716d275fc8..58796eb6e2 100644 --- a/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/TestDedicatedServer.java +++ b/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/TestDedicatedServer.java @@ -23,11 +23,9 @@ import java.nio.file.Paths; import java.time.Duration; import java.util.Objects; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Function; import net.minecraft.server.Main; import net.minecraft.server.dedicated.MinecraftDedicatedServer; @@ -51,10 +49,7 @@ public String getConnectionAddress() { } public void runCommand(String command) { - submitAndWait(server -> { - server.enqueueCommand(command, server.getCommandSource()); - return null; - }); + ThreadingImpl.runOnServer(() -> server.getCommandManager().executeWithPrefix(server.getCommandSource(), command)); } private void run() { @@ -62,14 +57,6 @@ private void run() { Main.main(new String[]{}); } - private CompletableFuture submit(Function function) { - return server.submit(() -> function.apply(server)); - } - - private T submitAndWait(Function function) { - return submit(function).join(); - } - private void setupServer() { try { Files.writeString(Paths.get("eula.txt"), "eula=true"); @@ -100,7 +87,12 @@ private void waitUntilReady() { @Override public void close() { - server.stop(true); + server.stop(false); + + while (server.getThread().isAlive()) { + ThreadingImpl.runTick(); + } + executor.close(); } } diff --git a/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/ThreadingImpl.java b/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/ThreadingImpl.java new file mode 100644 index 0000000000..ee13bcf40b --- /dev/null +++ b/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/ThreadingImpl.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2016, 2017, 2018, 2019 FabricMC + * + * 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 net.fabricmc.fabric.test.base.client; + +import java.util.concurrent.Phaser; +import java.util.concurrent.Semaphore; + +import com.google.common.base.Preconditions; +import org.apache.commons.lang3.function.FailableRunnable; +import org.apache.commons.lang3.mutable.MutableObject; +import org.jetbrains.annotations.Nullable; + +/** + *

Implementation notes

+ * + *

When a client test is running, ticks are run in a much more controlled way than in vanilla. A tick is split into 4 + * phases: + *

    + *
  1. {@linkplain #PHASE_TICK} - The client and server threads run a single tick in parallel, if they exist. The test thread waits.
  2. + *
  3. {@linkplain #PHASE_SERVER_TASKS} - The server runs its task queue, if the server exists. The other threads wait.
  4. + *
  5. {@linkplain #PHASE_CLIENT_TASKS} - The client runs its task queue, if the client exists. The other threads wait.
  6. + *
  7. {@linkplain #PHASE_TEST} - The test thread runs test code while the client and server threads wait for tasks to be handed off.
  8. + *
+ * + *

In {@code PHASE_TEST}, the client and server threads (if they exist) are blocked on semaphores waiting for tasks + * to be handed to them from the test thread. When the test thread wants to send one of the other threads a task to run, + * it sets {@linkplain #taskToRun} to the task runnable and releases the semaphore of the thread that should run the + * task. It then blocks on its own semaphore until the task is complete, at which point the thread which completed the + * task will release the test thread semaphore and re-block on its own semaphore again and the cycle continues. When the + * test phase is over (i.e. when the test thread wants to wait a tick), the client and server semaphores will be + * released while leaving {@linkplain #taskToRun} as {@code null}, which they will interpret to mean they are to + * continue into {@linkplain #PHASE_TICK}. + * + *

The reason these phases were chosen are to make client-server interaction in singleplayer as consistent as + * possible. The task queues are when most packets are handled, and without them being run in sequence it would be + * unspecified whether a packet would be handled on the current tick until the next one. The server task queue is before + * the client so that changes on the server appear on the client more readily. The test phase is run after the task + * queues rather than at the end of the physical tick (i.e. {@code MinecraftClient}'s and {@code MinecraftServer}'s + * {@code tick} methods), for no particular reason other than to avoid needing a 5th phase, and having a power of 2 + * number of phases is convenient when using {@linkplain Phaser}, as it doesn't break when the phase counter overflows. + * + *

Other challenges include that a client or server can be started during {@linkplain #PHASE_TEST} but haven't + * reached their semaphore code yet meaning they are unable to accept tasks. This is solved by setting a flag to true + * when the client/server is ready to accept tasks. Also the client will block on the integrated server starting and + * stopping. This is solved by first deferring those operations until {@linkplain #PHASE_TICK} if they are being run + * inside a test phase task (which is a minor difference from vanilla), and then ensuring the client is still running + * the phase logic and is able to accept tasks while it is waiting for the server. + */ +public final class ThreadingImpl { + private ThreadingImpl() { + } + + public static final int PHASE_TICK = 0; + public static final int PHASE_SERVER_TASKS = 1; + public static final int PHASE_CLIENT_TASKS = 2; + public static final int PHASE_TEST = 3; + private static final int PHASE_MASK = 3; + + public static final Phaser PHASER = new Phaser(); + + public static volatile boolean isClientRunning = false; + public static volatile boolean clientCanAcceptTasks = false; + public static final Semaphore CLIENT_SEMAPHORE = new Semaphore(0); + + public static volatile boolean isServerRunning = false; + public static volatile boolean serverCanAcceptTasks = false; + public static final Semaphore SERVER_SEMAPHORE = new Semaphore(0); + + @Nullable + public static Thread testThread = null; + public static final Semaphore TEST_SEMAPHORE = new Semaphore(0); + + @Nullable + public static Runnable taskToRun = null; + + public static void enterPhase(int phase) { + while ((PHASER.getPhase() & PHASE_MASK) != phase) { + PHASER.arriveAndAwaitAdvance(); + } + + PHASER.arriveAndAwaitAdvance(); + } + + public static void runTestThread(Runnable test) { + Preconditions.checkState(testThread == null, "There is already a test thread running"); + + testThread = new Thread(() -> { + PHASER.register(); + enterPhase(PHASE_TEST); + + try { + test.run(); + } catch (Throwable e) { + e.printStackTrace(); + System.exit(1); + } finally { + PHASER.arriveAndDeregister(); + + if (clientCanAcceptTasks) { + CLIENT_SEMAPHORE.release(); + } + + if (serverCanAcceptTasks) { + SERVER_SEMAPHORE.release(); + } + + testThread = null; + } + }); + testThread.setName("Test thread"); + testThread.start(); + } + + @SuppressWarnings("unchecked") + public static void runOnClient(FailableRunnable action) throws E { + Preconditions.checkNotNull(action, "action"); + Preconditions.checkState(Thread.currentThread() == testThread, "runOnClient can only be called from the test thread"); + Preconditions.checkState(clientCanAcceptTasks, "runOnClient called when no client is running"); + + MutableObject thrown = new MutableObject<>(); + taskToRun = () -> { + try { + action.run(); + } catch (Throwable e) { + thrown.setValue((E) e); + } finally { + taskToRun = null; + TEST_SEMAPHORE.release(); + } + }; + + CLIENT_SEMAPHORE.release(); + + try { + TEST_SEMAPHORE.acquire(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + if (thrown.getValue() != null) { + throw thrown.getValue(); + } + } + + @SuppressWarnings("unchecked") + public static void runOnServer(FailableRunnable action) throws E { + Preconditions.checkNotNull(action, "action"); + Preconditions.checkState(Thread.currentThread() == testThread, "runOnServer can only be called from the test thread"); + Preconditions.checkState(serverCanAcceptTasks, "runOnServer called when no server is running"); + + MutableObject thrown = new MutableObject<>(); + taskToRun = () -> { + try { + action.run(); + } catch (Throwable e) { + thrown.setValue((E) e); + } finally { + taskToRun = null; + TEST_SEMAPHORE.release(); + } + }; + + SERVER_SEMAPHORE.release(); + + try { + TEST_SEMAPHORE.acquire(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + if (thrown.getValue() != null) { + throw thrown.getValue(); + } + } + + public static void runTick() { + Preconditions.checkState(Thread.currentThread() == testThread, "runTick can only be called from the test thread"); + + if (clientCanAcceptTasks) { + CLIENT_SEMAPHORE.release(); + } + + if (serverCanAcceptTasks) { + SERVER_SEMAPHORE.release(); + } + + enterPhase(PHASE_TEST); + } +} diff --git a/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/mixin/MinecraftClientMixin.java b/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/mixin/MinecraftClientMixin.java new file mode 100644 index 0000000000..137d4defa5 --- /dev/null +++ b/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/mixin/MinecraftClientMixin.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2016, 2017, 2018, 2019 FabricMC + * + * 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 net.fabricmc.fabric.test.base.client.mixin; + +import com.google.common.base.Preconditions; +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.resource.ResourcePackManager; +import net.minecraft.server.SaveLoader; +import net.minecraft.world.level.storage.LevelStorage; + +import net.fabricmc.fabric.test.base.client.FabricApiAutoTestClient; +import net.fabricmc.fabric.test.base.client.ThreadingImpl; + +@Mixin(MinecraftClient.class) +public class MinecraftClientMixin { + @Unique + private Runnable deferredTask = null; + + @WrapMethod(method = "run") + private void onRun(Operation original) { + if (FabricApiAutoTestClient.IS_AUTO_TEST) { + if (ThreadingImpl.isClientRunning) { + throw new IllegalStateException("Client is already running"); + } + + ThreadingImpl.isClientRunning = true; + ThreadingImpl.PHASER.register(); + } + + try { + original.call(); + } finally { + if (FabricApiAutoTestClient.IS_AUTO_TEST) { + ThreadingImpl.clientCanAcceptTasks = false; + ThreadingImpl.PHASER.arriveAndDeregister(); + ThreadingImpl.isClientRunning = false; + } + } + } + + @Inject(method = "render", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/MinecraftClient;runTasks()V")) + private void preRunTasks(CallbackInfo ci) { + if (FabricApiAutoTestClient.IS_AUTO_TEST) { + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_SERVER_TASKS); + // server tasks happen here + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_CLIENT_TASKS); + } + } + + @Inject(method = "render", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/MinecraftClient;runTasks()V", shift = At.Shift.AFTER)) + private void postRunTasks(CallbackInfo ci) { + if (FabricApiAutoTestClient.IS_AUTO_TEST) { + ThreadingImpl.clientCanAcceptTasks = true; + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TEST); + + if (ThreadingImpl.testThread != null) { + while (true) { + try { + ThreadingImpl.CLIENT_SEMAPHORE.acquire(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + if (ThreadingImpl.taskToRun != null) { + ThreadingImpl.taskToRun.run(); + } else { + break; + } + } + } + + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TICK); + + Runnable deferredTask = this.deferredTask; + this.deferredTask = null; + + if (deferredTask != null) { + deferredTask.run(); + } + } + } + + @Inject(method = "startIntegratedServer", at = @At("HEAD"), cancellable = true) + private void deferStartIntegratedServer(LevelStorage.Session session, ResourcePackManager dataPackManager, SaveLoader saveLoader, boolean newWorld, CallbackInfo ci) { + if (FabricApiAutoTestClient.IS_AUTO_TEST && ThreadingImpl.taskToRun != null) { + // don't start the integrated server (which busywaits) inside a task + deferredTask = () -> MinecraftClient.getInstance().startIntegratedServer(session, dataPackManager, saveLoader, newWorld); + ci.cancel(); + } + } + + @Inject(method = "startIntegratedServer", at = @At(value = "INVOKE", target = "Ljava/lang/Thread;sleep(J)V", remap = false)) + private void onStartIntegratedServerBusyWait(CallbackInfo ci) { + if (FabricApiAutoTestClient.IS_AUTO_TEST) { + // give the server a chance to tick too + preRunTasks(ci); + postRunTasks(ci); + } + } + + @Inject(method = "disconnect(Lnet/minecraft/client/gui/screen/Screen;Z)V", at = @At("HEAD"), cancellable = true) + private void deferDisconnect(Screen disconnectionScreen, boolean transferring, CallbackInfo ci) { + if (FabricApiAutoTestClient.IS_AUTO_TEST && MinecraftClient.getInstance().getServer() != null && ThreadingImpl.taskToRun != null) { + // don't disconnect (which busywaits) inside a task + deferredTask = () -> MinecraftClient.getInstance().disconnect(disconnectionScreen, transferring); + ci.cancel(); + } + } + + @Inject(method = "disconnect(Lnet/minecraft/client/gui/screen/Screen;Z)V", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/MinecraftClient;render(Z)V", shift = At.Shift.AFTER)) + private void onDisconnectBusyWait(CallbackInfo ci) { + if (FabricApiAutoTestClient.IS_AUTO_TEST) { + // give the server a chance to tick too + preRunTasks(ci); + postRunTasks(ci); + } + } + + @Inject(method = "getInstance", at = @At("HEAD")) + private static void checkThreadOnGetInstance(CallbackInfoReturnable cir) { + if (FabricApiAutoTestClient.IS_AUTO_TEST) { + // TODO: add suggestion of runOnClient etc when API methods are added + Preconditions.checkState(Thread.currentThread() != ThreadingImpl.testThread, "MinecraftClient.getInstance() cannot be called from the test thread"); + } + } +} diff --git a/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/mixin/MinecraftServerMixin.java b/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/mixin/MinecraftServerMixin.java new file mode 100644 index 0000000000..9b044be19b --- /dev/null +++ b/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/mixin/MinecraftServerMixin.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2016, 2017, 2018, 2019 FabricMC + * + * 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 net.fabricmc.fabric.test.base.client.mixin; + +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import net.minecraft.server.MinecraftServer; + +import net.fabricmc.fabric.test.base.client.FabricApiAutoTestClient; +import net.fabricmc.fabric.test.base.client.ThreadingImpl; + +@Mixin(MinecraftServer.class) +public class MinecraftServerMixin { + @WrapMethod(method = "runServer") + private void onRunServer(Operation original) { + if (FabricApiAutoTestClient.IS_AUTO_TEST) { + if (ThreadingImpl.isServerRunning) { + throw new IllegalStateException("Server is already running"); + } + + ThreadingImpl.isServerRunning = true; + ThreadingImpl.PHASER.register(); + } + + try { + original.call(); + } finally { + if (FabricApiAutoTestClient.IS_AUTO_TEST) { + ThreadingImpl.serverCanAcceptTasks = false; + ThreadingImpl.PHASER.arriveAndDeregister(); + ThreadingImpl.isServerRunning = false; + } + } + } + + @Inject(method = "runServer", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;runTasksTillTickEnd()V")) + private void preRunTasks(CallbackInfo ci) { + if (FabricApiAutoTestClient.IS_AUTO_TEST) { + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_SERVER_TASKS); + } + } + + @Inject(method = "runServer", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;runTasksTillTickEnd()V", shift = At.Shift.AFTER)) + private void postRunTasks(CallbackInfo ci) { + if (FabricApiAutoTestClient.IS_AUTO_TEST) { + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_CLIENT_TASKS); + // client tasks happen here + + ThreadingImpl.serverCanAcceptTasks = true; + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TEST); + + if (ThreadingImpl.testThread != null) { + while (true) { + try { + ThreadingImpl.SERVER_SEMAPHORE.acquire(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + if (ThreadingImpl.taskToRun != null) { + ThreadingImpl.taskToRun.run(); + } else { + break; + } + } + } + + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TICK); + } + } +} diff --git a/fabric-api-base/src/testmodClient/resources/fabric-api-base-testmod.client.mixins.json b/fabric-api-base/src/testmodClient/resources/fabric-api-base-testmod.client.mixins.json index ee12e340ba..fb5cc068d0 100644 --- a/fabric-api-base/src/testmodClient/resources/fabric-api-base-testmod.client.mixins.json +++ b/fabric-api-base/src/testmodClient/resources/fabric-api-base-testmod.client.mixins.json @@ -4,11 +4,15 @@ "compatibilityLevel": "JAVA_21", "client": [ "CyclingButtonWidgetAccessor", + "MinecraftClientMixin", "MinecraftDedicatedServerMixin", "ScreenAccessor", "TitleScreenAccessor" ], "injectors": { "defaultRequire": 1 - } + }, + "mixins": [ + "MinecraftServerMixin" + ] }