1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2024-12-14 04:00:30 +00:00

Add a test harness for ComputerThread

Geesh, this is nasty. Because ComputerThread is incredibly stateful, and
we want to run tests in isolation, we run each test inside its own
isolated ClassLoader (and thus ComputerThread instance).

Everything else is less nasty, though still a bit ... yuck. We also
define a custom ILuaMachine which just runs lambdas[^1], and some
utilities for starting those.

This is then tied together for four very basic tests. This is sufficient
for the changes I want to make, but might be nice to test some more
comprehensive stuff later on (e.g. timeouts after pausing).

[^1]: Which also means the ILuaMachine implementation can be changed by
other mods[^2], if someone wants to have another stab at LuaJIT :p.

[^2]: In theory. I doubt its possible in practice because so much is
package private.
This commit is contained in:
Jonathan Coates 2022-05-03 22:58:28 +01:00
parent 7ad6132494
commit 6322e72110
No known key found for this signature in database
GPG Key ID: B9E431FF07C98D06
12 changed files with 522 additions and 28 deletions

View File

@ -53,6 +53,7 @@ import java.util.concurrent.locks.ReentrantLock;
*/ */
final class ComputerExecutor final class ComputerExecutor
{ {
static ILuaMachine.Factory luaFactory = CobaltLuaMachine::new;
private static final int QUEUE_LIMIT = 256; private static final int QUEUE_LIMIT = 256;
private final Computer computer; private final Computer computer;
@ -400,7 +401,7 @@ final class ComputerExecutor
} }
// Create the lua machine // Create the lua machine
ILuaMachine machine = new CobaltLuaMachine( computer, timeout ); ILuaMachine machine = luaFactory.create( computer, timeout );
// Add the APIs. We unwrap them (yes, this is horrible) to get access to the underlying object. // Add the APIs. We unwrap them (yes, this is horrible) to get access to the underlying object.
for( ILuaAPI api : apis ) machine.addAPI( api instanceof ApiWrapper ? ((ApiWrapper) api).getDelegate() : api ); for( ILuaAPI api : apis ) machine.addAPI( api instanceof ApiWrapper ? ((ApiWrapper) api).getDelegate() : api );

View File

@ -7,6 +7,8 @@ package dan200.computercraft.core.lua;
import dan200.computercraft.api.lua.IDynamicLuaObject; import dan200.computercraft.api.lua.IDynamicLuaObject;
import dan200.computercraft.api.lua.ILuaAPI; import dan200.computercraft.api.lua.ILuaAPI;
import dan200.computercraft.core.computer.Computer;
import dan200.computercraft.core.computer.TimeoutState;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import javax.annotation.Nullable; import javax.annotation.Nullable;
@ -63,4 +65,9 @@ public interface ILuaMachine
* Close the Lua machine, aborting any running functions and deleting the internal state. * Close the Lua machine, aborting any running functions and deleting the internal state.
*/ */
void close(); void close();
interface Factory
{
ILuaMachine create( Computer computer, TimeoutState timeout );
}
} }

View File

@ -18,7 +18,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import static dan200.computercraft.ContramapMatcher.contramap; import static dan200.computercraft.support.ContramapMatcher.contramap;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*; import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;

View File

