// Copyright © 2011-2014, Esko Luontola <www.orfjackal.net> // This software is released under the Apache License 2.0. // The license text is at http://www.apache.org/licenses/LICENSE-2.0 package fi.jumi.test; import fi.jumi.launcher.JumiLauncher; import fi.jumi.test.util.Threads; import org.hamcrest.Matcher; import org.junit.*; import org.junit.rules.Timeout; import java.lang.reflect.*; import java.net.*; import java.util.*; import static fi.jumi.test.util.CollectionMatchers.containsAtMost; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; public class ReleasingResourcesTest { @Rule public final AppRunner app = new AppRunner(); @Rule public final Timeout timeout = Timeouts.forEndToEndTest(); @Test public void launcher_stops_the_threads_it_started() throws Exception { ThreadGroup threadGroup = Thread.currentThread().getThreadGroup(); List<Thread> threadsBefore = Threads.getActiveThreads(threadGroup); startAndStopLauncher(); List<Thread> threadsAfter = ignoreThreadsWithName("Daemon Output Copier", // XXX: remove after we get rid of ProcessStartingDaemonSummoner.copyInBackground() removeAlmostDeadThreads( Threads.getActiveThreads(threadGroup))); assertThat(threadsAfter, containsAtMost(threadsBefore)); } private static List<Thread> removeAlmostDeadThreads(List<Thread> maybeDyingThreads) { // ThreadPoolExecutor.awaitTermination() waits only for a signal from the worker // threads that they have finished processing all commands, but not that the threads // are completely finished. There is a 0.01 probability of the thread being still // alive due to that race condition. ArrayList<Thread> aliveThreads = new ArrayList<>(); for (Thread thread : maybeDyingThreads) { try { thread.join(1); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } if (thread.isAlive()) { aliveThreads.add(thread); } } return aliveThreads; } private static List<Thread> ignoreThreadsWithName(String name, List<Thread> threads) { // Another option would be to wait for the threads to stop and ignore // those that stop quickly. But would want JumiLauncher.close() already // to do that waiting to fully close everything, so let's not do it that way here. List<Thread> results = new ArrayList<>(); for (Thread thread : threads) { if (thread.getName().equals(name)) { System.err.println("WARN: Ignoring thread " + thread); } else { results.add(thread); } } return results; } @Test public void launcher_closes_all_server_sockets_it_opened() throws Exception { List<SocketImpl> serverSockets = Collections.synchronizedList(new ArrayList<SocketImpl>()); ServerSocket.setSocketFactory(new SpySocketImplFactory(serverSockets)); startAndStopLauncher(); assertThat("expected the launcher to open server sockets", serverSockets, not(hasSize(0))); for (SocketImpl impl : serverSockets) { assertIsClosed(getServerSocket(impl)); } } /** * Even though the launcher does not directly open client connections, when somebody (i.e. the daemon) connects to a * server socket, the server socket creates a client socket is to handle that connection. */ @Test public void launcher_closes_all_client_sockets_it_opened() throws Exception { List<SocketImpl> clientSockets = Collections.synchronizedList(new ArrayList<SocketImpl>()); Socket.setSocketImplFactory(new SpySocketImplFactory(clientSockets)); startAndStopLauncher(); ignoreUnconnectedSockets(clientSockets); assertThat("expected the launcher to open client sockets", clientSockets, not(hasSize(0))); for (SocketImpl impl : clientSockets) { assertIsClosed(getSocket(impl)); } } private static void ignoreUnconnectedSockets(List<SocketImpl> clientSockets) { // ServerSocket.accept() creates a Socket instance when it starts waiting for incoming connections, // but they won't be in connected state until a client connects. So it is normal for each ServerSocket // to have 0..1 unconnected sockets. for (Iterator<SocketImpl> it = clientSockets.iterator(); it.hasNext(); ) { Socket socket = getSocket(it.next()); if (!socket.isConnected()) { it.remove(); } } } private void startAndStopLauncher() throws Exception { app.runTests(); JumiLauncher launcher = app.getLauncher(); launcher.close(); } // asserts private static void assertIsClosed(Socket socket) { assertThat(socket.isClosed(), isClosed(socket)); } private static void assertIsClosed(ServerSocket socket) { assertThat(socket.isClosed(), isClosed(socket)); } private static Matcher<Boolean> isClosed(Object socket) { return describedAs("is closed: %0", is(true), socket); } // socket helpers private static SocketImpl newSocketImpl() { try { Class<?> defaultSocketImpl = Class.forName("java.net.SocksSocketImpl"); Constructor<?> constructor = defaultSocketImpl.getDeclaredConstructor(); constructor.setAccessible(true); return (SocketImpl) constructor.newInstance(); } catch (Exception e) { throw new RuntimeException(e); } } private static Socket getSocket(SocketImpl impl) { try { Method getSocket = SocketImpl.class.getDeclaredMethod("getSocket"); getSocket.setAccessible(true); return (Socket) getSocket.invoke(impl); } catch (Exception e) { throw new RuntimeException(e); } } private static ServerSocket getServerSocket(SocketImpl impl) { try { Method getServerSocket = SocketImpl.class.getDeclaredMethod("getServerSocket"); getServerSocket.setAccessible(true); return (ServerSocket) getServerSocket.invoke(impl); } catch (Exception e) { throw new RuntimeException(e); } } private static class SpySocketImplFactory implements SocketImplFactory { private final List<SocketImpl> spy; public SpySocketImplFactory(List<SocketImpl> spy) { this.spy = spy; } @Override public SocketImpl createSocketImpl() { SocketImpl socket = newSocketImpl(); spy.add(socket); return socket; } } }