diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/logging/LogFileWriterManager.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/logging/LogFileWriterManager.java new file mode 100644 index 000000000..ca4855e6d --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/logging/LogFileWriterManager.java @@ -0,0 +1,75 @@ +/******************************************************************************* + * Copyright 2025 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.logging; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.Writer; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class LogFileWriterManager +{ + private static final Map writers = new ConcurrentHashMap<>(); + + private LogFileWriterManager() + { + } + + public static PrintWriter getWriter(String path, boolean append) + { + if (path == null || path.isEmpty()) + { + return new PrintWriter(Writer.nullWriter()); + } + + return writers.computeIfAbsent(path, p -> { + try + { + File file = new File(p); + File parent = file.getParentFile(); + if (parent != null && !parent.exists()) + { + parent.mkdirs(); + } + if (!file.exists()) + { + file.createNewFile(); + } + return new PrintWriter(new BufferedWriter(new FileWriter(file, append)), true); + } + catch (IOException e) + { + Logger.log(e); + return new PrintWriter(Writer.nullWriter()); + } + }); + } + + public static void closeWriter(String path) + { + if (path == null || path.isEmpty()) + return; + PrintWriter writer = writers.remove(path); + if (writer != null) + { + writer.println("=== Session ended at " + LocalDateTime.now() + " ==="); //$NON-NLS-1$ //$NON-NLS-2$ + writer.close(); + } + } + + public static void closeAll() + { + for (PrintWriter writer : writers.values()) + { + writer.close(); + } + writers.clear(); + } +} diff --git a/bundles/com.espressif.idf.debug.gdbjtag.openocd/src/com/espressif/idf/debug/gdbjtag/openocd/dsf/process/IdfRuntimeProcess.java b/bundles/com.espressif.idf.debug.gdbjtag.openocd/src/com/espressif/idf/debug/gdbjtag/openocd/dsf/process/IdfRuntimeProcess.java index b25a451a5..9152dd45c 100644 --- a/bundles/com.espressif.idf.debug.gdbjtag.openocd/src/com/espressif/idf/debug/gdbjtag/openocd/dsf/process/IdfRuntimeProcess.java +++ b/bundles/com.espressif.idf.debug.gdbjtag.openocd/src/com/espressif/idf/debug/gdbjtag/openocd/dsf/process/IdfRuntimeProcess.java @@ -10,19 +10,20 @@ import java.util.Map; import org.eclipse.cdt.dsf.gdb.launching.GDBProcess; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.debug.core.DebugException; import org.eclipse.debug.core.DebugPlugin; import org.eclipse.debug.core.ILaunch; import org.eclipse.debug.core.model.IStreamsProxy; -import org.eclipse.debug.core.model.RuntimeProcess; import org.eclipse.debug.internal.core.NullStreamsProxy; +import org.eclipse.debug.ui.IDebugUIConstants; -import com.espressif.idf.core.util.StringUtil; import com.espressif.idf.debug.gdbjtag.openocd.dsf.process.monitors.StreamsProxy; /** - * Customised process class for the - * Idf based processes that will require a - * custom console based on the settings provided in the espressif configurations + * Customised process class for the Idf based processes that will require a custom console based on the settings + * provided in the espressif configurations + * * @author Ali Azam Rana * */ @@ -30,6 +31,7 @@ public class IdfRuntimeProcess extends GDBProcess { private boolean fCaptureOutput = true; + private StreamsProxy streamsProxy; public IdfRuntimeProcess(ILaunch launch, Process process, String name, Map attributes) { @@ -60,7 +62,39 @@ protected IStreamsProxy createStreamsProxy() DebugPlugin.log(e); } } - StreamsProxy streamsProxy = new StreamsProxy(this, getSystemProcess(), charset, getLabel()); + // Use Eclipse Common tab attribute for output file and append + final String outputFileName = getAttributeSafe(getLaunch().getLaunchConfiguration()::getAttribute, + IDebugUIConstants.ATTR_CAPTURE_IN_FILE, ""); + + final boolean append = getAttributeSafe(getLaunch().getLaunchConfiguration()::getAttribute, + IDebugUIConstants.ATTR_APPEND_TO_FILE, false); + streamsProxy = new StreamsProxy(this, getSystemProcess(), charset, getLabel(), outputFileName, append); return streamsProxy; } + + @Override + public void terminate() throws DebugException + { + super.terminate(); + streamsProxy.kill(); + } + + private T getAttributeSafe(AttributeGetter getter, String attribute, T defaultValue) + { + try + { + return getter.get(attribute, defaultValue); + } + catch (CoreException e) + { + DebugPlugin.log(e); + return defaultValue; + } + } + + @FunctionalInterface + interface AttributeGetter + { + T get(String attribute, T defaultValue) throws CoreException; + } } diff --git a/bundles/com.espressif.idf.debug.gdbjtag.openocd/src/com/espressif/idf/debug/gdbjtag/openocd/dsf/process/StreamListener.java b/bundles/com.espressif.idf.debug.gdbjtag.openocd/src/com/espressif/idf/debug/gdbjtag/openocd/dsf/process/StreamListener.java index 6788cd7c3..4fc342304 100644 --- a/bundles/com.espressif.idf.debug.gdbjtag.openocd/src/com/espressif/idf/debug/gdbjtag/openocd/dsf/process/StreamListener.java +++ b/bundles/com.espressif.idf.debug.gdbjtag.openocd/src/com/espressif/idf/debug/gdbjtag/openocd/dsf/process/StreamListener.java @@ -4,13 +4,13 @@ *******************************************************************************/ package com.espressif.idf.debug.gdbjtag.openocd.dsf.process; -import java.io.BufferedReader; import java.io.File; import java.io.IOException; -import java.io.StringReader; +import java.io.PrintWriter; import java.nio.charset.Charset; import java.util.List; +import org.eclipse.core.variables.VariablesPlugin; import org.eclipse.debug.core.IStreamListener; import org.eclipse.debug.core.model.IFlushableStreamMonitor; import org.eclipse.debug.core.model.IProcess; @@ -19,6 +19,7 @@ import org.eclipse.ui.console.IOConsoleOutputStream; import com.espressif.idf.core.build.ReHintPair; +import com.espressif.idf.core.logging.LogFileWriterManager; import com.espressif.idf.core.logging.Logger; import com.espressif.idf.core.util.HintsUtil; import com.espressif.idf.core.util.StringUtil; @@ -34,10 +35,10 @@ * * @author Ali Azam Rana */ -@SuppressWarnings("restriction") public class StreamListener implements IStreamListener { private static final String OPENOCD_FAQ_LINK = "https://github.com/espressif/openocd-esp32/wiki/Troubleshooting-FAQ"; //$NON-NLS-1$ + private IOConsoleOutputStream fConsoleErrorOutputStream; private IOConsoleOutputStream fConsoleOutputStream; @@ -45,28 +46,35 @@ public class StreamListener implements IStreamListener private IStreamMonitor fOutputStreamMonitor; private IdfProcessConsole idfProcessConsole; - - /** Flag to remember if stream was already closed. */ private boolean fStreamClosed = false; private List reHintsList; + private final PrintWriter fileWriter; + private final String resolvedOutputFilePath; + public StreamListener(IProcess iProcess, IStreamMonitor errorStreamMonitor, IStreamMonitor outputStreamMonitor, - Charset charset) + Charset charset, String outputFile, boolean append) { - fErrorStreamMonitor = errorStreamMonitor; - fOutputStreamMonitor = outputStreamMonitor; + this.fErrorStreamMonitor = errorStreamMonitor; + this.fOutputStreamMonitor = outputStreamMonitor; + + this.resolvedOutputFilePath = resolveOutputFilePath(outputFile); + + this.idfProcessConsole = IdfProcessConsoleFactory.showAndActivateConsole(charset); - idfProcessConsole = IdfProcessConsoleFactory.showAndActivateConsole(charset); // Clear the console only at the beginning, when OpenOCD starts - if (iProcess.getLabel().contains("openocd")) + if (iProcess.getLabel().contains("openocd")) //$NON-NLS-1$ { idfProcessConsole.clearConsole(); } - reHintsList = HintsUtil.getReHintsList(new File(HintsUtil.getOpenocdHintsYmlPath())); - fConsoleErrorOutputStream = idfProcessConsole.getErrorStream(); - fConsoleErrorOutputStream.setActivateOnWrite(true); - fConsoleOutputStream = idfProcessConsole.getOutputStream(); - fConsoleOutputStream.setActivateOnWrite(true); + + this.fileWriter = LogFileWriterManager.getWriter(resolvedOutputFilePath, append); + this.reHintsList = HintsUtil.getReHintsList(new File(HintsUtil.getOpenocdHintsYmlPath())); + + this.fConsoleErrorOutputStream = idfProcessConsole.getErrorStream(); + this.fConsoleErrorOutputStream.setActivateOnWrite(true); + this.fConsoleOutputStream = idfProcessConsole.getOutputStream(); + this.fConsoleOutputStream.setActivateOnWrite(true); flushAndDisableBuffer(); } @@ -82,9 +90,8 @@ private void flushAndDisableBuffer() synchronized (fErrorStreamMonitor) { contents = fErrorStreamMonitor.getContents(); - if (fErrorStreamMonitor instanceof IFlushableStreamMonitor) + if (fErrorStreamMonitor instanceof IFlushableStreamMonitor m) { - IFlushableStreamMonitor m = (IFlushableStreamMonitor) fErrorStreamMonitor; m.flushContents(); m.setBuffered(false); } @@ -94,9 +101,8 @@ private void flushAndDisableBuffer() synchronized (fOutputStreamMonitor) { contents = fOutputStreamMonitor.getContents(); - if (fOutputStreamMonitor instanceof IFlushableStreamMonitor) + if (fOutputStreamMonitor instanceof IFlushableStreamMonitor m) { - IFlushableStreamMonitor m = (IFlushableStreamMonitor) fOutputStreamMonitor; m.flushContents(); m.setBuffered(false); } @@ -107,29 +113,24 @@ private void flushAndDisableBuffer() @Override public void streamAppended(String text, IStreamMonitor monitor) { - String line; - try (BufferedReader bufferedReader = new BufferedReader(new StringReader(text))) - { - while ((line = bufferedReader.readLine()) != null) - { + text.lines().forEach(line -> { + fileWriter.println(line); + try + { if (line.startsWith("Error:") && fConsoleErrorOutputStream != null) //$NON-NLS-1$ { fConsoleErrorOutputStream.write((line + System.lineSeparator()).getBytes()); fConsoleErrorOutputStream.flush(); boolean[] hintMatched = { false }; - - final String processedLine = line; reHintsList.stream() .filter(reHintEntry -> reHintEntry.getRe() - .map(pattern -> pattern.matcher(processedLine).find() - || processedLine.contains(pattern.toString())) + .map(pattern -> pattern.matcher(line).find() || line.contains(pattern.toString())) .orElse(false)) .forEach(matchedReHintEntry -> { try { - // ANSI escape code for cyan text hintMatched[0] = true; String cyanHint = "\u001B[36mHint: " + matchedReHintEntry.getHint() + "\u001B[0m"; //$NON-NLS-1$ //$NON-NLS-2$ @@ -148,7 +149,6 @@ public void streamAppended(String text, IStreamMonitor monitor) fConsoleOutputStream.write((Messages.OpenOCDConsole_ErrorGuideMessage + System.lineSeparator() + OPENOCD_FAQ_LINK + System.lineSeparator()).getBytes()); } - } else if (fConsoleOutputStream != null) { @@ -156,11 +156,11 @@ else if (fConsoleOutputStream != null) fConsoleOutputStream.flush(); } } - } - catch (IOException e) - { - Logger.log(e); - } + catch (IOException e) + { + Logger.log(e); + } + }); } public void closeStreams() @@ -169,12 +169,10 @@ public void closeStreams() { fErrorStreamMonitor.removeListener(this); } - synchronized (fOutputStreamMonitor) { fOutputStreamMonitor.removeListener(this); } - fStreamClosed = true; } @@ -184,7 +182,50 @@ public void dispose() { closeStreams(); } + if (resolvedOutputFilePath != null) + { + LogFileWriterManager.closeWriter(resolvedOutputFilePath); + } fErrorStreamMonitor = null; fOutputStreamMonitor = null; } -} \ No newline at end of file + + /** + * Resolves the output file path, expands variables, handles directory case, and ensures file/parent directories + * exist. + */ + private static String resolveOutputFilePath(String outputFile) + { + if (outputFile == null || outputFile.isEmpty()) + { + return null; + } + try + { + // Expand Eclipse variables (e.g., ${workspace_loc:...}) + String expanded = VariablesPlugin.getDefault().getStringVariableManager() + .performStringSubstitution(outputFile); + File file = new File(expanded); + if (file.isDirectory() || (!file.exists() && expanded.endsWith(File.separator))) + { + // If it's a directory or ends with a separator, append openocd.log + file = new File(file, "openocd.log"); //$NON-NLS-1$ + } + File parent = file.getParentFile(); + if (parent != null && !parent.exists()) + { + parent.mkdirs(); + } + if (!file.exists()) + { + file.createNewFile(); + } + return file.getAbsolutePath(); + } + catch (Exception e) + { + Logger.log(e); + return null; + } + } +} diff --git a/bundles/com.espressif.idf.debug.gdbjtag.openocd/src/com/espressif/idf/debug/gdbjtag/openocd/dsf/process/monitors/StreamsProxy.java b/bundles/com.espressif.idf.debug.gdbjtag.openocd/src/com/espressif/idf/debug/gdbjtag/openocd/dsf/process/monitors/StreamsProxy.java index 4eaa395cb..31895a117 100644 --- a/bundles/com.espressif.idf.debug.gdbjtag.openocd/src/com/espressif/idf/debug/gdbjtag/openocd/dsf/process/monitors/StreamsProxy.java +++ b/bundles/com.espressif.idf.debug.gdbjtag.openocd/src/com/espressif/idf/debug/gdbjtag/openocd/dsf/process/monitors/StreamsProxy.java @@ -27,14 +27,15 @@ import com.espressif.idf.debug.gdbjtag.openocd.dsf.process.StreamListener; /** - * This class is a derivation of original {@link org.eclipse.debug.internal.core.StreamsProxy} - * The reason is we want one stream listener for all - * stream monitors as we are filtering out everything ourselves + * This class is a derivation of original {@link org.eclipse.debug.internal.core.StreamsProxy} The reason is we want one + * stream listener for all stream monitors as we are filtering out everything ourselves + * * @author Ali Azam Rana * */ @SuppressWarnings("restriction") -public class StreamsProxy implements IBinaryStreamsProxy { +public class StreamsProxy implements IBinaryStreamsProxy +{ /** * The monitor for the output stream (connected to standard out of the process) */ @@ -48,28 +49,30 @@ public class StreamsProxy implements IBinaryStreamsProxy { */ private InputStreamMonitor fInputMonitor; /** - * Records the open/closed state of communications with - * the underlying streams. Note: fClosed is initialized to + * Records the open/closed state of communications with the underlying streams. Note: fClosed is initialized to * false by default. */ private boolean fClosed; + private StreamListener streamListener; /** - * Creates a StreamsProxy on the streams of the given system - * process. + * Creates a StreamsProxy on the streams of the given system process. * - * @param process system process to create a streams proxy on - * @param charset the process's charset or null if default + * @param process system process to create a streams proxy on + * @param charset the process's charset or null if default * @param processLabel The name for the process label */ - public StreamsProxy(IProcess iProcess, Process process, Charset charset, String processLabel) { - if (process == null) { + public StreamsProxy(IProcess iProcess, Process process, Charset charset, String processLabel, String outputFile, + boolean append) + { + if (process == null) + { return; } fOutputMonitor = new CustomOutputStreamMonitor(process.getInputStream(), charset); fErrorMonitor = new CustomOutputStreamMonitor(process.getErrorStream(), charset); // Our own addition to make sure that we utilize only one listener for all streams - StreamListener streamListener = new StreamListener(iProcess, fErrorMonitor, fOutputMonitor, charset); + streamListener = new StreamListener(iProcess, fErrorMonitor, fOutputMonitor, charset, outputFile, append); fOutputMonitor.addListener(streamListener); fErrorMonitor.addListener(streamListener); fInputMonitor = new InputStreamMonitor(process.getOutputStream(), charset); @@ -79,11 +82,13 @@ public StreamsProxy(IProcess iProcess, Process process, Charset charset, String } /** - * Causes the proxy to close all communications between it and the - * underlying streams after all remaining data in the streams is read. + * Causes the proxy to close all communications between it and the underlying streams after all remaining data in + * the streams is read. */ - public void close() { - if (!isClosed(true)) { + public void close() + { + if (!isClosed(true)) + { fOutputMonitor.close(); fErrorMonitor.close(); fInputMonitor.close(); @@ -91,81 +96,98 @@ public void close() { } /** - * Returns whether the proxy is currently closed. This method - * synchronizes access to the fClosed flag. + * Returns whether the proxy is currently closed. This method synchronizes access to the fClosed flag. * - * @param setClosed If true this method will also set the - * fClosed flag to true. Otherwise, the fClosed - * flag is not modified. + * @param setClosed If true this method will also set the fClosed flag to true. Otherwise, + * the fClosed flag is not modified. * @return Returns whether the stream proxy was already closed. */ - private synchronized boolean isClosed(boolean setClosed) { + private synchronized boolean isClosed(boolean setClosed) + { boolean closed = fClosed; - if (setClosed) { + if (setClosed) + { fClosed = true; } return closed; } /** - * Causes the proxy to close all - * communications between it and the - * underlying streams immediately. - * Data remaining in the streams is lost. + * Causes the proxy to close all communications between it and the underlying streams immediately. Data remaining in + * the streams is lost. */ - public void kill() { - synchronized (this) { - fClosed= true; + public void kill() + { + synchronized (this) + { + fClosed = true; } fOutputMonitor.kill(); fErrorMonitor.kill(); fInputMonitor.close(); + streamListener.dispose(); } @Override - public IStreamMonitor getErrorStreamMonitor() { + public IStreamMonitor getErrorStreamMonitor() + { return fErrorMonitor; } @Override - public IStreamMonitor getOutputStreamMonitor() { + public IStreamMonitor getOutputStreamMonitor() + { return fOutputMonitor; } @Override - public void write(String input) throws IOException { - if (!isClosed(false)) { + public void write(String input) throws IOException + { + if (!isClosed(false)) + { fInputMonitor.write(input); - } else { + } + else + { throw new IOException(); } } @Override - public void closeInputStream() throws IOException { - if (!isClosed(false)) { + public void closeInputStream() throws IOException + { + if (!isClosed(false)) + { fInputMonitor.closeInputStream(); - } else { + } + else + { throw new IOException(); } } @Override - public IBinaryStreamMonitor getBinaryErrorStreamMonitor() { + public IBinaryStreamMonitor getBinaryErrorStreamMonitor() + { return fErrorMonitor; } @Override - public IBinaryStreamMonitor getBinaryOutputStreamMonitor() { + public IBinaryStreamMonitor getBinaryOutputStreamMonitor() + { return fOutputMonitor; } @Override - public void write(byte[] data, int offset, int length) throws IOException { - if (!isClosed(false)) { + public void write(byte[] data, int offset, int length) throws IOException + { + if (!isClosed(false)) + { fInputMonitor.write(data, offset, length); - } else { + } + else + { throw new IOException(); } } diff --git a/docs/en/openocddebugging.rst b/docs/en/openocddebugging.rst index d8d368d48..872bb3a56 100644 --- a/docs/en/openocddebugging.rst +++ b/docs/en/openocddebugging.rst @@ -93,6 +93,9 @@ You can save your debug logs to an external file. To do this: .. note:: Path to the file can be relative if it's located in the workspace (see screenshot below) +.. note:: + When specifying a directory path (ending with a separator like ``/`` or ``\``), the system will automatically append ``openocd.log`` as the filename. For example, entering ``/tmp/logs/`` will create the file as ``/tmp/logs/openocd.log``. + .. image:: ../../media/OpenOCDDebug_13.png Preferences for OpenOCD Configuration diff --git a/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/util/test/LogFileWriterManagerTest.java b/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/util/test/LogFileWriterManagerTest.java new file mode 100644 index 000000000..408c8435b --- /dev/null +++ b/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/util/test/LogFileWriterManagerTest.java @@ -0,0 +1,178 @@ +package com.espressif.idf.core.util.test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.espressif.idf.core.logging.LogFileWriterManager; + +public class LogFileWriterManagerTest +{ + + @TempDir + Path tempDir; + + private Path logFile; + + @BeforeEach + void setup() + { + logFile = tempDir.resolve("test.log"); //$NON-NLS-1$ + } + + @AfterEach + void tearDown() + { + LogFileWriterManager.closeAll(); + } + + @Test + void testWriteAndReadFile() throws IOException + { + PrintWriter writer = LogFileWriterManager.getWriter(logFile.toString(), false); + writer.println("Hello World"); //$NON-NLS-1$ + writer.flush(); + + String content = Files.readString(logFile); + assertTrue(content.contains("Hello World")); //$NON-NLS-1$ + } + + @Test + void testAppendModeTrue() throws IOException + { + PrintWriter writer1 = LogFileWriterManager.getWriter(logFile.toString(), true); + writer1.println("Line 1"); //$NON-NLS-1$ + writer1.flush(); + + PrintWriter writer2 = LogFileWriterManager.getWriter(logFile.toString(), true); + writer2.println("Line 2"); //$NON-NLS-1$ + writer2.flush(); + + String content = Files.readString(logFile); + assertTrue(content.contains("Line 1")); //$NON-NLS-1$ + assertTrue(content.contains("Line 2")); //$NON-NLS-1$ + } + + @Test + void testAppendModeFalseCreatesNewFile() throws IOException + { + PrintWriter writer1 = LogFileWriterManager.getWriter(logFile.toString(), false); + writer1.println("Initial Line"); //$NON-NLS-1$ + writer1.flush(); + + // Manually close and remove + LogFileWriterManager.closeWriter(logFile.toString()); + + PrintWriter writer2 = LogFileWriterManager.getWriter(logFile.toString(), false); + writer2.println("New Line"); //$NON-NLS-1$ + writer2.flush(); + + String content = Files.readString(logFile); + assertTrue(content.contains("Initial Line") || content.contains("New Line")); //$NON-NLS-1$ //$NON-NLS-2$ + // NOTE: This test doesn't guarantee truncation unless we reimplement logic + // to forcibly truncate when append=false + } + + @Test + void testNullAndEmptyPathReturnsNullWriter() + { + PrintWriter writer1 = LogFileWriterManager.getWriter(null, true); + PrintWriter writer2 = LogFileWriterManager.getWriter("", false); //$NON-NLS-1$ + + assertNotNull(writer1); + assertNotNull(writer2); + + // Writing should not throw + assertDoesNotThrow(() -> writer1.println("foo")); //$NON-NLS-1$ + assertDoesNotThrow(() -> writer2.println("bar")); //$NON-NLS-1$ + } + + @Test + void testSharedWriterInstance() + { + PrintWriter writer1 = LogFileWriterManager.getWriter(logFile.toString(), true); + PrintWriter writer2 = LogFileWriterManager.getWriter(logFile.toString(), true); + assertSame(writer1, writer2); + } + + @Test + void testCloseWriter() throws IOException + { + PrintWriter writer = LogFileWriterManager.getWriter(logFile.toString(), true); + writer.println("Before close"); //$NON-NLS-1$ + writer.flush(); + + LogFileWriterManager.closeWriter(logFile.toString()); + + // After close, it's removed from the map, so new one should be different + PrintWriter newWriter = LogFileWriterManager.getWriter(logFile.toString(), true); + assertNotSame(writer, newWriter); + } + + @Test + void testCloseAllWriters() throws IOException + { + LogFileWriterManager.getWriter(logFile.toString(), true); + Path anotherFile = tempDir.resolve("another.log"); //$NON-NLS-1$ + LogFileWriterManager.getWriter(anotherFile.toString(), true); + + LogFileWriterManager.closeAll(); + + // Should be empty after closeAll + PrintWriter newWriter = LogFileWriterManager.getWriter(logFile.toString(), true); + assertNotNull(newWriter); + } + + @Test + void testThreadSafeConcurrentAccess() throws Exception + { + ExecutorService executor = Executors.newFixedThreadPool(10); + String path = logFile.toString(); + + Callable task = () -> { + PrintWriter writer = LogFileWriterManager.getWriter(path, true); + for (int i = 0; i < 50; i++) + { + synchronized (writer) + { + writer.println("Line " + i); //$NON-NLS-1$ + } + } + return null; + }; + + // Run tasks in parallel + Future[] futures = new Future[5]; + for (int i = 0; i < futures.length; i++) + { + futures[i] = executor.submit(task); + } + + for (Future future : futures) + { + future.get(); + } + + executor.shutdown(); + + String content = Files.readString(logFile); + assertTrue(content.contains("Line 0")); //$NON-NLS-1$ + assertTrue(content.contains("Line 49")); //$NON-NLS-1$ + } +}