@ -0,0 +1,107 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer;
import dan200.computercraft.ComputerCraft;
import dan200.computercraft.core.lua.MachineResult;
import dan200.computercraft.support.ConcurrentHelpers;
import dan200.computercraft.support.IsolatedRunner;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import java.util.concurrent.TimeUnit;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.closeTo;
import static org.junit.jupiter.api.Assertions.*;
@Timeout( value = 15 )
@ExtendWith( IsolatedRunner.class )
@Execution( ExecutionMode.CONCURRENT )
public class ComputerThreadTest
{
@Test
public void testSoftAbort() throws Exception
{
Computer computer = FakeComputerManager.create();
FakeComputerManager.enqueue( computer, timeout -> {
assertFalse( timeout.isSoftAborted(), "Should not start soft-aborted" );
long delay = ConcurrentHelpers.waitUntil( () -> {
timeout.refresh();
return timeout.isSoftAborted();
} );
assertThat( "Should be soft aborted", delay * 1e-9, closeTo( 7, 0.5 ) );
ComputerCraft.log.info( "Slept for {}", delay );
computer.shutdown();
return MachineResult.OK;
} );
FakeComputerManager.startAndWait( computer );
}
@Test
public void testHardAbort() throws Exception
{
Computer computer = FakeComputerManager.create();
FakeComputerManager.enqueue( computer, timeout -> {
assertFalse( timeout.isHardAborted(), "Should not start soft-aborted" );
assertThrows( InterruptedException.class, () -> Thread.sleep( 11_000 ), "Sleep should be hard aborted" );
assertTrue( timeout.isHardAborted(), "Thread should be hard aborted" );
computer.shutdown();
return MachineResult.OK;
} );
FakeComputerManager.startAndWait( computer );
}
@Test
public void testNoPauseIfNoOtherMachines() throws Exception
{
Computer computer = FakeComputerManager.create();
FakeComputerManager.enqueue( computer, timeout -> {
boolean didPause = ConcurrentHelpers.waitUntil( () -> {
timeout.refresh();
return timeout.isPaused();
}, 5, TimeUnit.SECONDS );
assertFalse( didPause, "Machine shouldn't have paused within 5s" );
computer.shutdown();
return MachineResult.OK;
} );
FakeComputerManager.startAndWait( computer );
}
@Test
public void testPauseIfSomeOtherMachine() throws Exception
{
Computer computer = FakeComputerManager.create();
FakeComputerManager.enqueue( computer, timeout -> {
long budget = ComputerThread.scaledPeriod();
assertEquals( budget, TimeUnit.MILLISECONDS.toNanos( 25 ), "Budget should be 25ms" );
long delay = ConcurrentHelpers.waitUntil( () -> {
timeout.refresh();
return timeout.isPaused();
} );
assertThat( "Paused within 25ms", delay * 1e-9, closeTo( 0.025, 0.01 ) );
computer.shutdown();
return MachineResult.OK;
} );
FakeComputerManager.createLoopingComputer();
FakeComputerManager.startAndWait( computer );
}
}

View File

