From 1d31f5f56754c62350b9f4b81d8dc179a7436638 Mon Sep 17 00:00:00 2001 From: Rieno Date: Mon, 27 Jul 2026 22:48:06 +0100 Subject: [PATCH 1/2] Redstone Output server hang - Fixed Redstone output server hang with Non-blocking Redstone Output handoff - Enforced and corrected endpoint execution-side policies --- .../content/blocks/ComputerBlockEntity.java | 138 +++++++++-- .../ComputerCraftIntegration.java | 8 +- .../integration/create/CreateIntegration.java | 10 +- .../lua/endpoint/BuiltinEndpoints.java | 18 +- .../computed/lua/endpoint/EndpointPolicy.java | 7 + .../lua/endpoint/ServerEndpointExecutor.java | 9 + .../lua/runtime/LuaEndpointProxy.java | 41 +++- .../lua/runtime/PendingLuaInvocation.java | 26 ++- .../computed/graph/LuaGraphSchedulerTest.java | 51 ++++- .../lua/node/BundledLuaLibraryTest.java | 184 ++++++++++----- .../runtime/ServerEndpointDispatchTest.java | 214 ++++++++++++++++++ 11 files changed, 599 insertions(+), 107 deletions(-) create mode 100644 src/main/java/dev/propulsionteam/computed/lua/endpoint/ServerEndpointExecutor.java create mode 100644 src/test/java/dev/propulsionteam/computed/lua/runtime/ServerEndpointDispatchTest.java diff --git a/src/main/java/dev/propulsionteam/computed/content/blocks/ComputerBlockEntity.java b/src/main/java/dev/propulsionteam/computed/content/blocks/ComputerBlockEntity.java index d42ec85..07bac29 100644 --- a/src/main/java/dev/propulsionteam/computed/content/blocks/ComputerBlockEntity.java +++ b/src/main/java/dev/propulsionteam/computed/content/blocks/ComputerBlockEntity.java @@ -19,6 +19,8 @@ import dev.propulsionteam.computed.graph.LuaGraphScheduler; import dev.propulsionteam.computed.lua.endpoint.BuiltinEndpointHost; import dev.propulsionteam.computed.lua.endpoint.BuiltinWidget; +import dev.propulsionteam.computed.lua.endpoint.EndpointResult; +import dev.propulsionteam.computed.lua.endpoint.ServerEndpointExecutor; import dev.propulsionteam.computed.menu.ComputerPeripheralMenu; import dev.propulsionteam.computed.network.ComputerEditPolicy; import dev.propulsionteam.computed.network.ComputedNetworking; @@ -31,6 +33,9 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import net.minecraft.commands.CommandSourceStack; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; @@ -53,7 +58,8 @@ import net.minecraft.world.phys.Vec3; import org.jetbrains.annotations.Nullable; -public class ComputerBlockEntity extends BaseContainerBlockEntity implements BuiltinEndpointHost { +public class ComputerBlockEntity extends BaseContainerBlockEntity + implements BuiltinEndpointHost, ServerEndpointExecutor { public static final int CONTAINER_SIZE = 9; public static final String PROGRAM_TAG = "ComputedProgram"; @@ -338,7 +344,11 @@ public void handleUpdateTag(CompoundTag tag, HolderLookup.Provider registries) { @Override public double worldTime() { - return level == null ? 0 : level.getDayTime(); + if (!(level instanceof ServerLevel serverLevel) || isRemoved()) { + return 0; + } + requireServerThread(serverLevel); + return serverLevel.getDayTime(); } @Override @@ -357,42 +367,85 @@ public double[] rotation() { @Override public int redstoneInput(String face) { + if (!(level instanceof ServerLevel serverLevel) || isRemoved()) { + return 0; + } + requireServerThread(serverLevel); Direction worldFace = worldFace(face); - if (worldFace == null || level == null || level.isClientSide) { + if (worldFace == null) { return 0; } BlockPos neighbor = worldPosition.relative(worldFace); - return level.getSignal(neighbor, worldFace); + return serverLevel.getSignal(neighbor, worldFace); } @Override public int comparatorInput(String face) { + if (!(level instanceof ServerLevel serverLevel) || isRemoved()) { + return 0; + } + requireServerThread(serverLevel); Direction worldFace = worldFace(face); - if (worldFace == null || level == null || level.isClientSide) { + if (worldFace == null) { return 0; } BlockPos neighbor = worldPosition.relative(worldFace); - BlockState target = level.getBlockState(neighbor); + BlockState target = serverLevel.getBlockState(neighbor); return target.hasAnalogOutputSignal() - ? target.getAnalogOutputSignal(level, neighbor) - : level.getSignal(neighbor, worldFace); + ? target.getAnalogOutputSignal(serverLevel, neighbor) + : serverLevel.getSignal(neighbor, worldFace); } @Override public boolean blockPresent(String face) { + if (!(level instanceof ServerLevel serverLevel) || isRemoved()) { + return false; + } + requireServerThread(serverLevel); Direction worldFace = worldFace(face); return worldFace != null - && level != null - && !level.isClientSide - && !level.getBlockState(worldPosition.relative(worldFace)).isAir(); + && !serverLevel.getBlockState(worldPosition.relative(worldFace)).isAir(); } @Override public void redstoneOutput(String face, int power) { + Level currentLevel = level; + if (!(currentLevel instanceof ServerLevel serverLevel) || isRemoved()) { + return; + } + + MinecraftServer server = serverLevel.getServer(); + LuaGraphScheduler expectedScheduler = scheduler; + if (!server.isSameThread()) { + server.execute(() -> applyRedstoneOutput( + serverLevel, + expectedScheduler, + face, + power)); + return; + } + + applyRedstoneOutput(serverLevel, expectedScheduler, face, power); + } + + private void applyRedstoneOutput( + ServerLevel expectedLevel, + LuaGraphScheduler expectedScheduler, + String face, + int power) { + requireServerThread(expectedLevel); + if (isRemoved() + || level != expectedLevel + || scheduler != expectedScheduler + || expectedLevel.getBlockEntity(worldPosition) != this) { + return; + } + Direction worldFace = worldFace(face); - if (worldFace == null || level == null || level.isClientSide) { + if (worldFace == null) { return; } + int clamped = net.minecraft.util.Mth.clamp(power, 0, 15); if (emittedRedstone[worldFace.ordinal()] == clamped) { return; @@ -412,16 +465,10 @@ private void flushRedstoneUpdate() { @Override public void showWidgets(String target, List definitions) { - if (level == null || level.isClientSide) { - return; - } - MinecraftServer server = level.getServer(); - if (server != null && !server.isSameThread()) { - String queuedTarget = target; - List queuedDefinitions = List.copyOf(definitions); - server.execute(() -> applyWidgets(queuedTarget, queuedDefinitions)); + if (!(level instanceof ServerLevel serverLevel) || isRemoved()) { return; } + requireServerThread(serverLevel); applyWidgets(target, definitions); } @@ -454,12 +501,14 @@ private void applyWidgets(String target, List definitions) { public void runCommand(String commandText) { if (commandText == null || commandText.isBlank() - || !(level instanceof ServerLevel serverLevel)) { + || !(level instanceof ServerLevel serverLevel) + || isRemoved()) { return; } + requireServerThread(serverLevel); MinecraftServer server = serverLevel.getServer(); String command = commandText.startsWith("/") ? commandText.substring(1) : commandText; - if (server == null || command.isBlank()) { + if (command.isBlank()) { return; } Vec3 center = Vec3.atCenterOf(worldPosition); @@ -471,6 +520,41 @@ public void runCommand(String commandText) { server.getCommands().performPrefixedCommand(source, command); } + @Override + public CompletionStage submitServerEndpoint( + Callable endpointCall) { + if (!(level instanceof ServerLevel expectedLevel) || isRemoved()) { + return CompletableFuture.failedFuture( + new IllegalStateException("Computer is not available")); + } + + MinecraftServer server = expectedLevel.getServer(); + LuaGraphScheduler expectedScheduler = scheduler; + CompletableFuture result = new CompletableFuture<>(); + try { + server.execute(() -> { + if (isRemoved() + || level != expectedLevel + || scheduler != expectedScheduler + || expectedLevel.getBlockEntity(worldPosition) != this) { + result.completeExceptionally( + new IllegalStateException( + "Computer or program changed before endpoint execution")); + return; + } + + try { + result.complete(endpointCall.call()); + } catch (Exception exception) { + result.completeExceptionally(exception); + } + }); + } catch (RuntimeException exception) { + result.completeExceptionally(exception); + } + return result; + } + private LuaGraphScheduler ensureScheduler() { if (scheduler == null) { scheduler = new LuaGraphScheduler(program, getOrCreateUuid(), this); @@ -562,9 +646,19 @@ private static UUID stableGraphId(BlockPos pos) { } public Direction worldFaceForEndpoint(String name) { + if (level instanceof ServerLevel serverLevel) { + requireServerThread(serverLevel); + } return worldFace(name); } + private static void requireServerThread(ServerLevel serverLevel) { + if (!serverLevel.getServer().isSameThread()) { + throw new IllegalStateException( + "Computer world access attempted outside the server thread"); + } + } + private Direction worldFace(String name) { if (name == null) { return null; diff --git a/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftIntegration.java b/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftIntegration.java index a7c67f2..6d07c8a 100644 --- a/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftIntegration.java +++ b/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftIntegration.java @@ -106,7 +106,7 @@ private static void registerEndpoints() { ComputedEndpoints.register("computercraft:peripheral", endpoint -> endpoint.method( "methods", EndpointSignature.of(List.of(), List.of(EndpointType.TABLE)), - EndpointPolicy.computerThread(false, false), + EndpointPolicy.serverThread(false, false), ComputerCraftIntegration::peripheralMethods, null, "Lists methods exposed by the adjacent CC peripheral.") @@ -116,11 +116,7 @@ private static void registerEndpoints() { List.of(EndpointType.STRING), List.of(EndpointType.TABLE), true), - new EndpointPolicy( - EndpointPolicy.ExecutionSide.SERVER_THREAD, - true, - true, - false), + EndpointPolicy.serverThread(true, false), ComputerCraftIntegration::callPeripheral, null, "Calls an adjacent CC peripheral method and resumes yielded results.")); diff --git a/src/main/java/dev/propulsionteam/computed/integration/create/CreateIntegration.java b/src/main/java/dev/propulsionteam/computed/integration/create/CreateIntegration.java index d64293f..a194fbf 100644 --- a/src/main/java/dev/propulsionteam/computed/integration/create/CreateIntegration.java +++ b/src/main/java/dev/propulsionteam/computed/integration/create/CreateIntegration.java @@ -27,21 +27,21 @@ public static void register() { ComputedEndpoints.register("create:kinetic", endpoint -> endpoint.method( "speed", EndpointSignature.of(List.of(), List.of(EndpointType.NUMBER)), - EndpointPolicy.computerThread(false, true), + EndpointPolicy.serverThread(false, true), invocation -> number(kinetic(invocation, Metric.SPEED)), ignored -> number(0), "Returns the adjacent Create kinetic speed.") .method( "stress", EndpointSignature.of(List.of(), List.of(EndpointType.NUMBER)), - EndpointPolicy.computerThread(false, true), + EndpointPolicy.serverThread(false, true), invocation -> number(kinetic(invocation, Metric.STRESS)), ignored -> number(0), "Returns the adjacent Create kinetic stress.") .method( "capacity", EndpointSignature.of(List.of(), List.of(EndpointType.NUMBER)), - EndpointPolicy.computerThread(false, true), + EndpointPolicy.serverThread(false, true), invocation -> number(kinetic(invocation, Metric.CAPACITY)), ignored -> number(0), "Returns the adjacent Create kinetic capacity.")); @@ -50,7 +50,7 @@ public static void register() { EndpointSignature.of( List.of(EndpointType.STRING, EndpointType.STRING), List.of(EndpointType.NUMBER)), - EndpointPolicy.computerThread(false, false), + EndpointPolicy.serverThread(false, false), CreateIntegration::receive, null, "Reads a Create redstone-link frequency pair.") @@ -59,7 +59,7 @@ public static void register() { EndpointSignature.of( List.of(EndpointType.STRING, EndpointType.STRING, EndpointType.NUMBER), List.of()), - EndpointPolicy.computerThread(true, false), + EndpointPolicy.serverThread(true, false), CreateIntegration::transmit, null, "Writes a Create redstone-link frequency pair.")); diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinEndpoints.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinEndpoints.java index 10cc26c..6d32472 100644 --- a/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinEndpoints.java +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinEndpoints.java @@ -20,7 +20,7 @@ public static void register() { ComputedEndpoints.register("computed:world", endpoint -> endpoint.method( "time", EndpointSignature.of(List.of(), List.of(EndpointType.NUMBER)), - EndpointPolicy.computerThread(false, true), + EndpointPolicy.serverThread(false, true), invocation -> EndpointResult.immediate( LuaValue.valueOf(requireHost(invocation).worldTime())), invocation -> EndpointResult.immediate(LuaValue.valueOf(6000)), @@ -30,7 +30,7 @@ public static void register() { EndpointSignature.of( List.of(), List.of(EndpointType.NUMBER, EndpointType.NUMBER, EndpointType.NUMBER)), - EndpointPolicy.computerThread(false, true), + EndpointPolicy.serverThread(false, true), invocation -> numbers(requireHost(invocation).position()), invocation -> numbers(new double[] {0.5, 64.5, 0.5}), "Returns the computer world position.") @@ -39,14 +39,14 @@ public static void register() { EndpointSignature.of( List.of(), List.of(EndpointType.NUMBER, EndpointType.NUMBER, EndpointType.NUMBER)), - EndpointPolicy.computerThread(false, true), + EndpointPolicy.serverThread(false, true), invocation -> numbers(requireHost(invocation).rotation()), invocation -> numbers(new double[] {0, 0, 0}), "Returns the computer rotation in degrees.") .method( "block_present", EndpointSignature.of(List.of(EndpointType.STRING), List.of(EndpointType.BOOLEAN)), - EndpointPolicy.computerThread(false, true), + EndpointPolicy.serverThread(false, true), invocation -> EndpointResult.immediate(LuaValue.valueOf( requireHost(invocation).blockPresent(argument(invocation, 0)))), invocation -> EndpointResult.immediate(LuaValue.FALSE), @@ -54,7 +54,7 @@ public static void register() { ComputedEndpoints.register("computed:redstone", endpoint -> endpoint.method( "input", EndpointSignature.of(List.of(EndpointType.STRING), List.of(EndpointType.NUMBER)), - EndpointPolicy.computerThread(false, true), + EndpointPolicy.serverThread(false, true), invocation -> EndpointResult.immediate(LuaValue.valueOf( requireHost(invocation).redstoneInput(argument(invocation, 0)))), invocation -> EndpointResult.immediate(LuaValue.ZERO), @@ -62,7 +62,7 @@ public static void register() { .method( "comparator", EndpointSignature.of(List.of(EndpointType.STRING), List.of(EndpointType.NUMBER)), - EndpointPolicy.computerThread(false, true), + EndpointPolicy.serverThread(false, true), invocation -> EndpointResult.immediate(LuaValue.valueOf( requireHost(invocation).comparatorInput(argument(invocation, 0)))), invocation -> EndpointResult.immediate(LuaValue.ZERO), @@ -72,7 +72,7 @@ public static void register() { EndpointSignature.of( List.of(EndpointType.STRING, EndpointType.NUMBER), List.of()), - EndpointPolicy.computerThread(true, false), + EndpointPolicy.serverThread(true, false), invocation -> { requireHost(invocation).redstoneOutput( argument(invocation, 0), @@ -84,7 +84,7 @@ public static void register() { ComputedEndpoints.register("computed:command", endpoint -> endpoint.method( "run", EndpointSignature.of(List.of(EndpointType.STRING), List.of()), - EndpointPolicy.computerThread(true, false), + EndpointPolicy.serverThread(true, false), invocation -> { BuiltinEndpointHost host = requireHost(invocation); host.runCommand(invocation.arguments().getFirst().tojstring()); @@ -153,7 +153,7 @@ public static void register() { ComputedEndpoints.register("computed:monitor", endpoint -> endpoint.method( "show", EndpointSignature.of(List.of(EndpointType.TABLE), List.of()), - EndpointPolicy.computerThread(true, false), + EndpointPolicy.serverThread(true, false), invocation -> { requireHost(invocation).showWidgets( invocation.target(), diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointPolicy.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointPolicy.java index 58c9dc4..4a48267 100644 --- a/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointPolicy.java +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointPolicy.java @@ -10,12 +10,19 @@ public record EndpointPolicy( public EndpointPolicy { Objects.requireNonNull(executionSide, "executionSide"); + if (executionSide == ExecutionSide.SERVER_THREAD && !yielding) { + throw new IllegalArgumentException("Server-thread endpoints must yield during dispatch"); + } } public static EndpointPolicy computerThread(boolean sideEffect, boolean previewAvailable) { return new EndpointPolicy(ExecutionSide.COMPUTER_THREAD, false, sideEffect, previewAvailable); } + public static EndpointPolicy serverThread(boolean sideEffect, boolean previewAvailable) { + return new EndpointPolicy(ExecutionSide.SERVER_THREAD, true, sideEffect, previewAvailable); + } + public enum ExecutionSide { COMPUTER_THREAD, SERVER_THREAD diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/ServerEndpointExecutor.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/ServerEndpointExecutor.java new file mode 100644 index 0000000..d564a9f --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/ServerEndpointExecutor.java @@ -0,0 +1,9 @@ +package dev.propulsionteam.computed.lua.endpoint; + +import java.util.concurrent.Callable; +import java.util.concurrent.CompletionStage; + +public interface ServerEndpointExecutor { + CompletionStage submitServerEndpoint( + Callable endpointCall); +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaEndpointProxy.java b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaEndpointProxy.java index 7d2995d..3e86859 100644 --- a/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaEndpointProxy.java +++ b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaEndpointProxy.java @@ -4,11 +4,15 @@ import dev.propulsionteam.computed.lua.endpoint.EndpointDefinition; import dev.propulsionteam.computed.lua.endpoint.EndpointInvocation; import dev.propulsionteam.computed.lua.endpoint.EndpointMethod; +import dev.propulsionteam.computed.lua.endpoint.EndpointPolicy; import dev.propulsionteam.computed.lua.endpoint.EndpointResult; import dev.propulsionteam.computed.lua.endpoint.EndpointType; +import dev.propulsionteam.computed.lua.endpoint.ServerEndpointExecutor; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.luaj.vm2.LuaError; import org.luaj.vm2.LuaTable; import org.luaj.vm2.LuaValue; @@ -102,7 +106,12 @@ private static Varargs call( if (!method.policy().yielding()) { throw new LuaError("Endpoint returned a continuation but is not declared yielding"); } - pending.yieldFor(yielded.continuation()); + CompletionStage continuation = + yielded.continuation().thenApply(immediate -> { + validateReturns(method, immediate.values()); + return immediate; + }); + pending.yieldFor(continuation); yield pending.sandbox().globals().yield(LuaValue.NIL); } }; @@ -119,6 +128,11 @@ private static EndpointResult invoke( } return method.previewFixture().apply(invocation); } + + if (method.policy().executionSide() == EndpointPolicy.ExecutionSide.SERVER_THREAD) { + return dispatchToServer(method, invocation); + } + return method.handler().invoke(invocation); } catch (LuaError error) { throw error; @@ -127,6 +141,31 @@ private static EndpointResult invoke( } } + private static EndpointResult dispatchToServer( + EndpointMethod method, + EndpointInvocation invocation) { + if (!(invocation.host() instanceof ServerEndpointExecutor executor)) { + return EndpointResult.unavailable( + "Endpoint host cannot execute server-thread methods"); + } + + CompletionStage continuation = + executor.submitServerEndpoint(() -> method.handler().invoke(invocation)) + .thenCompose(LuaEndpointProxy::flattenServerResult); + return EndpointResult.yielded(continuation); + } + + private static CompletionStage flattenServerResult( + EndpointResult result) { + return switch (result) { + case EndpointResult.Immediate immediate -> + CompletableFuture.completedFuture(immediate); + case EndpointResult.Yielded yielded -> yielded.continuation(); + case EndpointResult.Unavailable unavailable -> + CompletableFuture.failedFuture(new LuaError(unavailable.reason())); + }; + } + private static void validateArguments(EndpointMethod method, List arguments) { List expected = method.signature().arguments(); if (!method.signature().variadic() && arguments.size() != expected.size()) { diff --git a/src/main/java/dev/propulsionteam/computed/lua/runtime/PendingLuaInvocation.java b/src/main/java/dev/propulsionteam/computed/lua/runtime/PendingLuaInvocation.java index 482465c..df7e120 100644 --- a/src/main/java/dev/propulsionteam/computed/lua/runtime/PendingLuaInvocation.java +++ b/src/main/java/dev/propulsionteam/computed/lua/runtime/PendingLuaInvocation.java @@ -5,7 +5,9 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; import java.util.function.BiConsumer; import org.luaj.vm2.LuaValue; @@ -77,9 +79,27 @@ Varargs start(List eventArguments) { Varargs resume() { CompletableFuture future = continuation.toCompletableFuture(); - EndpointResult.Immediate result = future.join(); - continuation = null; - return worker.resume(result.values()); + try { + EndpointResult.Immediate result = future.join(); + continuation = null; + return worker.resume(result.values()); + } catch (CancellationException exception) { + continuation = null; + throw new org.luaj.vm2.LuaError("Endpoint call was cancelled"); + } catch (CompletionException exception) { + continuation = null; + Throwable cause = exception.getCause(); + if (cause instanceof org.luaj.vm2.LuaError luaError) { + throw luaError; + } + if (cause instanceof CancellationException) { + throw new org.luaj.vm2.LuaError("Endpoint call was cancelled"); + } + String message = cause == null || cause.getMessage() == null + ? "Endpoint call failed" + : "Endpoint call failed: " + cause.getMessage(); + throw new org.luaj.vm2.LuaError(message); + } } void yieldFor(CompletionStage continuation) { diff --git a/src/test/java/dev/propulsionteam/computed/graph/LuaGraphSchedulerTest.java b/src/test/java/dev/propulsionteam/computed/graph/LuaGraphSchedulerTest.java index 7ff985a..3642aec 100644 --- a/src/test/java/dev/propulsionteam/computed/graph/LuaGraphSchedulerTest.java +++ b/src/test/java/dev/propulsionteam/computed/graph/LuaGraphSchedulerTest.java @@ -6,14 +6,20 @@ import dev.propulsionteam.computed.lua.endpoint.BuiltinEndpointHost; import dev.propulsionteam.computed.lua.endpoint.BuiltinWidget; +import dev.propulsionteam.computed.lua.endpoint.EndpointResult; +import dev.propulsionteam.computed.lua.endpoint.ServerEndpointExecutor; import dev.propulsionteam.computed.lua.node.BundledLuaLibrary; import dev.propulsionteam.computed.lua.node.ConnectionType; import dev.propulsionteam.computed.lua.runtime.LuaStateCodec; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.junit.jupiter.api.Test; import org.luaj.vm2.LuaValue; @@ -120,6 +126,9 @@ void usesPreviewFixturesAndProductionEndpointHosts() { LuaGraphScheduler scheduler = new LuaGraphScheduler(program, uuid(202), host); assertEquals(6000.0, scheduler.tick(true).outputs().get(nodeId).get("time").todouble()); + scheduler.tick(false); + assertTrue(host.hasTasks()); + host.runNext(); assertEquals(18000.0, scheduler.tick(false).outputs().get(nodeId).get("time").todouble()); } @@ -168,7 +177,14 @@ void sendsClockWidgetToMonitorEndpoint() { null); Host host = new Host(); - LuaGraphTickResult result = new LuaGraphScheduler(program, uuid(206), host).tick(false); + LuaGraphScheduler scheduler = new LuaGraphScheduler(program, uuid(206), host); + LuaGraphTickResult yielded = scheduler.tick(false); + + assertTrue(yielded.diagnostics().isEmpty()); + assertTrue(host.monitorTargets.isEmpty()); + assertTrue(host.hasTasks()); + host.runNext(); + LuaGraphTickResult result = scheduler.tick(false); assertTrue(result.diagnostics().isEmpty()); assertEquals(List.of("front"), host.monitorTargets); @@ -367,7 +383,8 @@ private static UUID uuid(long value) { return new UUID(0, value); } - private static final class Host implements BuiltinEndpointHost { + private static final class Host implements BuiltinEndpointHost, ServerEndpointExecutor { + private final ArrayDeque tasks = new ArrayDeque<>(); private final List commands = new ArrayList<>(); private final List monitorTargets = new ArrayList<>(); private final List> monitorWidgets = new ArrayList<>(); @@ -387,5 +404,35 @@ public void showWidgets(String target, List widgets) { monitorTargets.add(target); monitorWidgets.add(widgets); } + + @Override + public CompletionStage submitServerEndpoint( + Callable endpointCall) { + CompletableFuture result = new CompletableFuture<>(); + synchronized (tasks) { + tasks.addLast(() -> { + try { + result.complete(endpointCall.call()); + } catch (Exception exception) { + result.completeExceptionally(exception); + } + }); + } + return result; + } + + boolean hasTasks() { + synchronized (tasks) { + return !tasks.isEmpty(); + } + } + + void runNext() { + Runnable task; + synchronized (tasks) { + task = tasks.removeFirst(); + } + task.run(); + } } } diff --git a/src/test/java/dev/propulsionteam/computed/lua/node/BundledLuaLibraryTest.java b/src/test/java/dev/propulsionteam/computed/lua/node/BundledLuaLibraryTest.java index 76f2ba1..75222b3 100644 --- a/src/test/java/dev/propulsionteam/computed/lua/node/BundledLuaLibraryTest.java +++ b/src/test/java/dev/propulsionteam/computed/lua/node/BundledLuaLibraryTest.java @@ -7,13 +7,22 @@ import dev.propulsionteam.computed.lua.endpoint.BuiltinEndpointHost; import dev.propulsionteam.computed.lua.endpoint.BuiltinEndpoints; import dev.propulsionteam.computed.lua.endpoint.BuiltinWidget; +import dev.propulsionteam.computed.lua.endpoint.EndpointResult; +import dev.propulsionteam.computed.lua.endpoint.ServerEndpointExecutor; import dev.propulsionteam.computed.lua.runtime.LuaComputerRuntime; +import dev.propulsionteam.computed.lua.runtime.LuaInvocationResult; +import dev.propulsionteam.computed.lua.runtime.LuaNodeInstance; import dev.propulsionteam.computed.lua.runtime.LuaNodeStatus; import dev.propulsionteam.computed.lua.sandbox.LuaInstructionBudget; import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.junit.jupiter.api.Test; class BundledLuaLibraryTest { @@ -35,43 +44,7 @@ void everyBundledDefinitionCompilesAndReturnsItsRegisteredId() { @Test void everyBundledDefinitionRunsWithItsDefaults() { BuiltinEndpoints.register(); - var host = new BuiltinEndpointHost() { - @Override - public double worldTime() { - return 6000; - } - - @Override - public double[] position() { - return new double[] {0.5, 64.5, 0.5}; - } - - @Override - public double[] rotation() { - return new double[] {0, 0, 0}; - } - - @Override - public int redstoneInput(String face) { - return 0; - } - - @Override - public int comparatorInput(String face) { - return 0; - } - - @Override - public boolean blockPresent(String face) { - return false; - } - - @Override - public void redstoneOutput(String face, int level) {} - - @Override - public void runCommand(String command) {} - }; + QueuedBuiltinHost host = new QueuedBuiltinHost(); var runtime = new LuaComputerRuntime(UUID.randomUUID(), new LuaInstructionBudget(), host); runtime.beginTick(1); @@ -81,32 +54,48 @@ public void runCommand(String command) {} instance.definition().inputs().forEach(port -> inputs.put(port.id(), port.defaultValue())); var fields = new LinkedHashMap(); instance.definition().fields().forEach(field -> fields.put(field.id(), field.defaultValue())); - var result = instance.run(inputs, fields, 1, runtime.nextGraphStep(), false, (name, values) -> {}); + LuaInvocationResult result = + instance.run(inputs, fields, 1, runtime.nextGraphStep(), false, (name, values) -> {}); + result = completeQueuedInvocation(host, instance, result); assertEquals(LuaNodeStatus.IDLE, result.status(), id + ": " + result.diagnostics()); assertEquals(List.of(), result.diagnostics(), id); }); } @Test - void monitorDefinitionCallsItsProductionEndpoint() { + void redstoneOutputRunsOnlyAfterServerDispatch() { BuiltinEndpoints.register(); - var calls = new java.util.ArrayList>(); - var targets = new java.util.ArrayList(); - var host = new BuiltinEndpointHost() { - @Override - public double worldTime() { - return 0; - } + QueuedBuiltinHost host = new QueuedBuiltinHost(); + var runtime = new LuaComputerRuntime(UUID.randomUUID(), new LuaInstructionBudget(), host); + runtime.beginTick(1); + var source = BundledLuaLibrary.load().get("computed:redstone_emitter"); + LuaNodeInstance instance = + runtime.createNode(UUID.randomUUID(), source.apiVersion(), source.source()); + var inputs = new LinkedHashMap(); + instance.definition().inputs().forEach(port -> inputs.put(port.id(), port.defaultValue())); + inputs.put("trigger", org.luaj.vm2.LuaValue.TRUE); + inputs.put("level", org.luaj.vm2.LuaValue.valueOf(15)); + var fields = new LinkedHashMap(); + instance.definition().fields().forEach(field -> fields.put(field.id(), field.defaultValue())); - @Override - public void runCommand(String command) {} + LuaInvocationResult yielded = + instance.run(inputs, fields, 1, runtime.nextGraphStep(), false, null); - @Override - public void showWidgets(String target, List widgets) { - targets.add(target); - calls.add(widgets); - } - }; + assertEquals(LuaNodeStatus.YIELDED, yielded.status()); + assertTrue(host.redstoneLevels.isEmpty()); + + host.runNext(); + LuaInvocationResult resumed = instance.resumeIfReady(); + + assertEquals(LuaNodeStatus.IDLE, resumed.status()); + assertEquals(List.of(15), host.redstoneLevels); + assertTrue(!host.redstoneThread.startsWith("Coroutine-")); + } + + @Test + void monitorDefinitionCallsItsProductionEndpoint() { + BuiltinEndpoints.register(); + QueuedBuiltinHost host = new QueuedBuiltinHost(); var source = BundledLuaLibrary.load().get("computed:peripheral"); var runtime = new LuaComputerRuntime(UUID.randomUUID(), new LuaInstructionBudget(), host); runtime.beginTick(1); @@ -126,13 +115,15 @@ public void showWidgets(String target, List widgets) { var fields = new LinkedHashMap(); instance.definition().fields().forEach(field -> fields.put(field.id(), field.defaultValue())); - var result = instance.run(inputs, fields, 1, runtime.nextGraphStep(), false, (name, values) -> {}); + LuaInvocationResult result = + instance.run(inputs, fields, 1, runtime.nextGraphStep(), false, (name, values) -> {}); + result = completeQueuedInvocation(host, instance, result); assertEquals(LuaNodeStatus.IDLE, result.status()); - assertEquals(List.of("front"), targets); - assertEquals(1, calls.size()); - assertEquals(1, calls.getFirst().size()); - BuiltinWidget shown = calls.getFirst().getFirst(); + assertEquals(List.of("front"), host.monitorTargets); + assertEquals(1, host.monitorWidgets.size()); + assertEquals(1, host.monitorWidgets.getFirst().size()); + BuiltinWidget shown = host.monitorWidgets.getFirst().getFirst(); assertEquals(widgetId, shown.id()); assertEquals("text", shown.type()); assertEquals("Monitor output", shown.properties().get("text")); @@ -143,4 +134,79 @@ public void showWidgets(String target, List widgets) { assertEquals(1.0, shown.properties().get("span")); assertEquals("auto", shown.properties().get("fit")); } + + private static LuaInvocationResult completeQueuedInvocation( + QueuedBuiltinHost host, + LuaNodeInstance instance, + LuaInvocationResult initial) { + LuaInvocationResult result = initial; + for (int resumes = 0; result.status() == LuaNodeStatus.YIELDED; resumes++) { + if (resumes >= 16) { + throw new AssertionError("Bundled node exceeded the expected endpoint dispatch count"); + } + assertTrue(host.hasTasks(), "Yielded node did not queue a server endpoint"); + host.runNext(); + result = instance.resumeIfReady(); + } + return result; + } + + private static final class QueuedBuiltinHost + implements BuiltinEndpointHost, ServerEndpointExecutor { + private final ArrayDeque tasks = new ArrayDeque<>(); + private final List redstoneLevels = new ArrayList<>(); + private final List monitorTargets = new ArrayList<>(); + private final List> monitorWidgets = new ArrayList<>(); + private String redstoneThread; + + @Override + public double worldTime() { + return 6000; + } + + @Override + public void redstoneOutput(String face, int level) { + redstoneThread = Thread.currentThread().getName(); + redstoneLevels.add(level); + } + + @Override + public void showWidgets(String target, List widgets) { + monitorTargets.add(target); + monitorWidgets.add(widgets); + } + + @Override + public void runCommand(String command) {} + + @Override + public CompletionStage submitServerEndpoint( + Callable endpointCall) { + CompletableFuture result = new CompletableFuture<>(); + synchronized (tasks) { + tasks.addLast(() -> { + try { + result.complete(endpointCall.call()); + } catch (Exception exception) { + result.completeExceptionally(exception); + } + }); + } + return result; + } + + boolean hasTasks() { + synchronized (tasks) { + return !tasks.isEmpty(); + } + } + + void runNext() { + Runnable task; + synchronized (tasks) { + task = tasks.removeFirst(); + } + task.run(); + } + } } diff --git a/src/test/java/dev/propulsionteam/computed/lua/runtime/ServerEndpointDispatchTest.java b/src/test/java/dev/propulsionteam/computed/lua/runtime/ServerEndpointDispatchTest.java new file mode 100644 index 0000000..1ebe169 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/lua/runtime/ServerEndpointDispatchTest.java @@ -0,0 +1,214 @@ +package dev.propulsionteam.computed.lua.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.lua.endpoint.ComputedEndpoints; +import dev.propulsionteam.computed.lua.endpoint.EndpointPolicy; +import dev.propulsionteam.computed.lua.endpoint.EndpointResult; +import dev.propulsionteam.computed.lua.endpoint.EndpointSignature; +import dev.propulsionteam.computed.lua.endpoint.EndpointType; +import dev.propulsionteam.computed.lua.endpoint.ServerEndpointExecutor; +import dev.propulsionteam.computed.lua.sandbox.LuaInstructionBudget; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.luaj.vm2.LuaValue; + +class ServerEndpointDispatchTest { + @Test + void dispatchesServerEndpointWithoutBlockingTheLuaCoroutine() { + String endpointId = endpointId(); + AtomicReference handlerThread = new AtomicReference<>(); + ComputedEndpoints.register(endpointId, endpoint -> endpoint.method( + "read", + EndpointSignature.of(List.of(), List.of(EndpointType.NUMBER)), + EndpointPolicy.serverThread(false, false), + invocation -> { + handlerThread.set(Thread.currentThread().getName()); + return EndpointResult.immediate(LuaValue.valueOf(7)); + }, + null, + "Test server-thread dispatch.")); + QueuedServerHost host = new QueuedServerHost(); + LuaComputerRuntime runtime = + new LuaComputerRuntime(UUID.randomUUID(), new LuaInstructionBudget(), host); + LuaNodeInstance node = createNode(runtime, endpointId); + runtime.beginTick(1); + + LuaInvocationResult first = + node.run(Map.of(), Map.of(), 1, runtime.nextGraphStep(), false, null); + + assertEquals(LuaNodeStatus.YIELDED, first.status()); + assertNull(handlerThread.get()); + assertTrue(host.hasTasks()); + + host.runNext(); + assertFalse(handlerThread.get().startsWith("Coroutine-")); + + runtime.beginTick(2); + LuaInvocationResult resumed = node.resumeIfReady(); + + assertEquals(LuaNodeStatus.IDLE, resumed.status()); + assertEquals(7, resumed.outputs().get("value").checkint()); + } + + @Test + void flattensAContinuationReturnedByTheServerHandler() { + String endpointId = endpointId(); + CompletableFuture nested = new CompletableFuture<>(); + ComputedEndpoints.register(endpointId, endpoint -> endpoint.method( + "read", + EndpointSignature.of(List.of(), List.of(EndpointType.NUMBER)), + EndpointPolicy.serverThread(false, false), + invocation -> EndpointResult.yielded(nested), + null, + "Test nested server continuation.")); + QueuedServerHost host = new QueuedServerHost(); + LuaComputerRuntime runtime = + new LuaComputerRuntime(UUID.randomUUID(), new LuaInstructionBudget(), host); + LuaNodeInstance node = createNode(runtime, endpointId); + runtime.beginTick(1); + + LuaInvocationResult first = + node.run(Map.of(), Map.of(), 1, runtime.nextGraphStep(), false, null); + host.runNext(); + + assertEquals(LuaNodeStatus.YIELDED, first.status()); + assertEquals(LuaNodeStatus.YIELDED, node.resumeIfReady().status()); + + nested.complete(EndpointResult.immediate(LuaValue.valueOf(11))); + LuaInvocationResult resumed = node.resumeIfReady(); + + assertEquals(LuaNodeStatus.IDLE, resumed.status()); + assertEquals(11, resumed.outputs().get("value").checkint()); + } + + @Test + void reportsServerHandlerFailuresAsRuntimeDiagnostics() { + String endpointId = endpointId(); + ComputedEndpoints.register(endpointId, endpoint -> endpoint.method( + "read", + EndpointSignature.of(List.of(), List.of(EndpointType.NUMBER)), + EndpointPolicy.serverThread(false, false), + invocation -> { + throw new IllegalStateException("server handler failed"); + }, + null, + "Test server failure.")); + QueuedServerHost host = new QueuedServerHost(); + LuaComputerRuntime runtime = + new LuaComputerRuntime(UUID.randomUUID(), new LuaInstructionBudget(), host); + LuaNodeInstance node = createNode(runtime, endpointId); + runtime.beginTick(1); + + LuaInvocationResult first = + node.run(Map.of(), Map.of(), 1, runtime.nextGraphStep(), false, null); + host.runNext(); + LuaInvocationResult resumed = node.resumeIfReady(); + + assertEquals(LuaNodeStatus.YIELDED, first.status()); + assertEquals(LuaNodeStatus.FAILED, resumed.status()); + assertTrue(resumed.diagnostics().getFirst().message().contains("server handler failed")); + } + + @Test + void reportsCancelledServerContinuationsAsRuntimeDiagnostics() { + String endpointId = endpointId(); + CompletableFuture nested = new CompletableFuture<>(); + ComputedEndpoints.register(endpointId, endpoint -> endpoint.method( + "read", + EndpointSignature.of(List.of(), List.of(EndpointType.NUMBER)), + EndpointPolicy.serverThread(false, false), + invocation -> EndpointResult.yielded(nested), + null, + "Test server cancellation.")); + QueuedServerHost host = new QueuedServerHost(); + LuaComputerRuntime runtime = + new LuaComputerRuntime(UUID.randomUUID(), new LuaInstructionBudget(), host); + LuaNodeInstance node = createNode(runtime, endpointId); + runtime.beginTick(1); + + LuaInvocationResult first = + node.run(Map.of(), Map.of(), 1, runtime.nextGraphStep(), false, null); + host.runNext(); + nested.cancel(false); + LuaInvocationResult resumed = node.resumeIfReady(); + + assertEquals(LuaNodeStatus.YIELDED, first.status()); + assertEquals(LuaNodeStatus.FAILED, resumed.status()); + assertTrue(resumed.diagnostics().getFirst().message().contains("cancelled")); + } + + @Test + void rejectsServerPoliciesThatDoNotYield() { + assertThrows( + IllegalArgumentException.class, + () -> new EndpointPolicy( + EndpointPolicy.ExecutionSide.SERVER_THREAD, + false, + false, + false)); + } + + private static LuaNodeInstance createNode( + LuaComputerRuntime runtime, + String endpointId) { + return runtime.createNode(UUID.randomUUID(), 1, """ + local node = computed.node(1, "test:server_node", "Server Node") + node:output("value", "number") + node:on_run(function(ctx) + local value = ctx:endpoint("%s"):call("read") + ctx:output("value", value) + end) + return node + """.formatted(endpointId)); + } + + private static String endpointId() { + return "test:server_dispatch_" + UUID.randomUUID().toString().replace("-", ""); + } + + private static final class QueuedServerHost implements ServerEndpointExecutor { + private final ArrayDeque tasks = new ArrayDeque<>(); + + @Override + public CompletionStage submitServerEndpoint( + Callable endpointCall) { + CompletableFuture result = new CompletableFuture<>(); + synchronized (tasks) { + tasks.addLast(() -> { + try { + result.complete(endpointCall.call()); + } catch (Exception exception) { + result.completeExceptionally(exception); + } + }); + } + return result; + } + + boolean hasTasks() { + synchronized (tasks) { + return !tasks.isEmpty(); + } + } + + void runNext() { + Runnable task; + synchronized (tasks) { + task = tasks.removeFirst(); + } + task.run(); + } + } +} From fc8e6a048e243a419f6f38dd84e0067c7f6568d2 Mon Sep 17 00:00:00 2001 From: Rieno Date: Tue, 28 Jul 2026 00:46:56 +0100 Subject: [PATCH 2/2] Text Widget - Added missing color input parameter --- .../computed/lua/endpoint/BuiltinEndpoints.java | 14 +++++++++++--- .../resources/computed/lua/nodes/widgets/text.lua | 4 +++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinEndpoints.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinEndpoints.java index 6d32472..7dec26e 100644 --- a/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinEndpoints.java +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinEndpoints.java @@ -94,16 +94,24 @@ public static void register() { "Runs a command through the computer host.")); ComputedEndpoints.register("computed:widget", endpoint -> endpoint.method( "text", - EndpointSignature.of(List.of(EndpointType.STRING), List.of(EndpointType.TABLE)), + EndpointSignature.of( + List.of(EndpointType.STRING, EndpointType.NUMBER), + List.of(EndpointType.TABLE)), EndpointPolicy.computerThread(false, true), invocation -> EndpointResult.immediate(widget( invocation.nodeId(), "text", - Map.of("text", argument(invocation, 0), "alignment", "left"))), + Map.of( + "text", argument(invocation, 0), + "color", invocation.arguments().get(1).checkint(), + "alignment", "left"))), invocation -> EndpointResult.immediate(widget( invocation.nodeId(), "text", - Map.of("text", argument(invocation, 0), "alignment", "left"))), + Map.of( + "text", argument(invocation, 0), + "color", invocation.arguments().get(1).checkint(), + "alignment", "left"))), "Creates a text widget value.") .method( "clock", diff --git a/src/main/resources/computed/lua/nodes/widgets/text.lua b/src/main/resources/computed/lua/nodes/widgets/text.lua index f4aa15e..5336170 100644 --- a/src/main/resources/computed/lua/nodes/widgets/text.lua +++ b/src/main/resources/computed/lua/nodes/widgets/text.lua @@ -2,6 +2,7 @@ local node = computed.node(1, "computed:text_widget", "Text Widget") node:category("widgets") node:input("text", "string", { default = "" }) +node:input("color", "number", { default = 4294967295 }) node:field("layout_mode", "choice", { default = "line", label = "Layout", @@ -45,7 +46,8 @@ node:field("alignment", "choice", { node:output("widget", "widget") node:on_run(function(ctx) local widgets = ctx:endpoint("computed:widget") - local widget = widgets:call("text", ctx:input("text")) + local widget = widgets:call("text", ctx:input("text"), + ctx:input("color")) widget.x = math.floor(ctx:field("x")) widget.y = math.floor(ctx:field("y")) widget.width = math.max(1, math.floor(ctx:field("width")))