From fa3eedb02883bfcba01d3396f108a7d6bcc0f71a Mon Sep 17 00:00:00 2001 From: Chase-san <34535+Chase-san@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:17:49 -0500 Subject: [PATCH 1/2] Stop keeping a robot's thread after it opens a file, which kept robots in memory with Java 24+ ThreadManager adds the thread to outputStreamThreads before it opens a robot's file or creates its data directory, and only RobocodeSecurityPolicy removes it again, when the SecurityManager asks about the write. Java 24+ has no SecurityManager, so the thread stays in the list, and through it the whole robot: its proxy, peer, class loader and static data. This hits every robot that writes a file, so a long run runs out of memory. Three RoboRumble clients on Java 25, with the -Xmx512M of roborumble.bat, ran out of memory after 59, 86 and 362 battles. The battle thread died and the clients hung. A heap dump showed 164 RobotPeers, of which 162 were only held through this list. Version 1.11.2 does the same. Java 8 and 21 do not, as their SecurityManager removes the thread. With this change, a client on Java 25 ran 333 battles without running out of memory, and held only the 2 robots of the running battle when checked after 120 battles. Co-Authored-By: Claude Opus 5.5 --- .../robocode/host/security/ThreadManager.java | 15 ++- .../host/security/ThreadManagerTest.java | 110 ++++++++++++++++++ versions.md | 3 + 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 robocode.host/src/test/java/net/sf/robocode/host/security/ThreadManagerTest.java diff --git a/robocode.host/src/main/java/net/sf/robocode/host/security/ThreadManager.java b/robocode.host/src/main/java/net/sf/robocode/host/security/ThreadManager.java index f3f2e9fe0..63cbd7f21 100644 --- a/robocode.host/src/main/java/net/sf/robocode/host/security/ThreadManager.java +++ b/robocode.host/src/main/java/net/sf/robocode/host/security/ThreadManager.java @@ -153,8 +153,12 @@ public FileOutputStream createRobotFileStream(String fileName, boolean append) t AccessController.doPrivileged(new PrivilegedAction() { public Object run() { outputStreamThreads.add(c); - if (!dir.exists() && !dir.mkdirs()) { - syserr.println("Can't create dir " + dir); + try { + if (!dir.exists() && !dir.mkdirs()) { + syserr.println("Can't create dir " + dir); + } + } finally { + outputStreamThreads.remove(c); } return null; } @@ -176,7 +180,12 @@ public Object run() { } outputStreamThreads.add(c); - return new RobotFileOutputStream(fileName, append, fileSystemManager); + try { + return new RobotFileOutputStream(fileName, append, fileSystemManager); + } finally { + // The SecurityManager removes the thread when the file is opened, but Java 24+ has no SecurityManager + outputStreamThreads.remove(c); + } } public void checkRobotFileWrite(File file) { diff --git a/robocode.host/src/test/java/net/sf/robocode/host/security/ThreadManagerTest.java b/robocode.host/src/test/java/net/sf/robocode/host/security/ThreadManagerTest.java new file mode 100644 index 000000000..1b41ebc86 --- /dev/null +++ b/robocode.host/src/test/java/net/sf/robocode/host/security/ThreadManagerTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2001-2026 Mathew A. Nelson and Robocode contributors + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://robocode.sourceforge.io/license/epl-v10.html + */ +package net.sf.robocode.host.security; + + +import net.sf.robocode.host.IHostedThread; +import net.sf.robocode.host.RobotStatics; +import net.sf.robocode.host.io.RobotFileSystemManager; +import net.sf.robocode.repository.IRobotItem; +import net.sf.robocode.security.HiddenAccess; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import robocode.control.RobotSpecification; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + + +/** + * @author Robert Maupin (original) + */ +public class ThreadManagerTest { + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + // The thread would keep the whole robot in memory, and Java 24+ has no SecurityManager to remove it + @Test + public void aThreadIsNotKeptAfterItOpensAFile() throws IOException { + File root = folder.newFolder("data"); + IHostedThread robot = createAdvancedRobot(root); + ThreadManager manager = new ThreadManager(); + + manager.addThreadGroup(Thread.currentThread().getThreadGroup(), robot); + + FileOutputStream stream = manager.createRobotFileStream(new File(root, "Robot.data/file").getPath(), false); + + stream.close(); + + Assert.assertFalse(manager.checkRobotFileStream()); + } + + private static IHostedThread createAdvancedRobot(final File root) { + HiddenAccess.init(); + + IRobotItem item = stub(IRobotItem.class, new InvocationHandler() { + public Object invoke(Object proxy, Method method, Object[] args) { + switch (method.getName()) { + case "isAdvancedRobot": + return true; + + case "getFullClassName": + return "test.Robot"; + + case "getShortClassName": + return "Robot"; + + default: + return defaultValue(method.getReturnType()); + } + } + }); + RobotSpecification specification = HiddenAccess.createSpecification(item, "test.Robot 1.0", null, null, "1.0", null, null, + "test.Robot", null); + final RobotStatics statics = new RobotStatics(specification, "test.Robot 1.0", "", false, null, null, null, 0, 0); + + return stub(IHostedThread.class, new InvocationHandler() { + private RobotFileSystemManager manager; + + public Object invoke(Object proxy, Method method, Object[] args) { + switch (method.getName()) { + case "getStatics": + return statics; + + case "getRobotFileSystemManager": + if (manager == null) { + manager = new RobotFileSystemManager((IHostedThread) proxy, 200000, root.getPath(), root.getPath(), null); + } + return manager; + + default: + return defaultValue(method.getReturnType()); + } + } + }); + } + + @SuppressWarnings("unchecked") + private static T stub(Class type, InvocationHandler handler) { + return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class[] { type}, handler); + } + + private static Object defaultValue(Class type) { + if (type == boolean.class) { + return false; + } + return type.isPrimitive() && type != void.class ? 0 : null; + } +} diff --git a/versions.md b/versions.md index 04d058ab4..f979eb42c 100644 --- a/versions.md +++ b/versions.md @@ -9,6 +9,9 @@ `BulletHitBulletEvent`, if the robot that fired was on a team. - Files that a robot left open were not closed when the battle ended. They are now closed, with a warning in the console naming the robot and the file. On Windows, an open file cannot be deleted. +- With Java 24 and newer, a robot that wrote a file stayed in memory after the battle, as its thread was kept in a list + that only the SecurityManager removed threads from. Long runs, e.g. of the RoboRumble client, ran out of memory and + hung. ### Changes From 961e278d1e33dee09192f79029af42f65636732d Mon Sep 17 00:00:00 2001 From: Chase-san <34535+Chase-san@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:17:49 -0500 Subject: [PATCH 2/2] Retry deleting the robot data after a garbage collection with CLEARDATA, and wait if it still fails closeOpenStreams only knows the files that robots open for writing through Robocode. A file a robot opened for reading and did not close stays open until the stream is garbage collected, and until then it cannot be deleted on Windows, so the client skips battles. Skipping a battle allocates next to nothing, so a collection can be a long time coming. With a test robot that does this, in a 3-robot list on Windows, clients skipped 69% of their battles on Java 25 and 82% on Java 21 over two minutes. No real robot did it in about 26,000 battles, but only about 3,200 of those were on Windows. So when the data cannot be deleted, collect the garbage and try again. The same clients then fought 20 of 20 battles on each of Java 8, 21 and 25. If that fails too, e.g. as another program holds the file, the client had nothing to slow it down. It went through its battles at full speed and asked the server for the ratings on each iteration: 348 iterations, 17,362 skipped battles and 4 MB of log in a minute with ITERATE=YES. Waiting a minute before going on brought this down to 1 iteration and 2 skipped battles in 130 seconds. Each battle tries again, so the client fights as soon as the data can be deleted. Co-Authored-By: Claude Opus 5.5 --- .../battlesengine/BattlesRunner.java | 33 ++++++++++++++++++- .../battlesengine/BattlesRunnerTest.java | 21 ++++++++++++ versions.md | 4 +-- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/robocode.roborumble/src/main/java/net/sf/robocode/roborumble/battlesengine/BattlesRunner.java b/robocode.roborumble/src/main/java/net/sf/robocode/roborumble/battlesengine/BattlesRunner.java index c3451b74f..96ceb2194 100644 --- a/robocode.roborumble/src/main/java/net/sf/robocode/roborumble/battlesengine/BattlesRunner.java +++ b/robocode.roborumble/src/main/java/net/sf/robocode/roborumble/battlesengine/BattlesRunner.java @@ -32,6 +32,9 @@ * @author Robert D. Maupin (contributor) */ public class BattlesRunner { + // How long to wait in milliseconds before going on when the robot data could not be deleted + private static final long CLEARDATA_RETRY_WAIT = 60000; + private final BattlesFile inputfile; private final int numrounds; private final int fieldlen; @@ -117,7 +120,7 @@ public void runBattlesImpl(boolean melee) { battle.getSentryBorderSize(), hidenames, robotsList); lastResults = null; - if (cleardata && !deleteContents(FileUtil.getRobotsDataDir())) { + if (cleardata && !clearRobotData(FileUtil.getRobotsDataDir(), CLEARDATA_RETRY_WAIT)) { // The results would claim that the data was cleared Logger.realErr.println("Skipping battle because the robot data could not be deleted: " + enemies); } else { @@ -137,6 +140,34 @@ public void runBattlesImpl(boolean melee) { outtxt.close(); } + /** + * Deletes the data robots saved. A robot may have left a file open for reading, which keeps it from being deleted + * on Windows until the stream is garbage collected, so this collects the garbage before trying again. If that does + * not help either, it waits, so that a client that cannot fight does not keep asking the server for the ratings. + * + * @return true if nothing is left of the data; false otherwise. + */ + static boolean clearRobotData(File data, long waitAfterFailure) { + if (deleteContents(data)) { + return true; + } + System.gc(); + pause(100); // A separate thread closes the stream after the collection + if (deleteContents(data)) { + return true; + } + pause(waitAfterFailure); + return false; + } + + private static void pause(long milliseconds) { + try { + Thread.sleep(milliseconds); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + /** * Deletes everything in a directory. For the robot data directory this is safe between battles, as * a robot gets back the data in its jar when a battle starts. diff --git a/robocode.roborumble/src/test/java/net/sf/robocode/roborumble/battlesengine/BattlesRunnerTest.java b/robocode.roborumble/src/test/java/net/sf/robocode/roborumble/battlesengine/BattlesRunnerTest.java index 812217c7e..76d8505f8 100644 --- a/robocode.roborumble/src/test/java/net/sf/robocode/roborumble/battlesengine/BattlesRunnerTest.java +++ b/robocode.roborumble/src/test/java/net/sf/robocode/roborumble/battlesengine/BattlesRunnerTest.java @@ -14,6 +14,7 @@ import org.junit.rules.TemporaryFolder; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; @@ -49,4 +50,24 @@ public void aMissingDirectoryIsFine() { public void aPathThatCannotBeListedIsAFailure() throws IOException { Assert.assertFalse(BattlesRunner.deleteContents(folder.newFile("not-a-directory"))); } + + @Test + public void aFileLeftOpenForReadingIsDeletedAfterACollection() throws IOException { + File data = folder.newFolder("data"); + + leaveOpenForReading(new File(data, "kept")); + + Assert.assertTrue(BattlesRunner.clearRobotData(data, 0)); + Assert.assertEquals(0, data.list().length); + } + + @Test + public void dataThatCannotBeDeletedIsAFailure() throws IOException { + Assert.assertFalse(BattlesRunner.clearRobotData(folder.newFile("not-a-directory"), 0)); + } + + private static void leaveOpenForReading(File file) throws IOException { + Assert.assertTrue(file.createNewFile()); + new FileInputStream(file).read(); + } } diff --git a/versions.md b/versions.md index f979eb42c..44d157fca 100644 --- a/versions.md +++ b/versions.md @@ -19,8 +19,8 @@ configuration, robots only see their enemies by anonymous names like `#1`, and uploads say `hidenames=YES`. - #118: New `CLEARDATA` setting for the RoboRumble client, by Chase-san. With `CLEARDATA=YES`, the data robots saved is deleted before each battle, so they only keep it between the rounds of one battle, and uploads say `cleardata=YES`. - Data in a robot's jar is not lost. If the data cannot be deleted, the client logs which file it was and skips the - battle. + Data in a robot's jar is not lost. If the data cannot be deleted, even after a garbage collection in case a robot left + a file open for reading, the client logs which file it was, waits a minute, and skips the battle. - Both settings default to `NOT` and must match the rumble on the server. The official LiteRumble rumbles need `NOT`. ## Version 1.11.2 (31-Oct-2026)