@ -0,0 +1,219 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer;
import dan200.computercraft.api.lua.ILuaAPI;
import dan200.computercraft.core.lua.ILuaMachine;
import dan200.computercraft.core.lua.MachineResult;
import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.support.IsolatedRunner;
import org.jetbrains.annotations.Nullable;
import javax.annotation.Nonnull;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* Creates "fake" computers, which just run user-defined tasks rather than Lua code.
*
* Note, this will clobber some parts of the global state. It's recommended you use this inside an {@link IsolatedRunner}.
*/
public class FakeComputerManager
{
interface Task
{
MachineResult run( TimeoutState state ) throws Exception;
}
private static final Map<Computer, Queue<Task>> machines = new HashMap<>();
private static final Lock errorLock = new ReentrantLock();
private static final Condition hasError = errorLock.newCondition();
private static volatile Throwable error;
static
{
ComputerExecutor.luaFactory = ( computer, timeout ) -> new DummyLuaMachine( timeout, machines.get( computer ) );
}
/**
* Create a new computer which pulls from our task queue.
*
* @return The computer. This will not be started yet, you must call {@link Computer#turnOn()} and
* {@link Computer#tick()} to do so.
*/
@Nonnull
public static Computer create()
{
Computer computer = new Computer( new BasicEnvironment(), new Terminal( 51, 19 ), 0 );
machines.put( computer, new ConcurrentLinkedQueue<>() );
return computer;
}
/**
* Create and start a new computer which loops forever.
*/
public static void createLoopingComputer()
{
Computer computer = create();
enqueueForever( computer, t -> {
Thread.sleep( 100 );
return MachineResult.OK;
} );
computer.turnOn();
computer.tick();
}
/**
* Enqueue a task on a computer.
*
* @param computer The computer to enqueue the work on.
* @param task The task to run.
*/
public static void enqueue( @Nonnull Computer computer, @Nonnull Task task )
{
machines.get( computer ).offer( task );
}
/**
* Enqueue a repeated task on a computer. This is automatically requeued when the task finishes, meaning the task
* queue is never empty.
*
* @param computer The computer to enqueue the work on.
* @param task The task to run.
*/
private static void enqueueForever( @Nonnull Computer computer, @Nonnull Task task )
{
machines.get( computer ).offer( t -> {
MachineResult result = task.run( t );
enqueueForever( computer, task );
computer.queueEvent( "some_event", null );
return result;
} );
}
/**
* Sleep for a given period, immediately propagating any exceptions thrown by a computer.
*
* @param delay The duration to sleep for.
* @param unit The time unit the duration is measured in.
* @throws Exception An exception thrown by a running computer.
*/
public static void sleep( long delay, TimeUnit unit ) throws Exception
{
errorLock.lock();
try
{
rethrowIfNeeded();
if( hasError.await( delay, unit ) ) rethrowIfNeeded();
}
finally
{
errorLock.unlock();
}
}
/**
* Start a computer and wait for it to finish.
*
* @param computer The computer to wait for.
* @throws Exception An exception thrown by a running computer.
*/
public static void startAndWait( Computer computer ) throws Exception
{
computer.turnOn();
computer.tick();
do
{
sleep( 100, TimeUnit.MILLISECONDS );
} while( ComputerThread.hasPendingWork() || computer.isOn() );
rethrowIfNeeded();
}
private static void rethrowIfNeeded() throws Exception
{
if( error == null ) return;
if( error instanceof Exception ) throw (Exception) error;
if( error instanceof Error ) throw (Error) error;
rethrow( error );
}
@SuppressWarnings( "unchecked" )
private static <T extends Throwable> void rethrow( Throwable e ) throws T
{
throw (T) e;
}
private static class DummyLuaMachine implements ILuaMachine
{
private final TimeoutState state;
private final Queue<Task> handleEvent;
DummyLuaMachine( TimeoutState state, Queue<Task> handleEvent )
{
this.state = state;
this.handleEvent = handleEvent;
}
@Override
public void addAPI( @Nonnull ILuaAPI api )
{
}
@Override
public MachineResult loadBios( @Nonnull InputStream bios )
{
return MachineResult.OK;
}
@Override
public MachineResult handleEvent( @Nullable String eventName, @Nullable Object[] arguments )
{
try
{
return handleEvent.remove().run( state );
}
catch( Throwable e )
{
errorLock.lock();
try
{
if( error == null )
{
error = e;
hasError.signal();
}
else
{
error.addSuppressed( e );
}
}
finally
{
errorLock.unlock();
}
if( !(e instanceof Exception) && !(e instanceof AssertionError) ) rethrow( e );
return MachineResult.error( e.getMessage() );
}
}
@Override
public void close()
{
}
}
}

View File

@ -5,7 +5,7 @@
*/ */
package dan200.computercraft.core.terminal; package dan200.computercraft.core.terminal;
import dan200.computercraft.ContramapMatcher; import dan200.computercraft.support.ContramapMatcher;
import org.hamcrest.Matcher; import org.hamcrest.Matcher;
import org.hamcrest.Matchers; import org.hamcrest.Matchers;
@ -36,11 +36,11 @@ public class TerminalMatchers
public static Matcher<Terminal> linesMatchWith( String kind, LineProvider getLine, Matcher<String>[] lines ) public static Matcher<Terminal> linesMatchWith( String kind, LineProvider getLine, Matcher<String>[] lines )
{ {
return new ContramapMatcher<>( kind, terminal -> { return ContramapMatcher.contramap( Matchers.array( lines ), kind, terminal -> {
String[] termLines = new String[terminal.getHeight()]; String[] termLines = new String[terminal.getHeight()];
for( int i = 0; i < termLines.length; i++ ) termLines[i] = getLine.getLine( terminal, i ).toString(); for( int i = 0; i < termLines.length; i++ ) termLines[i] = getLine.getLine( terminal, i ).toString();
return termLines; return termLines;
}, Matchers.array( lines ) ); } );
} }
@FunctionalInterface @FunctionalInterface

