Skip to content

Commit 3395c61

Browse files
Rotate forge.log on startup; consolidate log rotation and export (Card-Forge#10588)
ExceptionHandler previously deleted forge.log at every startup unless the file was locked by another running instance, wiping the prior crash log. On Android, where exporting logs requires restarting the app, this meant crash traces were lost before users could export them. ExceptionHandler now renames the previous forge.log to forge.<timestamp>.log at startup, preserving its contents. A new LogRotation utility prunes rotated backups to MAX_LOG_FILES (default 10), called from FModel.initialize() once preferences are loaded. On mobile, replaced the separate "Export Game Log" and "Export Network Logs" menu entries with one "Export Logs" entry that collects both forge.*.log and network-debug-*.log files into a single zip. - Help -> Open Log File opens the actually-claimed slot via ExceptionHandler.getActiveLogFile(). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d33f761 commit 3395c61

8 files changed

Lines changed: 150 additions & 57 deletions

File tree

forge-gui-desktop/src/main/java/forge/menus/HelpMenu.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import javax.swing.JMenuItem;
1111
import javax.swing.KeyStroke;
1212

13+
import forge.error.ExceptionHandler;
1314
import forge.localinstance.properties.ForgeConstants;
1415
import forge.toolbox.FOptionPane;
1516
import forge.util.BuildInfo;
@@ -86,7 +87,7 @@ private static JMenuItem getMenuItem_HowToPlayFile() {
8687
private static JMenuItem getMenuItem_OpenLogFile() {
8788
final Localizer localizer = Localizer.getInstance();
8889
JMenuItem menuItem = new JMenuItem(localizer.getMessage("lblOpenLogFile"));
89-
menuItem.addActionListener(getOpenFileAction(getAbsoluteFile(ForgeConstants.LOG_FILE)));
90+
menuItem.addActionListener(getOpenFileAction(ExceptionHandler.getActiveLogFile()));
9091
return menuItem;
9192
}
9293

forge-gui-mobile/src/forge/screens/settings/FilesPage.java

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
package forge.screens.settings;
22

33
import java.io.File;
4-
import java.io.FilenameFilter;
54
import java.io.IOException;
65
import java.time.LocalDateTime;
76
import java.time.format.DateTimeFormatter;
87
import java.util.ArrayList;
9-
import java.util.Arrays;
8+
import java.util.Collections;
109
import java.util.List;
1110
import java.util.Map;
1211
import java.util.TreeMap;
@@ -93,24 +92,25 @@ public void select() {
9392
});
9493
}
9594
}, 0);
96-
//Export Game Log
97-
lstItems.addItem(new Extra(Forge.getLocalizer().getMessage("lblExportGameLog"), Forge.getLocalizer().getMessage("lblExportGameLogDescription")) {
95+
lstItems.addItem(new Extra(Forge.getLocalizer().getMessage("lblExportLogs"), Forge.getLocalizer().getMessage("lblExportLogsDescription")) {
9896
@Override
9997
public void select() {
100-
exportLogs("lblExportGameLog",
101-
new File(ForgeProfileProperties.getUserDir()),
102-
(dir, name) -> name.startsWith("forge") && name.endsWith(".log"),
103-
"forge-logs");
104-
}
105-
}, 0);
106-
//Export Network Logs
107-
lstItems.addItem(new Extra(Forge.getLocalizer().getMessage("lblExportNetworkLogs"), Forge.getLocalizer().getMessage("lblExportNetworkLogsDescription")) {
108-
@Override
109-
public void select() {
110-
exportLogs("lblExportNetworkLogs",
111-
new File(ForgeConstants.NETWORK_LOGS_DIR),
112-
(dir, name) -> name.startsWith("network-debug-") && name.endsWith(".log"),
113-
"forge-network-logs");
98+
List<File> files = new ArrayList<>();
99+
File userDir = new File(ForgeProfileProperties.getUserDir());
100+
if (userDir.isDirectory()) {
101+
File[] forgeLogs = userDir.listFiles((d, n) -> n.startsWith("forge") && n.endsWith(".log"));
102+
if (forgeLogs != null) {
103+
Collections.addAll(files, forgeLogs);
104+
}
105+
}
106+
File netDir = new File(ForgeConstants.NETWORK_LOGS_DIR);
107+
if (netDir.isDirectory()) {
108+
File[] netLogs = netDir.listFiles((d, n) -> n.startsWith("network-debug-") && n.endsWith(".log"));
109+
if (netLogs != null) {
110+
Collections.addAll(files, netLogs);
111+
}
112+
}
113+
exportLogs(files);
114114
}
115115
}, 0);
116116
//Auditer
@@ -257,23 +257,22 @@ protected void doLayout(float width, float height) {
257257
lstItems.setBounds(0, 0, width, height);
258258
}
259259

260-
private void exportLogs(String dialogTitleKey, File sourceDir, FilenameFilter filter, String outputPrefix) {
260+
private void exportLogs(List<File> files) {
261261
if (Forge.getDeviceAdapter().needFileAccess()) {
262262
Forge.getDeviceAdapter().requestFileAcces();
263263
return;
264264
}
265-
final String dialogTitle = Forge.getLocalizer().getMessage(dialogTitleKey);
265+
final String dialogTitle = Forge.getLocalizer().getMessage("lblExportLogs");
266266
FThreads.invokeInEdtLater(() -> LoadingOverlay.show(Forge.getLocalizer().getMessage("lblExporting"), true, () -> {
267267
try {
268-
File[] matches = sourceDir.isDirectory() ? sourceDir.listFiles(filter) : null;
269-
if (matches == null || matches.length == 0) {
268+
if (files.isEmpty()) {
270269
FOptionPane.showMessageDialog(Forge.getLocalizer().getMessage("lblNoLogFilesFound"), dialogTitle, FOptionPane.INFORMATION_ICON);
271270
return;
272271
}
273272
String stamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
274273
File downloads = new FileHandle(Forge.getDeviceAdapter().getDownloadsDir()).file();
275-
File zipFile = new File(downloads, outputPrefix + "-" + stamp + ".zip");
276-
ZipUtil.zipFiles(Arrays.asList(matches), zipFile);
274+
File zipFile = new File(downloads, "forge-logs-" + stamp + ".zip");
275+
ZipUtil.zipFiles(files, zipFile);
277276
FOptionPane.showMessageDialog(Forge.getLocalizer().getMessage("lblSuccess") + "\n" + zipFile.getAbsolutePath(), dialogTitle, FOptionPane.INFORMATION_ICON);
278277
} catch (IOException e) {
279278
FOptionPane.showMessageDialog(e.toString(), Forge.getLocalizer().getMessage("lblError"), FOptionPane.ERROR_ICON);

forge-gui/res/languages/en-US.properties

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3439,10 +3439,8 @@ lblPrepareDatabase=Preparing database...
34393439
lblLoadingGameResources=Loading game resources...
34403440
lblBackupRestore=Backup and Restore
34413441
lblBackupRestoreDescription=Backup or Restore Classic game mode data to/from Downloads folder
3442-
lblExportGameLog=Export Game Log
3443-
lblExportGameLogDescription=Export forge.log files to Downloads folder as a zip
3444-
lblExportNetworkLogs=Export Network Logs
3445-
lblExportNetworkLogsDescription=Export network debug logs to Downloads folder as a zip
3442+
lblExportLogs=Export Logs
3443+
lblExportLogsDescription=Export forge.log files and network debug logs to Downloads folder as a zip
34463444
lblExporting=Exporting...
34473445
lblNoLogFilesFound=No log files found
34483446
lblDataManagement=Data Management

forge-gui/src/main/java/forge/error/ExceptionHandler.java

Lines changed: 115 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@
2525

2626
import java.io.*;
2727
import java.lang.Thread.UncaughtExceptionHandler;
28+
import java.nio.channels.Channels;
29+
import java.nio.channels.FileChannel;
30+
import java.nio.channels.FileLock;
31+
import java.nio.file.FileAlreadyExistsException;
32+
import java.nio.file.StandardOpenOption;
33+
import java.text.SimpleDateFormat;
34+
import java.util.Arrays;
35+
import java.util.Comparator;
36+
import java.util.Date;
37+
import java.util.regex.Pattern;
2838

2939
/**
3040
* This class handles all exceptions that weren't caught by showing the error to
@@ -41,48 +51,119 @@ public class ExceptionHandler implements UncaughtExceptionHandler {
4151
System.setProperty("sun.awt.exception.handler", ExceptionHandler.class.getName());
4252
}
4353

54+
private static final String LOG_SUFFIX = ".log";
55+
private static final String TIMESTAMP_FORMAT = "yyyyMMdd-HHmmss";
56+
// Each running instance owns one forge<N>.log "slot" for its lifetime (forge.log = slot 0,
57+
// forge1.log = slot 1, ...); after the owner exits, the next startup archives the file to forge.<ts>.log
58+
private static final Pattern SLOT_PATTERN = Pattern.compile("forge\\d*\\.log");
59+
// Slot files deliberately don't match — they may be live
60+
private static final Pattern ARCHIVE_PATTERN = Pattern.compile("forge\\.\\d{8}-\\d{6}(\\.\\d+)?\\.log");
61+
4462
private static PrintStream oldSystemOut;
4563
private static PrintStream oldSystemErr;
4664
private static OutputStream logFileStream;
65+
private static File activeLogFile;
66+
private static FileChannel logChannel;
67+
private static FileLock logLock;
68+
69+
/** The log file this JVM is writing to, null until {@link #registerErrorHandling()} has run */
70+
public static File getActiveLogFile() {
71+
return activeLogFile;
72+
}
4773

4874
/**
4975
* Call this at the beginning to make sure that the class is loaded and the
5076
* static initializer has run.
5177
*/
5278
public static void registerErrorHandling() {
53-
//initialize log file
54-
File logFile = new File(ForgeConstants.LOG_FILE);
79+
File parent = new File(ForgeConstants.LOG_FILE).getParentFile();
80+
parent.mkdirs();
5581

56-
int i = 0;
57-
while (logFile.exists() && !logFile.delete()) {
58-
String pathname = logFile.getPath().replaceAll("[0-9]{0,2}.log$", i++ + ".log");
59-
logFile = new File(pathname);
82+
// Archive slot files whose JVM has exited. FileLock probes liveness:
83+
// POSIX rename succeeds even when another process has the file open, so
84+
// rename-success isn't a liveness signal. FileLock is honored cross-process.
85+
File[] existingSlots = parent.listFiles(f -> f.isFile() && SLOT_PATTERN.matcher(f.getName()).matches());
86+
if (existingSlots != null) {
87+
SimpleDateFormat ts = new SimpleDateFormat(TIMESTAMP_FORMAT);
88+
for (File slot : existingSlots) {
89+
boolean unowned = false;
90+
try (FileChannel probe = FileChannel.open(slot.toPath(), StandardOpenOption.WRITE)) {
91+
FileLock lock = probe.tryLock();
92+
if (lock != null) {
93+
lock.release();
94+
unowned = true;
95+
}
96+
} catch (IOException ignored) {}
97+
if (unowned) {
98+
File archive = nextAvailable(new File(parent,
99+
"forge." + ts.format(new Date(slot.lastModified())) + LOG_SUFFIX));
100+
slot.renameTo(archive);
101+
}
102+
}
60103
}
61-
62-
if (!logFile.exists()) {
104+
105+
// CREATE_NEW is atomic, so concurrent startups can't both claim the same slot
106+
for (int n = 0; ; n++) {
107+
File slot = slotFile(parent, n);
63108
try {
64-
logFile.getParentFile().mkdirs();
65-
logFile.createNewFile();
109+
FileChannel ch = FileChannel.open(slot.toPath(),
110+
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
111+
FileLock lock = ch.tryLock();
112+
if (lock != null) {
113+
logChannel = ch;
114+
logLock = lock;
115+
logFileStream = Channels.newOutputStream(ch);
116+
activeLogFile = slot;
117+
break;
118+
}
119+
ch.close();
120+
} catch (FileAlreadyExistsException e) {
121+
// slot owned by another live instance; try next
66122
} catch (IOException e) {
67123
e.printStackTrace();
124+
break;
68125
}
69126
}
70127

71-
try {
72-
logFileStream = new FileOutputStream(logFile);
73-
}
74-
catch (final FileNotFoundException e) {
75-
e.printStackTrace();
128+
if (logFileStream != null) {
129+
oldSystemOut = System.out;
130+
System.setOut(new PrintStream(new MultiplexOutputStream(System.out, logFileStream), true));
131+
oldSystemErr = System.err;
132+
System.setErr(new PrintStream(new MultiplexOutputStream(System.err, logFileStream), true));
133+
// no logger here, if it ever fails we'll know at least we passed through here
134+
System.out.println("Error handling registered!");
76135
}
136+
FTrace.initialize();
137+
}
77138

78-
oldSystemOut = System.out;
79-
System.setOut(new PrintStream(new MultiplexOutputStream(System.out, logFileStream), true));
80-
oldSystemErr = System.err;
81-
System.setErr(new PrintStream(new MultiplexOutputStream(System.err, logFileStream), true));
139+
private static File slotFile(File parent, int n) {
140+
return new File(parent, n == 0 ? "forge.log" : "forge" + n + ".log");
141+
}
82142

83-
// no logger here, if it ever fails we'll know at least we passed through here
84-
System.out.println("Error handling registered!");
85-
FTrace.initialize();
143+
// Same-second collisions: append .1, .2, ... until unused
144+
private static File nextAvailable(File target) {
145+
if (!target.exists()) return target;
146+
String base = target.getPath().substring(0, target.getPath().length() - LOG_SUFFIX.length());
147+
for (int i = 1; ; i++) {
148+
File alt = new File(base + "." + i + LOG_SUFFIX);
149+
if (!alt.exists()) return alt;
150+
}
151+
}
152+
153+
/** Delete oldest forge.<ts>.log backups until at most maxFiles remain. Per-process logs are not pruned. */
154+
public static void pruneForgeLogs(int maxFiles) {
155+
if (maxFiles <= 0) return;
156+
try {
157+
File dir = new File(ForgeConstants.LOG_FILE).getParentFile();
158+
if (dir == null || !dir.isDirectory()) return;
159+
File[] archives = dir.listFiles(f -> f.isFile() && ARCHIVE_PATTERN.matcher(f.getName()).matches());
160+
if (archives == null || archives.length <= maxFiles) return;
161+
Arrays.sort(archives, Comparator.comparingLong(File::lastModified));
162+
int toDelete = archives.length - maxFiles;
163+
for (int i = 0; i < toDelete; i++) archives[i].delete();
164+
} catch (Exception ignored) {
165+
// non-critical — never fail the app over log cleanup
166+
}
86167
}
87168

88169
/**
@@ -91,9 +172,18 @@ public static void registerErrorHandling() {
91172
*/
92173
public static void unregisterErrorHandling() throws IOException {
93174
FTrace.dump(); //dump trace before unregistering error handling
94-
System.setOut(oldSystemOut);
95-
System.setErr(oldSystemErr);
96-
logFileStream.close();
175+
if (oldSystemOut != null) System.setOut(oldSystemOut);
176+
if (oldSystemErr != null) System.setErr(oldSystemErr);
177+
try {
178+
if (logFileStream != null) logFileStream.close();
179+
} finally {
180+
if (logChannel != null) {
181+
try { logChannel.close(); } catch (IOException ignored) {}
182+
logChannel = null;
183+
}
184+
// lock is auto-released when its channel closes
185+
logLock = null;
186+
}
97187
}
98188

99189
/** {@inheritDoc} */

forge-gui/src/main/java/forge/gamemodes/net/NetworkLogConfig.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import forge.localinstance.properties.ForgeConstants;
44
import forge.localinstance.properties.ForgeNetPreferences.FNetPref;
5+
import forge.localinstance.properties.ForgePreferences.FPref;
56
import forge.model.FModel;
67
import forge.util.FileUtil;
78
import forge.util.IHasForgeLog;
@@ -304,7 +305,7 @@ private static void updateLogfileKey() {
304305
* Delete old log subdirectories from the network logs directory, respecting:
305306
* - Grace period: skip directories modified within the last 5 minutes
306307
* - Current batch protection: skip the current batchId subdirectory
307-
* - Max directory limit from NET_MAX_LOG_FILES preference
308+
* - Max directory limit from MAX_LOG_FILES preference (shared with forge.log rotation)
308309
* - Cleanup can be disabled via NET_LOG_CLEANUP_ENABLED preference
309310
* - Also cleans up legacy flat log files (network-debug-*.log)
310311
*/
@@ -316,7 +317,7 @@ private static void cleanupOldLogs() {
316317
if (!FModel.getNetPreferences().getPrefBoolean(FNetPref.NET_LOG_CLEANUP_ENABLED)) {
317318
return;
318319
}
319-
int maxEntries = FModel.getNetPreferences().getPrefInt(FNetPref.NET_MAX_LOG_FILES);
320+
int maxEntries = FModel.getPreferences().getPrefInt(FPref.MAX_LOG_FILES);
320321
if (maxEntries <= 0) {
321322
return;
322323
}

forge-gui/src/main/java/forge/localinstance/properties/ForgeNetPreferences.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ public enum FNetPref implements PreferencesStore.IPref {
2828
NET_PORT("36743"),
2929
UPnP("ASK"),
3030
NET_BANDWIDTH_LOGGING("false"),
31-
NET_MAX_LOG_FILES("10"),
3231
NET_LOG_CLEANUP_ENABLED("true"),
3332
NET_AFK_TIMEOUT("5"),
3433
NET_LAST_COPIED_URL("");

forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ public enum FPref implements PreferencesStore.IPref {
201201
AUTO_UPDATE("none"),
202202
USE_SENTRY("false"), // this controls whether automated bug reporting is done or not
203203
CHECK_SNAPSHOT_AT_STARTUP("true"),
204+
MAX_LOG_FILES("10"), // applied per category: up to N forge.*.log backups AND N network log entries
204205

205206
MATCH_HOT_SEAT_MODE("false"), //this only applies to mobile game
206207

forge-gui/src/main/java/forge/model/FModel.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import forge.deck.CardArchetypeLDAGenerator;
2929
import forge.deck.CardRelationMatrixGenerator;
3030
import forge.deck.io.DeckPreferences;
31+
import forge.error.ExceptionHandler;
3132
import forge.game.GameFormat;
3233
import forge.game.GameType;
3334
import forge.game.card.CardUtil;
@@ -165,6 +166,9 @@ public static void initialize(final IProgressBar progressBar, Function<ForgePref
165166
throw new RuntimeException(exn);
166167
}
167168

169+
// Runs here because preferences must be loaded before MAX_LOG_FILES is readable
170+
ExceptionHandler.pruneForgeLogs(preferences.getPrefInt(FPref.MAX_LOG_FILES));
171+
168172
Lang.createInstance(getPreferences().getPref(FPref.UI_LANGUAGE));
169173
Localizer.getInstance().initialize(getPreferences().getPref(FPref.UI_LANGUAGE), ForgeConstants.LANG_DIR);
170174

0 commit comments

Comments
 (0)