View File

@ -7,7 +7,7 @@ package dan200.computercraft.core.terminal;
import dan200.computercraft.api.lua.LuaValues; import dan200.computercraft.api.lua.LuaValues;
import dan200.computercraft.shared.util.Colour; import dan200.computercraft.shared.util.Colour;
import dan200.computercraft.utils.CallCounter; import dan200.computercraft.support.CallCounter;
import io.netty.buffer.Unpooled; import io.netty.buffer.Unpooled;
import net.minecraft.nbt.CompoundNBT; import net.minecraft.nbt.CompoundNBT;
import net.minecraft.network.PacketBuffer; import net.minecraft.network.PacketBuffer;

View File

@ -3,7 +3,7 @@
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com * Send enquiries to dratcliffe@gmail.com
*/ */
package dan200.computercraft.utils; package dan200.computercraft.support;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;

View File

@ -0,0 +1,56 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.support;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.LockSupport;
import java.util.function.BooleanSupplier;
/**
* Utilities for working with concurrent systems.
*/
public class ConcurrentHelpers
{
private static final long DELAY = TimeUnit.MILLISECONDS.toNanos( 2 );
/**
* Wait until a condition is true, checking the condition every 2ms.
*
* @param isTrue The condition to check
* @return How long we waited for.
*/
public static long waitUntil( BooleanSupplier isTrue )
{
long start = System.nanoTime();
while( true )
{
if( isTrue.getAsBoolean() ) return System.nanoTime() - start;
LockSupport.parkNanos( DELAY );
}
}
/**
* Wait until a condition is true or a timeout is elapsed, checking the condition every 2ms.
*
* @param isTrue The condition to check
* @param timeout The delay after which we will timeout.
* @param unit The time unit the duration is measured in.
* @return {@literal true} if the condition was met, {@literal false} if we timed out instead.
*/
public static boolean waitUntil( BooleanSupplier isTrue, long timeout, TimeUnit unit )
{
long start = System.nanoTime();
long timeoutNs = unit.toNanos( timeout );
while( true )
{
long time = System.nanoTime() - start;
if( isTrue.getAsBoolean() ) return true;
if( time > timeoutNs ) return false;
LockSupport.parkNanos( DELAY );
}
}
}

View File

@ -3,42 +3,34 @@
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com * Send enquiries to dratcliffe@gmail.com
*/ */
package dan200.computercraft; package dan200.computercraft.support;
import org.hamcrest.Description; import org.hamcrest.FeatureMatcher;
import org.hamcrest.Matcher; import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeDiagnosingMatcher;
import java.util.function.Function; import java.util.function.Function;
public class ContramapMatcher<T, U> extends TypeSafeDiagnosingMatcher<T> /**
* Given some function from {@code T} to {@code U}, converts a {@code Matcher<U>} to {@code Matcher<T>}. This is useful
* when you want to match on a particular field (or some other projection) as part of a larger matcher.
*
* @param <T> The type of the object to be matched.
* @param <U> The type of the projection/field to be matched.
*/
public final class ContramapMatcher<T, U> extends FeatureMatcher<T, U>
{ {
private final String desc;
private final Function<T, U> convert; private final Function<T, U> convert;
private final Matcher<U> matcher;
public ContramapMatcher( String desc, Function<T, U> convert, Matcher<U> matcher ) public ContramapMatcher( String desc, Function<T, U> convert, Matcher<U> matcher )
{ {
this.desc = desc; super( matcher, desc, desc );
this.convert = convert; this.convert = convert;
this.matcher = matcher;
} }
@Override @Override
protected boolean matchesSafely( T item, Description mismatchDescription ) protected U featureValueOf( T actual )
{ {
U converted = convert.apply( item ); return convert.apply( actual );
if( matcher.matches( converted ) ) return true;
mismatchDescription.appendText( desc ).appendText( " " );
matcher.describeMismatch( converted, mismatchDescription );
return false;
}
@Override
public void describeTo( Description description )
{
description.appendText( desc ).appendText( " " ).appendDescriptionOf( matcher );
} }
public static <T, U> Matcher<T> contramap( Matcher<U> matcher, String desc, Function<T, U> convert ) public static <T, U> Matcher<T> contramap( Matcher<U> matcher, String desc, Function<T, U> convert )

View File

@ -0,0 +1,110 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.support;
import com.google.common.io.ByteStreams;
import net.minecraftforge.fml.unsafe.UnsafeHacks;
import org.junit.jupiter.api.extension.*;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.security.CodeSource;
import java.security.SecureClassLoader;
/**
* Runs a test method in an entirely isolated {@link ClassLoader}, so you can mess around with as much of
* {@link dan200.computercraft} as you like.
*
* This <strong>IS NOT</strong> a good idea, but helps us run some tests in parallel while having lots of (terrible)
* global state.
*/
public class IsolatedRunner implements InvocationInterceptor, BeforeEachCallback, AfterEachCallback
{
private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create( new Object() );
@Override
public void beforeEach( ExtensionContext context ) throws Exception
{
ClassLoader loader = context.getStore( NAMESPACE ).getOrComputeIfAbsent( IsolatedClassLoader.class );
// Rename the global thread group to something more obvious.
ThreadGroup group = (ThreadGroup) loader.loadClass( "dan200.computercraft.shared.util.ThreadUtils" ).getMethod( "group" ).invoke( null );
Field field = ThreadGroup.class.getDeclaredField( "name" );
UnsafeHacks.setField( field, group, "<" + context.getDisplayName() + ">" );
}
@Override
public void afterEach( ExtensionContext context ) throws Exception
{
ClassLoader loader = context.getStore( NAMESPACE ).get( IsolatedClassLoader.class, IsolatedClassLoader.class );
loader.loadClass( "dan200.computercraft.core.computer.ComputerThread" )
.getDeclaredMethod( "stop" )
.invoke( null );
}
@Override
public void interceptTestMethod( Invocation<Void> invocation, ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext ) throws Throwable
{
invocation.skip();
ClassLoader loader = extensionContext.getStore( NAMESPACE ).get( IsolatedClassLoader.class, IsolatedClassLoader.class );
Method method = invocationContext.getExecutable();
Class<?> ourClass = loader.loadClass( method.getDeclaringClass().getName() );
Method ourMethod = ourClass.getDeclaredMethod( method.getName(), method.getParameterTypes() );
try
{
ourMethod.invoke( ourClass.getConstructor().newInstance(), invocationContext.getArguments().toArray() );
}
catch( InvocationTargetException e )
{
throw e.getTargetException();
}
}
private static class IsolatedClassLoader extends SecureClassLoader
{
IsolatedClassLoader()
{
super( IsolatedClassLoader.class.getClassLoader() );
}
@Override
public Class<?> loadClass( String name, boolean resolve ) throws ClassNotFoundException
{
synchronized( getClassLoadingLock( name ) )
{
Class<?> c = findLoadedClass( name );
if( c != null ) return c;
if( name.startsWith( "dan200.computercraft." ) )
{
CodeSource parentSource = getParent().loadClass( name ).getProtectionDomain().getCodeSource();
byte[] contents;
try( InputStream stream = getResourceAsStream( name.replace( '.', '/' ) + ".class" ) )
{
if( stream == null ) throw new ClassNotFoundException( name );
contents = ByteStreams.toByteArray( stream );
}
catch( IOException e )
{
throw new ClassNotFoundException( name, e );
}
return defineClass( name, contents, 0, contents.length, parentSource );
}
}
return super.loadClass( name, resolve );
}
}
}

View File

@ -0,0 +1,2 @@
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.config.dynamic.factor=4