From 7fb22a030852b20c8d24b9fab8288f7ed563cf0c Mon Sep 17 00:00:00 2001 From: Leumor <116955025+leumor@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:55:06 +0000 Subject: [PATCH 1/6] fix(persistence): Close restored RAF handles on resume failure --- .../crypta/client/async/SplitFileFetcher.java | 75 +++++++++++-------- .../async/SplitFileInserterStorage.java | 10 ++- .../crypt/EncryptedRandomAccessBuffer.java | 9 ++- .../support/io/PaddedRandomAccessBuffer.java | 14 +++- .../EncryptedRandomAccessBufferTest.java | 41 ++++++++++ .../io/PaddedRandomAccessBufferTest.java | 31 ++++++++ 6 files changed, 142 insertions(+), 38 deletions(-) diff --git a/src/main/java/network/crypta/client/async/SplitFileFetcher.java b/src/main/java/network/crypta/client/async/SplitFileFetcher.java index 35e1560134..470dcac643 100644 --- a/src/main/java/network/crypta/client/async/SplitFileFetcher.java +++ b/src/main/java/network/crypta/client/async/SplitFileFetcher.java @@ -25,6 +25,7 @@ import network.crypta.support.compress.Compressor.COMPRESSOR_TYPE; import network.crypta.support.io.BucketTools; import network.crypta.support.io.FileUtil; +import network.crypta.support.io.IOUtils; import network.crypta.support.io.InsufficientDiskSpaceException; import network.crypta.support.io.PooledFileRandomAccessBuffer; import network.crypta.support.io.ResumeFailedException; @@ -791,40 +792,48 @@ public boolean writeTrivialProgress(DataOutputStream dos) throws IOException { public SplitFileFetcher(ClientGetter getter, DataInputStream dis, ClientContext context) throws StorageFormatException, ResumeFailedException, IOException { LOG.info("Resuming splitfile download for {}", this); - boolean completeViaTruncation = dis.readBoolean(); - if (completeViaTruncation) { - fileCompleteViaTruncation = new File(dis.readUTF()); - if (!fileCompleteViaTruncation.exists()) - throw new ResumeFailedException( - "Storage file does not exist: " + fileCompleteViaTruncation); - callbackCompleteViaTruncation = getter; - long rafSize = dis.readLong(); - if (fileCompleteViaTruncation.length() != rafSize) - throw new ResumeFailedException("Storage file is not of the correct length"); - // Note: Could verify against finalLength to finish immediately if it matches. - this.raf = - new PooledFileRandomAccessBuffer(fileCompleteViaTruncation, false, rafSize, -1, true); - } else { - this.raf = - BucketTools.restoreRAFFrom( - dis, - context.persistentFG, - context.getPersistentFileTracker(), - context.getPersistentMasterSecret()); - fileCompleteViaTruncation = null; - callbackCompleteViaTruncation = null; + LockableRandomAccessBuffer restored = null; + boolean success = false; + try { + boolean completeViaTruncation = dis.readBoolean(); + if (completeViaTruncation) { + fileCompleteViaTruncation = new File(dis.readUTF()); + if (!fileCompleteViaTruncation.exists()) + throw new ResumeFailedException( + "Storage file does not exist: " + fileCompleteViaTruncation); + callbackCompleteViaTruncation = getter; + long rafSize = dis.readLong(); + if (fileCompleteViaTruncation.length() != rafSize) + throw new ResumeFailedException("Storage file is not of the correct length"); + // Note: Could verify against finalLength to finish immediately if it matches. + restored = + new PooledFileRandomAccessBuffer(fileCompleteViaTruncation, false, rafSize, -1, true); + } else { + restored = + BucketTools.restoreRAFFrom( + dis, + context.persistentFG, + context.getPersistentFileTracker(), + context.getPersistentMasterSecret()); + fileCompleteViaTruncation = null; + callbackCompleteViaTruncation = null; + } + this.raf = restored; + this.parent = getter; + this.cb = getter; + this.persistent = true; + this.realTimeFlag = parent.realTimeFlag(); + this.requestKey = null; + token = dis.readLong(); + this.blockFetchContext = getter.ctx; + this.wantBinaryBlob = getter.collectingBinaryBlob(); + // onResume() will do the rest. + LOG.info("Resumed splitfile download for {}", this); + lastNotifiedStoreFetch = System.currentTimeMillis(); + success = true; + } finally { + if (!success) IOUtils.closeQuietly(restored); } - this.parent = getter; - this.cb = getter; - this.persistent = true; - this.realTimeFlag = parent.realTimeFlag(); - this.requestKey = null; - token = dis.readLong(); - this.blockFetchContext = getter.ctx; - this.wantBinaryBlob = getter.collectingBinaryBlob(); - // onResume() will do the rest. - LOG.info("Resumed splitfile download for {}", this); - lastNotifiedStoreFetch = System.currentTimeMillis(); } /** diff --git a/src/main/java/network/crypta/client/async/SplitFileInserterStorage.java b/src/main/java/network/crypta/client/async/SplitFileInserterStorage.java index fc31b273c6..6e5e9ec914 100644 --- a/src/main/java/network/crypta/client/async/SplitFileInserterStorage.java +++ b/src/main/java/network/crypta/client/async/SplitFileInserterStorage.java @@ -52,6 +52,7 @@ import network.crypta.support.io.ArrayBucket; import network.crypta.support.io.ArrayBucketFactory; import network.crypta.support.io.BucketTools; +import network.crypta.support.io.IOUtils; import network.crypta.support.io.NullBucket; import network.crypta.support.io.RAFInputStream; import network.crypta.support.io.ResumeFailedException; @@ -1128,7 +1129,14 @@ private LockableRandomAccessBuffer chooseOriginalData( LockableRandomAccessBuffer passed, LockableRandomAccessBuffer restored) throws StorageFormatException { if (passed == null) return restored; - if (!passed.equals(restored)) + if (passed == restored) return passed; + boolean same; + try { + same = passed.equals(restored); + } finally { + IOUtils.closeQuietly(restored); + } + if (!same) throw new StorageFormatException( "Original data restored from different filename! Expected " + passed diff --git a/src/main/java/network/crypta/crypt/EncryptedRandomAccessBuffer.java b/src/main/java/network/crypta/crypt/EncryptedRandomAccessBuffer.java index 092f1758b0..a9422a57c5 100755 --- a/src/main/java/network/crypta/crypt/EncryptedRandomAccessBuffer.java +++ b/src/main/java/network/crypta/crypt/EncryptedRandomAccessBuffer.java @@ -22,6 +22,7 @@ import network.crypta.support.api.LockableRandomAccessBuffer; import network.crypta.support.io.BucketTools; import network.crypta.support.io.FilenameGenerator; +import network.crypta.support.io.IOUtils; import network.crypta.support.io.PersistentFileTracker; import network.crypta.support.io.ResumeFailedException; import network.crypta.support.io.StorageFormatException; @@ -578,11 +579,17 @@ public static LockableRandomAccessBuffer create( if (type == null) throw new StorageFormatException("Unknown EncryptedRandomAccessBufferType"); LockableRandomAccessBuffer underlying = BucketTools.restoreRAFFrom(dis, fg, persistentFileTracker, masterKey); + boolean success = false; try { - return new EncryptedRandomAccessBuffer(type, underlying, masterKey, false); + EncryptedRandomAccessBuffer restored = + new EncryptedRandomAccessBuffer(type, underlying, masterKey, false); + success = true; + return restored; } catch (GeneralSecurityException e) { throw new ResumeFailedException( new GeneralSecurityException("Crypto error resuming EncryptedRandomAccessBuffer", e)); + } finally { + if (!success) IOUtils.closeQuietly(underlying); } } diff --git a/src/main/java/network/crypta/support/io/PaddedRandomAccessBuffer.java b/src/main/java/network/crypta/support/io/PaddedRandomAccessBuffer.java index 11da0b894f..65b89de75d 100644 --- a/src/main/java/network/crypta/support/io/PaddedRandomAccessBuffer.java +++ b/src/main/java/network/crypta/support/io/PaddedRandomAccessBuffer.java @@ -197,9 +197,17 @@ public PaddedRandomAccessBuffer( throws ResumeFailedException, IOException, StorageFormatException { realSize = dis.readLong(); if (realSize < 0) throw new StorageFormatException("Negative length"); - raf = BucketTools.restoreRAFFrom(dis, fg, persistentFileTracker, masterSecret); - if (realSize > raf.size()) - throw new ResumeFailedException("Padded file is smaller than expected length"); + LockableRandomAccessBuffer restored = + BucketTools.restoreRAFFrom(dis, fg, persistentFileTracker, masterSecret); + boolean success = false; + try { + if (realSize > restored.size()) + throw new ResumeFailedException("Padded file is smaller than expected length"); + raf = restored; + success = true; + } finally { + if (!success) IOUtils.closeQuietly(restored); + } } /** diff --git a/src/test/java/network/crypta/crypt/EncryptedRandomAccessBufferTest.java b/src/test/java/network/crypta/crypt/EncryptedRandomAccessBufferTest.java index d12f565237..b890ff7d34 100644 --- a/src/test/java/network/crypta/crypt/EncryptedRandomAccessBufferTest.java +++ b/src/test/java/network/crypta/crypt/EncryptedRandomAccessBufferTest.java @@ -8,6 +8,7 @@ import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; +import java.util.Locale; import java.util.Random; import java.util.stream.Stream; import network.crypta.client.async.ClientContext; @@ -27,6 +28,8 @@ import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + /** * Unit tests for {@link EncryptedRandomAccessBuffer}. * @@ -427,6 +430,44 @@ void restoreRAFFrom_whenWrongSecret_expectResumeFailedException( } } + @ParameterizedTest + @MethodSource("types") + @DisplayName("restoreRAFFrom_whenWrongSecret_onWindows_releasesUnderlyingFileHandle") + void restoreRAFFrom_whenWrongSecret_onWindows_releasesUnderlyingFileHandle( + EncryptedRandomAccessBufferType type) throws Exception { + assumeTrue(System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win")); + + // Arrange + File file = new File(tmpDir, "underlying-win-" + type.name() + ".dat"); + int payload = 64; + MasterSecret secret = deterministicSecret(); + byte[] persisted; + try (FileRandomAccessBuffer fab = + new FileRandomAccessBuffer(file, type.headerLen + payload, false); + EncryptedRandomAccessBuffer raf = + new EncryptedRandomAccessBuffer(type, fab, secret, true)) { + raf.pwrite(0, new byte[payload], 0, payload); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try (DataOutputStream dos = new DataOutputStream(bos)) { + raf.storeTo(dos); + } + persisted = bos.toByteArray(); + } + + FilenameGenerator fg = new FilenameGenerator(new Random(9123L), false, tmpDir, "t-win-"); + PersistentFileTracker pft = new DummyTracker(tmpDir, fg); + MasterSecret wrong = new MasterSecret(new byte[64]); + + // Act + Assert + try (DataInputStream dis = new DataInputStream(new ByteArrayInputStream(persisted))) { + assertThrows( + ResumeFailedException.class, () -> BucketTools.restoreRAFFrom(dis, fg, pft, wrong)); + } + + assertTrue(file.delete(), "Expected failed restore to leave no open file handle"); + } + // ---------------- Java serialization ---------------- @ParameterizedTest diff --git a/src/test/java/network/crypta/support/io/PaddedRandomAccessBufferTest.java b/src/test/java/network/crypta/support/io/PaddedRandomAccessBufferTest.java index 1dc88dc27f..0c55a842dc 100644 --- a/src/test/java/network/crypta/support/io/PaddedRandomAccessBufferTest.java +++ b/src/test/java/network/crypta/support/io/PaddedRandomAccessBufferTest.java @@ -7,6 +7,8 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.nio.file.Path; +import java.util.Locale; import network.crypta.client.async.ClientContext; import network.crypta.crypt.MasterSecret; import network.crypta.support.api.LockableRandomAccessBuffer.RAFLock; @@ -14,6 +16,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mockito; @@ -25,6 +28,8 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyInt; import static org.mockito.Mockito.anyLong; @@ -307,6 +312,32 @@ void constructor_whenRealSizeGreaterThanInner_throwsResumeFailed() throws Except } } + @Test + void constructor_whenRealSizeGreaterThanInner_onWindows_releasesFileHandle( + @TempDir Path tempDir) throws Exception { + assumeTrue(System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win")); + + // Arrange + File tmp = tempDir.resolve("underlying.dat").toFile(); + try (FileOutputStream fos = new FileOutputStream(tmp)) { + fos.write(new byte[10]); + } + + // Act + Assert + try (DataInputStream din = buildPaddedRafStream(12L, tmp)) { + assertThrows( + ResumeFailedException.class, + () -> + new PaddedRandomAccessBuffer( + din, + mock(FilenameGenerator.class), + mock(PersistentFileTracker.class), + mock(MasterSecret.class))); + } + + assertTrue(tmp.delete(), "Expected failed restore to leave no open file handle"); + } + @Test void constructor_whenValid_buildsAndEnforcesPadding() throws Exception { // Arrange: inner size 10, realSize 6 From b0e003ab879991412f21107e2da870776150a189 Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Sun, 22 Feb 2026 05:59:50 +0000 Subject: [PATCH 2/6] fix(tests): Add resource handling and cleanup for tests and Windows compatibility Improve test stability by adding proper resource cleanup and robust handling for Windows-specific filesystem behavior such as hidden files and stale file handles. --- build.gradle.kts | 4 ++ .../crypta/launcher/LauncherController.java | 12 +++- .../crypta/launcher/LauncherUtils.java | 15 ++++- .../network/crypta/node/PeerPersistence.java | 17 +++-- .../network/crypta/support/BloomFilter.java | 65 ++++++++++++++++-- .../support/io/FileRandomAccessBuffer.java | 22 ++++++- .../network/crypta/support/io/FileUtil.java | 11 ++-- .../io/PooledFileRandomAccessBuffer.java | 46 ++++++++++--- .../support/io/ReadOnlyFileSliceBucket.java | 33 ++++++---- .../bitpedia/collider/core/Submission.java | 3 +- .../client/async/SplitFileFetcherTest.java | 55 +++++++++++----- .../fcp/ClientPutDiskDirMessageTest.java | 17 ++++- .../LocalDownloadDirectoryToadletTest.java | 12 ++-- .../java/network/crypta/crypt/YarrowTest.java | 7 +- .../launcher/LauncherControllerTest.java | 4 +- .../crypta/node/LoggingConfigHandlerTest.java | 6 +- .../network/crypta/node/NodeStarterTest.java | 17 +++-- .../node/simulator/BootstrapPullTestTest.java | 5 +- .../simulator/BootstrapPushPullTestTest.java | 6 +- .../node/simulator/LongTermTestTest.java | 6 +- .../node/updater/RevocationCheckerTest.java | 57 ++++++++++------ .../crypta/support/BinaryBloomFilterTest.java | 12 ++-- .../crypta/support/io/FallocateTest.java | 4 ++ .../crypta/support/io/FileUtilTest.java | 7 +- .../io/PooledFileRandomAccessBufferTest.java | 66 ++++++++++--------- .../java/network/crypta/tools/AddRefTest.java | 12 +++- .../crypta/tools/CleanupTranslationsTest.java | 7 +- 27 files changed, 382 insertions(+), 146 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 501ec82a46..b3b5565b18 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -77,6 +77,10 @@ tasks.register("printVersion") { doLast { println(project.version.toString()) } } +// The default from build-logic (512m) is too small for the full test suite on Windows and can +// trigger OOM in long-running integration tests. +tasks.withType().configureEach { maxHeapSize = "2g" } + // Application entrypoint (used by jpackage). This does not change how we build the wrapper // distribution; it's only to inform launchers that invoke the launcher main class directly. // Align with the actual launcher entrypoint in Launcher.java diff --git a/src/main/java/network/crypta/launcher/LauncherController.java b/src/main/java/network/crypta/launcher/LauncherController.java index 5b8f1750e5..26ec903ff5 100644 --- a/src/main/java/network/crypta/launcher/LauncherController.java +++ b/src/main/java/network/crypta/launcher/LauncherController.java @@ -176,7 +176,10 @@ public synchronized void start() { private void startProcessAndWatch() { Path cryptadPath = LauncherUtils.resolveCryptadPath(cwd); - if (!Files.isRegularFile(cryptadPath) || !Files.isExecutable(cryptadPath)) { + AppEnv env = new AppEnv(); + boolean launchable = + Files.isRegularFile(cryptadPath) && (env.isWindows() || Files.isExecutable(cryptadPath)); + if (!launchable) { emitLog(ts() + " ERROR: Cannot find executable 'cryptad' at " + cryptadPath); return; } @@ -247,6 +250,7 @@ private void watchProcess(Process started) { } emitLog(ts() + " cryptad exited with code " + exitCode); clearTrackedProcess(started); + interruptTailThread(); updateState(s -> s.withRunning(false)); } @@ -293,6 +297,11 @@ private void stopManagedProcess(Process current) { if (current.isAlive()) { current.destroyForcibly(); } + if (!current.isAlive()) { + clearTrackedProcess(current); + interruptTailThread(); + updateState(s -> s.withRunning(false)); + } } catch (Exception e) { emitLog(ts() + " ERROR: Failed to stop process: " + e.getMessage()); logDebug("Failed to stop process gracefully", e); @@ -799,6 +808,7 @@ public void shutdownAndWait() { if (current != null && current.isAlive()) { stopManagedProcess(current); } + interruptTailThread(); } private void tryEnableConsoleFlush(Path cryptadPath) { diff --git a/src/main/java/network/crypta/launcher/LauncherUtils.java b/src/main/java/network/crypta/launcher/LauncherUtils.java index fedc18440b..20e5678016 100644 --- a/src/main/java/network/crypta/launcher/LauncherUtils.java +++ b/src/main/java/network/crypta/launcher/LauncherUtils.java @@ -420,7 +420,7 @@ private static Path resolveCryptadPathFromJar(boolean isWindows) { candidates.add(jarDir.resolve("../bin/" + CRYPTAD_SCRIPT).normalize()); for (Path candidate : candidates) { - if (Files.isRegularFile(candidate) && Files.isExecutable(candidate)) { + if (isLaunchScriptCandidate(candidate, isWindows)) { return candidate; } } @@ -439,13 +439,24 @@ private static Path resolveCryptadPathFromCwd(Path cwd, boolean isWindows) { candidates.add(cwd.resolve(CRYPTAD_SCRIPT)); for (Path candidate : candidates) { - if (Files.isRegularFile(candidate) && Files.isExecutable(candidate)) { + if (isLaunchScriptCandidate(candidate, isWindows)) { return candidate; } } return null; } + private static boolean isLaunchScriptCandidate(Path candidate, boolean isWindows) { + if (!Files.isRegularFile(candidate)) { + return false; + } + // Windows launch scripts are often .bat files where executable bit checks are unreliable. + if (isWindows) { + return true; + } + return Files.isExecutable(candidate); + } + private static boolean isCryptadJarFile(Path path) { if (!Files.isRegularFile(path)) { return false; diff --git a/src/main/java/network/crypta/node/PeerPersistence.java b/src/main/java/network/crypta/node/PeerPersistence.java index 011adb890b..01d783a7af 100644 --- a/src/main/java/network/crypta/node/PeerPersistence.java +++ b/src/main/java/network/crypta/node/PeerPersistence.java @@ -514,21 +514,24 @@ private void writePeersInner(String filename, String sb, int maxBackups, boolean w.write(sb); w.flush(); fos.getFD().sync(); - - if (rotateBackups) { - rotateBackupFiles(filename, maxBackups, f); - } else { - FileUtil.moveTo(f, getBackupFilename(filename, 0)); - } } catch (FileNotFoundException e2) { LOG.error("Cannot write peers to disk: cannot create {} (error={})", f, e2, e2); safeDeleteIfExists(f); + return; } catch (IOException e) { LOG.error("I/O error writing peers file: {}", e, e); safeDeleteIfExists(f); // don't overwrite the old file! + return; + } + + try { + if (rotateBackups) { + rotateBackupFiles(filename, maxBackups, f); + } else { + FileUtil.moveTo(f, getBackupFilename(filename, 0)); + } } finally { - // Try-with-resources handles the stream cleanup safeDeleteIfExists(f); } } diff --git a/src/main/java/network/crypta/support/BloomFilter.java b/src/main/java/network/crypta/support/BloomFilter.java index cc031f5429..0357568903 100644 --- a/src/main/java/network/crypta/support/BloomFilter.java +++ b/src/main/java/network/crypta/support/BloomFilter.java @@ -4,6 +4,8 @@ import java.io.IOException; import java.io.OutputStream; import java.lang.ref.Cleaner; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.nio.ByteBuffer; import java.nio.MappedByteBuffer; import java.util.Random; @@ -36,6 +38,7 @@ */ public abstract class BloomFilter implements AutoCloseable { private static final Cleaner cleaner = Cleaner.create(); + private static final UnsafeCleaner UNSAFE_CLEANER = UnsafeCleaner.load(); /** Backing buffer for the filter’s bytes (bits or counters), owned by subclasses. */ protected ByteBuffer filter; @@ -422,8 +425,10 @@ public void force() { */ @Override public void close() { - if (filter != null) { - force(); + ByteBuffer current = filter; + if (current != null) { + forceBuffer(current); + UNSAFE_CLEANER.clean(current); } filter = null; forkedFilter = null; @@ -481,7 +486,9 @@ public int copyTo(byte[] buf, int offset) { lock.readLock().lock(); try { int capacity = filter.capacity(); - System.arraycopy(filter.array(), filter.arrayOffset(), buf, offset, capacity); + ByteBuffer snapshot = filter.duplicate(); + snapshot.position(0); + snapshot.get(buf, offset, capacity); return capacity; } finally { lock.readLock().unlock(); @@ -495,6 +502,56 @@ public int copyTo(byte[] buf, int offset) { * @throws IOException if the write fails */ public void writeTo(OutputStream cos) throws IOException { - cos.write(filter.array(), filter.arrayOffset(), filter.capacity()); + ByteBuffer snapshot = filter.duplicate(); + snapshot.position(0); + byte[] chunk = new byte[Math.min(8192, snapshot.remaining())]; + while (snapshot.hasRemaining()) { + int n = Math.min(chunk.length, snapshot.remaining()); + snapshot.get(chunk, 0, n); + cos.write(chunk, 0, n); + } + } + + private static void forceBuffer(ByteBuffer buffer) { + if (buffer instanceof MappedByteBuffer mapped) { + mapped.force(); + } + } + + private static final class UnsafeCleaner { + private final Object unsafe; + private final Method invokeCleanerMethod; + + private UnsafeCleaner(Object unsafe, Method invokeCleanerMethod) { + this.unsafe = unsafe; + this.invokeCleanerMethod = invokeCleanerMethod; + } + + static UnsafeCleaner load() { + try { + Class unsafeClass = Class.forName("sun.misc.Unsafe"); + Field theUnsafe = unsafeClass.getDeclaredField("theUnsafe"); + theUnsafe.setAccessible(true); + Object unsafe = theUnsafe.get(null); + Method invokeCleaner = unsafeClass.getMethod("invokeCleaner", ByteBuffer.class); + return new UnsafeCleaner(unsafe, invokeCleaner); + } catch (ReflectiveOperationException | RuntimeException _) { + return new UnsafeCleaner(null, null); + } + } + + void clean(ByteBuffer buffer) { + if (!(buffer instanceof MappedByteBuffer)) { + return; + } + if (unsafe == null || invokeCleanerMethod == null) { + return; + } + try { + invokeCleanerMethod.invoke(unsafe, buffer); + } catch (ReflectiveOperationException | RuntimeException _) { + // Best-effort; dropping strong references still allows eventual unmapping via GC. + } + } } } diff --git a/src/main/java/network/crypta/support/io/FileRandomAccessBuffer.java b/src/main/java/network/crypta/support/io/FileRandomAccessBuffer.java index f07481392f..ceb67d0a3c 100644 --- a/src/main/java/network/crypta/support/io/FileRandomAccessBuffer.java +++ b/src/main/java/network/crypta/support/io/FileRandomAccessBuffer.java @@ -280,7 +280,8 @@ public void setSecureDelete(boolean secureDelete) { * Re-opens the underlying file after persistence resuming. * *

This method validates that the target file still exists and matches the expected length, - * then re-opens it in the recorded mode. + * then re-opens it in the recorded mode. If a stale handle is still present, it is closed before + * replacing it so callers can safely call {@link #free()} after resume on all platforms. * * @param context client context provided by the caller (not used here but part of the contract) * @throws ResumeFailedException if the file is missing or the length differs @@ -289,12 +290,27 @@ public void setSecureDelete(boolean secureDelete) { public void onResume(ClientContext context) throws ResumeFailedException { if (!file.exists()) throw new ResumeFailedException("File does not exist any more"); if (file.length() != length) throw new ResumeFailedException("File is wrong length"); + RandomAccessFile reopened; try { - if (raf == null) raf = new AtomicReference<>(); - raf.set(new RandomAccessFile(file, readOnly ? "r" : "rw")); + reopened = new RandomAccessFile(file, readOnly ? "r" : "rw"); } catch (FileNotFoundException _) { throw new ResumeFailedException("File does not exist any more"); } + + RandomAccessFile previous; + synchronized (this) { + if (raf == null) raf = new AtomicReference<>(); + previous = raf.getAndSet(reopened); + closed = false; + } + + if (previous != null) { + try { + previous.close(); + } catch (IOException e) { + LOG.warn("Could not close stale RandomAccessFile during resume for {}", file, e); + } + } } // Magic and version used for lightweight persistence of buffer metadata. diff --git a/src/main/java/network/crypta/support/io/FileUtil.java b/src/main/java/network/crypta/support/io/FileUtil.java index 6effbbc984..182df9e6b0 100644 --- a/src/main/java/network/crypta/support/io/FileUtil.java +++ b/src/main/java/network/crypta/support/io/FileUtil.java @@ -510,11 +510,14 @@ public static boolean moveTo(File orig, File dest) { try { Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); return true; - } catch (AtomicMoveNotSupportedException | FileAlreadyExistsException _) { - // Fall back to a non-atomic move allowing replacement. + } catch (AtomicMoveNotSupportedException | FileAlreadyExistsException e) { + if (LOG.isDebugEnabled()) { + LOG.debug("Atomic move unavailable for {} -> {}: {}", orig, dest, e.toString()); + } } catch (IOException e) { - LOG.error("Atomic move failed for {} -> {}: {}", orig, dest, e, e); - return false; + // On Windows this frequently fails when replacing an existing file; retry with + // REPLACE_EXISTING before giving up. + LOG.warn("Atomic move failed for {} -> {}, retrying non-atomically: {}", orig, dest, e); } try { Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); diff --git a/src/main/java/network/crypta/support/io/PooledFileRandomAccessBuffer.java b/src/main/java/network/crypta/support/io/PooledFileRandomAccessBuffer.java index 4ceabde3d6..6e7c999112 100644 --- a/src/main/java/network/crypta/support/io/PooledFileRandomAccessBuffer.java +++ b/src/main/java/network/crypta/support/io/PooledFileRandomAccessBuffer.java @@ -212,11 +212,8 @@ public PooledFileRandomAccessBuffer( } this.length = currentLength; lock.unlock(); - } catch (IOException e) { - synchronized (this) { - raf.close(); - raf = null; - } + } catch (IOException | RuntimeException e) { + cleanupAfterConstructorFailure(lock, e); throw e; } } @@ -241,15 +238,44 @@ public PooledFileRandomAccessBuffer( try { raf.write(initialContents, offset, size); lock.unlock(); - } catch (IOException e) { - synchronized (this) { - raf.close(); - raf = null; - } + } catch (IOException | RuntimeException e) { + cleanupAfterConstructorFailure(lock, e); throw e; } } + /** + * Best-effort rollback for constructor failures. + * + *

When construction fails after acquiring a pool lock, the partially initialized instance can + * otherwise leak an open descriptor and lock state. This helper tries to unlock and close through + * the normal lifecycle; if that fails, it force-closes the underlying RAF state while preserving + * the original exception. + */ + private void cleanupAfterConstructorFailure(RAFLock lock, Throwable failure) { + try { + lock.unlock(); + } catch (RuntimeException unlockFailure) { + failure.addSuppressed(unlockFailure); + } + + try { + close(); + return; + } catch (RuntimeException closeFailure) { + failure.addSuppressed(closeFailure); + } + + final Object monitor = fds.lock; + synchronized (monitor) { + lockLevel = 0; + closed = true; + fds.closables.remove(this); + closeRAF(); + monitor.notifyAll(); + } + } + /** * Serialization‑only constructor used by deserialization frameworks. * diff --git a/src/main/java/network/crypta/support/io/ReadOnlyFileSliceBucket.java b/src/main/java/network/crypta/support/io/ReadOnlyFileSliceBucket.java index 689aebc2ca..cd28a1fe53 100644 --- a/src/main/java/network/crypta/support/io/ReadOnlyFileSliceBucket.java +++ b/src/main/java/network/crypta/support/io/ReadOnlyFileSliceBucket.java @@ -152,17 +152,28 @@ private final class MyInputStream extends InputStream { */ MyInputStream() throws IOException { try { - this.f = new RandomAccessFile(file, "r"); - f.seek(startAt); - if (f.length() < (startAt + length)) - throw new ReadOnlyFileSliceBucketException( - "File truncated? Length " - + f.length() - + " but start at " - + startAt - + " for " - + length - + " bytes"); + RandomAccessFile raf = new RandomAccessFile(file, "r"); + try { + raf.seek(startAt); + long fileLength = raf.length(); + if (fileLength < (startAt + length)) + throw new ReadOnlyFileSliceBucketException( + "File truncated? Length " + + fileLength + + " but start at " + + startAt + + " for " + + length + + " bytes"); + this.f = raf; + } catch (IOException e) { + try { + raf.close(); + } catch (IOException closeFailure) { + e.addSuppressed(closeFailure); + } + throw e; + } ptr = 0; } catch (FileNotFoundException e) { throw new ReadOnlyFileSliceBucketException(e); diff --git a/src/main/java/org/bitpedia/collider/core/Submission.java b/src/main/java/org/bitpedia/collider/core/Submission.java index 2e56cb14da..c56d6ae4e4 100644 --- a/src/main/java/org/bitpedia/collider/core/Submission.java +++ b/src/main/java/org/bitpedia/collider/core/Submission.java @@ -473,8 +473,7 @@ public boolean getBitprintData( * @return filename segment without parent directories; identical input when no separator exists. */ public static String extractName(String fileName) { - - int sepPos = fileName.lastIndexOf(File.separatorChar); + int sepPos = Math.max(fileName.lastIndexOf('/'), fileName.lastIndexOf('\\')); if (-1 != sepPos) { fileName = fileName.substring(sepPos + 1); } diff --git a/src/test/java/network/crypta/client/async/SplitFileFetcherTest.java b/src/test/java/network/crypta/client/async/SplitFileFetcherTest.java index 8133596973..ed0bde1296 100644 --- a/src/test/java/network/crypta/client/async/SplitFileFetcherTest.java +++ b/src/test/java/network/crypta/client/async/SplitFileFetcherTest.java @@ -79,6 +79,25 @@ private static void setParentCtx(ClientGetter parent, FetchContext ctx) { } } + @SuppressWarnings("java:S3011") + private static void cleanupFetcherTempFile(SplitFileFetcher fetcher, File tmp) { + try { + Field rafField = SplitFileFetcher.class.getDeclaredField("raf"); + rafField.setAccessible(true); + Object raw = rafField.get(fetcher); + if (raw instanceof LockableRandomAccessBuffer raf) { + raf.close(); + raf.free(); + } + rafField.set(fetcher, null); + } catch (Exception ignored) { + // Best-effort cleanup to avoid leaking mapped files on Windows. + } + if (!tmp.delete() && tmp.exists()) { + tmp.deleteOnExit(); + } + } + private static byte[] resumeRecordForTruncation(File file, long size, long token) throws IOException { ByteArrayOutputStream bos = new ByteArrayOutputStream(); @@ -131,7 +150,7 @@ void getToken_returnsValueFromResumeRecord() throws Exception { long token = 987654321L; SplitFileFetcher f = newResumedFetcherWithTruncation(tmp, tmp.length(), token); assertEquals(token, f.getToken()); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -148,7 +167,7 @@ void onFetchedBlock_whenGetterHasQueued_expectNotifyFalse() throws Exception { f.onFetchedBlock(); verify(parent).completedBlock(false, clientContext); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -168,7 +187,7 @@ void onFetchedBlock_whenBelowThresholdAndRecent_expectDontNotifyTrue() throws Ex f.onFetchedBlock(); verify(parent).completedBlock(true, clientContext); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -187,7 +206,7 @@ void onFetchedBlock_whenCounterReachesThreshold_expectNotifyFalse() throws Excep f.onFetchedBlock(); verify(parent).completedBlock(false, clientContext); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -207,7 +226,7 @@ void onFetchedBlock_whenTimeElapsed_expectNotifyFalse() throws Exception { f.onFetchedBlock(); verify(parent).completedBlock(false, clientContext); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -222,7 +241,7 @@ void onFailedBlock_delegatesToParent() throws Exception { f.onFailedBlock(); verify(parent).failedBlock(clientContext); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -240,7 +259,7 @@ void cancel_invokesCallbackWithCancelled() throws Exception { verify(parent).onFailure(captor.capture(), eq(f), eq(clientContext)); assertEquals(FetchExceptionMode.CANCELLED, captor.getValue().getMode()); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -267,7 +286,7 @@ void onSuccess_withTruncation_callsFileCallbackAndCancelsGetter() throws Excepti // Scheduler invocation to remove pending keys (null keyListener on a mock storage is fine) verify(scheduler) .removePendingKeys((KeyListener) org.mockito.ArgumentMatchers.isNull(), eq(true)); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -293,7 +312,7 @@ void onResume_countsAndCallbacksCorrectly() throws Exception { verify(parent).blockSetFinalized(clientContext); verify(parent).onExpectedMIME(meta, clientContext); verify(parent).onExpectedSize(123L, clientContext); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -310,7 +329,7 @@ void setSplitfileBlocks_delegatesToParentAndNotify() throws Exception { verify(parent).addMustSucceedBlocks(4); verify(parent).addBlocks(7); verify(parent).notifyClients(clientContext); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -340,7 +359,7 @@ void onSplitfileCompatibilityMode_delegatesToCallback() throws Exception { false, true, clientContext); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -355,7 +374,7 @@ void maybeAddToBinaryBlob_whenParentIsClientGetter_delegatesToParent() throws Ex ClientCHKBlock block = mock(ClientCHKBlock.class); f.maybeAddToBinaryBlob(block); verify(parent).addKeyToBinaryBlob(block, clientContext); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -367,7 +386,7 @@ void getSendableGet_returnsAssignedGetter() throws Exception { SplitFileFetcher f = newResumedFetcherWithTruncation(tmp, tmp.length(), 12L); setField(f, "getter", sendableGetter); assertEquals(sendableGetter, f.getSendableGet()); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -385,7 +404,7 @@ void clearCooldown_and_reduceCooldown_delegateWhenNotFinished() throws Exception verify(sendableGetter).clearWakeupTime(clientContext); verify(sendableGetter).reduceWakeupTime(123L, clientContext); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -402,7 +421,7 @@ void hasFinished_reflectsFailAndSuccess() throws Exception { setField(f, "context", clientContext); f.cancel(clientContext); assertTrue(f.hasFinished()); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -444,7 +463,7 @@ void writeTrivialProgress_withTruncation_writesExpectedHeaderAndToken() throws E assertEquals(tmp.getAbsolutePath(), path); assertEquals(tmp.length(), size); assertEquals(token, tok); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -479,7 +498,7 @@ void getPriorityClass_and_toNetwork_delegateToParent() throws Exception { assertEquals(5, f.getPriorityClass()); f.toNetwork(); verify(parent).toNetwork(clientContext); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } @Test @@ -505,6 +524,6 @@ void localRequestOnly_reflectsFetchContextFromParent() throws Exception { } SplitFileFetcher f = newResumedFetcherWithTruncation(tmp, tmp.length(), 16L); assertTrue(f.localRequestOnly()); - assertTrue(tmp.delete() || !tmp.exists()); + cleanupFetcherTempFile(f, tmp); } } diff --git a/src/test/java/network/crypta/clients/fcp/ClientPutDiskDirMessageTest.java b/src/test/java/network/crypta/clients/fcp/ClientPutDiskDirMessageTest.java index 9833846c64..bd6ec3a2a3 100644 --- a/src/test/java/network/crypta/clients/fcp/ClientPutDiskDirMessageTest.java +++ b/src/test/java/network/crypta/clients/fcp/ClientPutDiskDirMessageTest.java @@ -1,6 +1,7 @@ package network.crypta.clients.fcp; import java.io.File; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Files; @@ -94,7 +95,14 @@ void run_whenDirectoryContainsFiles_passesManifestToHandler() throws Exception { Path file = Files.writeString(tempDir.resolve("index.html"), ""); Path nestedDir = Files.createDirectory(tempDir.resolve("assets")); Path nestedFile = Files.writeString(nestedDir.resolve("style.css"), "body"); - Files.writeString(tempDir.resolve(".secret"), "hidden"); + Path hiddenFile = Files.writeString(tempDir.resolve(".secret"), "hidden"); + if (!hiddenFile.toFile().isHidden()) { + try { + Files.setAttribute(hiddenFile, "dos:hidden", true); + } catch (UnsupportedOperationException | IOException _) { + // Platform does not expose DOS hidden attribute; fall back to runtime hidden semantics. + } + } ClientPutDiskDirMessage message = newMessage(tempDir); @@ -105,7 +113,12 @@ void run_whenDirectoryContainsFiles_passesManifestToHandler() throws Exception { verify(handler).startClientPutDir(eq(message), bucketsCaptor.capture(), eq(true)); HashMap buckets = bucketsCaptor.getValue(); - assertFalse(buckets.containsKey(".secret")); + String hiddenFileName = hiddenFile.getFileName().toString(); + if (hiddenFile.toFile().isHidden()) { + assertFalse(buckets.containsKey(hiddenFileName)); + } else { + assertTrue(buckets.containsKey(hiddenFileName)); + } ManifestElement rootElement = (ManifestElement) buckets.get("index.html"); assertEquals("index.html", rootElement.fullName); diff --git a/src/test/java/network/crypta/clients/http/LocalDownloadDirectoryToadletTest.java b/src/test/java/network/crypta/clients/http/LocalDownloadDirectoryToadletTest.java index 72759a65c1..ad3d13fdf9 100644 --- a/src/test/java/network/crypta/clients/http/LocalDownloadDirectoryToadletTest.java +++ b/src/test/java/network/crypta/clients/http/LocalDownloadDirectoryToadletTest.java @@ -53,13 +53,13 @@ void startingDir_selectsExpectedDefault(File[] allowedDirs, File downloadsDir, S } private static Stream startingDirScenarios() { + File downloads = new File("/downloads"); + File allowed = new File("/allowed/path"); + File other = new File("/other"); return Stream.of( - Arguments.of(new File[] {new File("all")}, new File("/downloads"), "/downloads"), - Arguments.of(new File[] {}, new File("/downloads"), "/downloads"), - Arguments.of( - new File[] {new File("/allowed/path"), new File("/other")}, - new File("/downloads"), - "/allowed/path")); + Arguments.of(new File[] {new File("all")}, downloads, downloads.getAbsolutePath()), + Arguments.of(new File[] {}, downloads, downloads.getAbsolutePath()), + Arguments.of(new File[] {allowed, other}, downloads, allowed.getAbsolutePath())); } @Test diff --git a/src/test/java/network/crypta/crypt/YarrowTest.java b/src/test/java/network/crypta/crypt/YarrowTest.java index 18b80e1a73..28e96ca1c8 100644 --- a/src/test/java/network/crypta/crypt/YarrowTest.java +++ b/src/test/java/network/crypta/crypt/YarrowTest.java @@ -7,6 +7,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; +import network.crypta.fs.AppEnv; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -206,7 +207,11 @@ void seedfile_whenPathIsDevUrandom_expectNull() { Yarrow y = new Yarrow(new File("/dev/urandom"), "SHA1", "Rijndael", true, false, false); // Assert - assertNull(y.seedfile); + if (new AppEnv().isWindows()) { + assertNotNull(y.seedfile); + } else { + assertNull(y.seedfile); + } } @Test diff --git a/src/test/java/network/crypta/launcher/LauncherControllerTest.java b/src/test/java/network/crypta/launcher/LauncherControllerTest.java index 362df37460..252ce6f60b 100644 --- a/src/test/java/network/crypta/launcher/LauncherControllerTest.java +++ b/src/test/java/network/crypta/launcher/LauncherControllerTest.java @@ -62,7 +62,7 @@ void start_whenScriptRuns_updatesStateAndReadsPort() throws Exception { assertFalse(stopped.isRunning()); awaitLog(logs, l -> l.contains("Starting FProxy on")); - assertTrue(logs.stream().anyMatch(l -> l.contains("Starting 'cryptad'"))); + assertTrue(logs.stream().anyMatch(l -> l.contains("Starting 'cryptad"))); assertTrue(logs.stream().anyMatch(l -> l.contains("exec:"))); var confLines = Files.readAllLines(wrapperConf, StandardCharsets.UTF_8); @@ -168,7 +168,7 @@ private static String awaitLog( private static AppState awaitState( LauncherController controller, java.util.function.Predicate predicate) { - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(40); while (System.nanoTime() < deadline) { AppState state = controller.getState(); if (predicate.test(state)) { diff --git a/src/test/java/network/crypta/node/LoggingConfigHandlerTest.java b/src/test/java/network/crypta/node/LoggingConfigHandlerTest.java index a977a687e7..08b32a1575 100644 --- a/src/test/java/network/crypta/node/LoggingConfigHandlerTest.java +++ b/src/test/java/network/crypta/node/LoggingConfigHandlerTest.java @@ -9,6 +9,7 @@ import ch.qos.logback.core.rolling.RollingFileAppender; import ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy; import java.io.File; +import java.nio.file.Files; import java.util.ArrayList; import java.util.Iterator; import java.util.List; @@ -22,7 +23,6 @@ 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 org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.slf4j.Logger; @@ -189,7 +189,9 @@ void priorityDetail_withInvalidLevel_throwsInvalidConfigValueException() throws } @Test - void dirname_whenChanged_updatesAppenderTargets(@TempDir File tmp) throws Exception { + void dirname_whenChanged_updatesAppenderTargets() throws Exception { + File tmp = Files.createTempDirectory("logging-config-handler-").toFile(); + tmp.deleteOnExit(); PersistentConfig cfg = new PersistentConfig(new SimpleFieldSet(true)); SubConfig logging = cfg.createSubConfig("logger"); new LoggingConfigHandler(logging); diff --git a/src/test/java/network/crypta/node/NodeStarterTest.java b/src/test/java/network/crypta/node/NodeStarterTest.java index f70e9ee54b..364aa310fe 100644 --- a/src/test/java/network/crypta/node/NodeStarterTest.java +++ b/src/test/java/network/crypta/node/NodeStarterTest.java @@ -2,6 +2,7 @@ import java.io.File; import java.lang.reflect.Field; +import java.nio.file.Files; import java.security.SecureRandom; import java.util.Properties; import network.crypta.config.PersistentConfig; @@ -128,8 +129,9 @@ void createTestNode_whenCalled_constructsNodeWithPersistentConfigAndCleansPeers( } @Test - void start_whenNodeStartupThrowsNodeInitException_andWrapperControlled_returnsExitCode( - @TempDir File tmpDir) throws ReflectiveOperationException { + void start_whenNodeStartupThrowsNodeInitException_andWrapperControlled_returnsExitCode() + throws ReflectiveOperationException, java.io.IOException { + File tmpDir = createStandaloneTempDir("node-starter-wrapper-"); NodeStarter ns = newNodeStarterViaReflection(); int expectedExitCode = NodeInitException.EXIT_COULD_NOT_START_UPDATER; String[] args = startupArgs(tmpDir); @@ -156,8 +158,9 @@ void start_whenNodeStartupThrowsNodeInitException_andWrapperControlled_returnsEx } @Test - void start_whenNodeStartupThrowsNodeInitException_andNotWrapper_returnsExitCode( - @TempDir File tmpDir) throws ReflectiveOperationException { + void start_whenNodeStartupThrowsNodeInitException_andNotWrapper_returnsExitCode() + throws ReflectiveOperationException, java.io.IOException { + File tmpDir = createStandaloneTempDir("node-starter-non-wrapper-"); NodeStarter ns = newNodeStarterViaReflection(); int expectedExitCode = NodeInitException.EXIT_COULD_NOT_START_UPDATER; String[] args = startupArgs(tmpDir); @@ -418,4 +421,10 @@ private static NodeStarter newNodeStarterViaReflection() throws ReflectiveOperat ctor.setAccessible(true); return ctor.newInstance(); } + + private static File createStandaloneTempDir(String prefix) throws java.io.IOException { + File dir = Files.createTempDirectory(prefix).toFile(); + dir.deleteOnExit(); + return dir; + } } diff --git a/src/test/java/network/crypta/node/simulator/BootstrapPullTestTest.java b/src/test/java/network/crypta/node/simulator/BootstrapPullTestTest.java index 766ed8db0a..22704dff02 100644 --- a/src/test/java/network/crypta/node/simulator/BootstrapPullTestTest.java +++ b/src/test/java/network/crypta/node/simulator/BootstrapPullTestTest.java @@ -22,6 +22,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import network.crypta.fs.AppEnv; import network.crypta.keys.FreenetURI; import org.jspecify.annotations.NonNull; import org.junit.jupiter.api.AfterEach; @@ -145,8 +146,8 @@ void insertData_whenProtocolError_expectSystemExitInserterProblem() throws Excep } private static int expectedOsExitCode(int javaExitCode) { - // POSIX process exit codes are 8-bit; System.exit(>255) wraps. - return javaExitCode & 0xFF; + // POSIX process exit codes are 8-bit; Windows preserves the full 32-bit exit status. + return new AppEnv().isWindows() ? javaExitCode : javaExitCode & 0xFF; } private static synchronized void setTestSize(int testSize) { diff --git a/src/test/java/network/crypta/node/simulator/BootstrapPushPullTestTest.java b/src/test/java/network/crypta/node/simulator/BootstrapPushPullTestTest.java index 1137da61a8..03710424cc 100644 --- a/src/test/java/network/crypta/node/simulator/BootstrapPushPullTestTest.java +++ b/src/test/java/network/crypta/node/simulator/BootstrapPushPullTestTest.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; +import network.crypta.fs.AppEnv; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -152,7 +153,10 @@ private static ProcessResult runMain(Path workingDir, List args) } private static void assertExitNoSeednodes(ProcessResult result) { - int expectedOsExitCode = Math.floorMod(BootstrapPushPullTest.EXIT_NO_SEEDNODES, 256); + int expectedOsExitCode = + new AppEnv().isWindows() + ? BootstrapPushPullTest.EXIT_NO_SEEDNODES + : Math.floorMod(BootstrapPushPullTest.EXIT_NO_SEEDNODES, 256); assertEquals( expectedOsExitCode, result.exitCode, diff --git a/src/test/java/network/crypta/node/simulator/LongTermTestTest.java b/src/test/java/network/crypta/node/simulator/LongTermTestTest.java index fdec438d59..a5183f0f00 100644 --- a/src/test/java/network/crypta/node/simulator/LongTermTestTest.java +++ b/src/test/java/network/crypta/node/simulator/LongTermTestTest.java @@ -9,6 +9,7 @@ import java.util.Date; import java.util.List; import java.util.concurrent.TimeUnit; +import network.crypta.fs.AppEnv; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -91,7 +92,10 @@ void writeToStatusLog_whenTargetIsDirectory_exitsWithExitThrewSomething() // Arrange Path statusDirectory = Files.createDirectory(tempDir.resolve("statusDir")); Path logFile = tempDir.resolve("exit-harness.log"); - int expectedProcessExitCode = LongTermTest.EXIT_THREW_SOMETHING & 0xFF; + int expectedProcessExitCode = + new AppEnv().isWindows() + ? LongTermTest.EXIT_THREW_SOMETHING + : LongTermTest.EXIT_THREW_SOMETHING & 0xFF; ProcessBuilder processBuilder = new ProcessBuilder( diff --git a/src/test/java/network/crypta/node/updater/RevocationCheckerTest.java b/src/test/java/network/crypta/node/updater/RevocationCheckerTest.java index fd3b57cf08..389debf5d9 100644 --- a/src/test/java/network/crypta/node/updater/RevocationCheckerTest.java +++ b/src/test/java/network/crypta/node/updater/RevocationCheckerTest.java @@ -242,13 +242,22 @@ void getBlobBuffer_whenOnlyOnDiskAndBlown_expectReadableBuffer() throws Exceptio // Act RandomAccessBucket bucket = checker.getBlobBucket(); RandomAccessBuffer buffer = checker.getBlobBuffer(); - - // Assert - assertNotNull(bucket); - assertNotNull(buffer); - byte[] data = new byte[(int) buffer.size()]; - buffer.pread(0, data, 0, data.length); - assertEquals("DISK", new String(data, StandardCharsets.UTF_8)); + try { + // Assert + assertNotNull(bucket); + assertNotNull(buffer); + byte[] data = new byte[(int) buffer.size()]; + buffer.pread(0, data, 0, data.length); + assertEquals("DISK", new String(data, StandardCharsets.UTF_8)); + } finally { + if (buffer != null) { + buffer.close(); + buffer.free(); + } + if (bucket != null) { + bucket.close(); + } + } } @Test @@ -306,19 +315,29 @@ void onSuccess_whenResultOk_expectHasBlownTrue_blowCalled_andBlobPersisted() thr // In-memory bucket returned when blown RandomAccessBucket rb = checker.getBlobBucket(); - assertNotNull(rb); - try (FileBucket onDisk = new FileBucket(blobFile, true, false, false, false)) { - assertEquals(onDisk.size(), rb.size()); - } - - // Buffer access returns the same bytes RandomAccessBuffer buf = checker.getBlobBuffer(); - assertNotNull(buf); - byte[] roundtrip = new byte[(int) buf.size()]; - buf.pread(0, roundtrip, 0, roundtrip.length); - assertEquals( - new String(blobBytes, StandardCharsets.UTF_8), - new String(roundtrip, StandardCharsets.UTF_8)); + try { + assertNotNull(rb); + try (FileBucket onDisk = new FileBucket(blobFile, true, false, false, false)) { + assertEquals(onDisk.size(), rb.size()); + } + + // Buffer access returns the same bytes + assertNotNull(buf); + byte[] roundtrip = new byte[(int) buf.size()]; + buf.pread(0, roundtrip, 0, roundtrip.length); + assertEquals( + new String(blobBytes, StandardCharsets.UTF_8), + new String(roundtrip, StandardCharsets.UTF_8)); + } finally { + if (buf != null) { + buf.close(); + buf.free(); + } + if (rb != null) { + rb.close(); + } + } } @Test diff --git a/src/test/java/network/crypta/support/BinaryBloomFilterTest.java b/src/test/java/network/crypta/support/BinaryBloomFilterTest.java index 2d5d92fd98..a8fb7257e1 100644 --- a/src/test/java/network/crypta/support/BinaryBloomFilterTest.java +++ b/src/test/java/network/crypta/support/BinaryBloomFilterTest.java @@ -263,14 +263,14 @@ void setupDir() throws IOException { if (!secureRoot.exists()) { assertTrue(secureRoot.mkdirs(), "Failed to create secure test temp root"); } - assertTrue(secureRoot.setReadable(true, true)); - assertTrue(secureRoot.setWritable(true, true)); - assertTrue(secureRoot.setExecutable(true, true)); + secureRoot.setReadable(true, true); + secureRoot.setWritable(true, true); + secureRoot.setExecutable(true, true); tempDir = Files.createTempDirectory(secureRoot.toPath(), "bbf-test-").toFile(); - assertTrue(tempDir.setReadable(true, true)); - assertTrue(tempDir.setWritable(true, true)); - assertTrue(tempDir.setExecutable(true, true)); + tempDir.setReadable(true, true); + tempDir.setWritable(true, true); + tempDir.setExecutable(true, true); tempDir.deleteOnExit(); } diff --git a/src/test/java/network/crypta/support/io/FallocateTest.java b/src/test/java/network/crypta/support/io/FallocateTest.java index 66f2d848f3..f7bf6ae142 100644 --- a/src/test/java/network/crypta/support/io/FallocateTest.java +++ b/src/test/java/network/crypta/support/io/FallocateTest.java @@ -143,6 +143,10 @@ void execute_whenOffsetEqualsFinalSize_expectNoWriteOnNonWindowsOrSingleByteOnWi throws IOException { // Arrange FileChannel channel = mock(FileChannel.class); + if (Platform.isWindows()) { + // Avoid a tight zero-progress loop when using a mock channel on the Windows legacy path. + when(channel.write(any(ByteBuffer.class), anyLong())).thenReturn(1); + } long finalSize = 10L; long offset = 10L; // zero remaining Fallocate f = Fallocate.forChannel(channel, finalSize).fromOffset(offset); diff --git a/src/test/java/network/crypta/support/io/FileUtilTest.java b/src/test/java/network/crypta/support/io/FileUtilTest.java index 752e0664fb..98a4fcde74 100644 --- a/src/test/java/network/crypta/support/io/FileUtilTest.java +++ b/src/test/java/network/crypta/support/io/FileUtilTest.java @@ -283,8 +283,9 @@ void moveTo_whenAtomicNotSupported_thenFallbackSucceeds_expectTrue(@TempDir Path } @Test - @DisplayName("moveTo_whenIOExceptionOnAtomic_expectFalse") - void moveTo_whenIOExceptionOnAtomic_expectFalse(@TempDir Path tmp) throws Exception { + @DisplayName("moveTo_whenIOExceptionOnAtomic_expectFallbackReplaceSucceeds") + void moveTo_whenIOExceptionOnAtomic_expectFallbackReplaceSucceeds(@TempDir Path tmp) + throws Exception { // Arrange Path src = tmp.resolve("from3.bin"); Path dst = tmp.resolve("to3.bin"); @@ -299,7 +300,7 @@ void moveTo_whenIOExceptionOnAtomic_expectFalse(@TempDir Path tmp) throws Except boolean ok = FileUtil.moveTo(src.toFile(), dst.toFile()); // Assert - assertFalse(ok); + assertTrue(ok); } } diff --git a/src/test/java/network/crypta/support/io/PooledFileRandomAccessBufferTest.java b/src/test/java/network/crypta/support/io/PooledFileRandomAccessBufferTest.java index 27b965e89d..d4e3f07283 100644 --- a/src/test/java/network/crypta/support/io/PooledFileRandomAccessBufferTest.java +++ b/src/test/java/network/crypta/support/io/PooledFileRandomAccessBufferTest.java @@ -345,25 +345,28 @@ void storeToAndLoad_whenPersistentTempIdMinusOne_roundTripsProperties() throws E File f = new File(tempDir, "round.bin"); byte[] content = new byte[3]; Files.write(f.toPath(), content); - PooledFileRandomAccessBuffer orig = + try (PooledFileRandomAccessBuffer orig = new PooledFileRandomAccessBuffer( - f, false, -1, -1L, true, new PooledFileRandomAccessBuffer.FDTracker(4)); - - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (DataOutputStream dos = new DataOutputStream(baos)) { - orig.storeTo(dos); + f, false, -1, -1L, true, new PooledFileRandomAccessBuffer.FDTracker(4))) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (DataOutputStream dos = new DataOutputStream(baos)) { + orig.storeTo(dos); + } + + // Act: read back; the reader expects the MAGIC to have been consumed already. + try (DataInputStream dis = + new DataInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + assertEquals(PooledFileRandomAccessBuffer.MAGIC, dis.readInt()); + try (PooledFileRandomAccessBuffer copy = + new PooledFileRandomAccessBuffer( + dis, mock(FilenameGenerator.class), mock(PersistentFileTracker.class))) { + + // Assert + assertEquals(orig.size(), copy.size()); + assertEquals(orig, copy); + } + } } - - // Act: read back; the reader expects the MAGIC to have been consumed already - DataInputStream dis = new DataInputStream(new ByteArrayInputStream(baos.toByteArray())); - assertEquals(PooledFileRandomAccessBuffer.MAGIC, dis.readInt()); - PooledFileRandomAccessBuffer copy = - new PooledFileRandomAccessBuffer( - dis, mock(FilenameGenerator.class), mock(PersistentFileTracker.class)); - - // Assert - assertEquals(orig.size(), copy.size()); - assertEquals(orig, copy); } @Test @@ -382,14 +385,16 @@ void load_whenPersistentTempFileMissingButMoved_registersWithTrackerAndUsesNewFi when(fg.getFilename(tempId)).thenReturn(moved); // Act - DataInputStream dis = new DataInputStream(new ByteArrayInputStream(baos.toByteArray())); - assertEquals(PooledFileRandomAccessBuffer.MAGIC, dis.readInt()); // consume magic - PooledFileRandomAccessBuffer p = new PooledFileRandomAccessBuffer(dis, fg, tracker); - - // Assert - verify(tracker, times(1)).register(moved); - assertNotNull(p.file); - assertEquals(moved.getCanonicalPath(), p.file.getCanonicalPath()); + try (DataInputStream dis = new DataInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + assertEquals(PooledFileRandomAccessBuffer.MAGIC, dis.readInt()); // consume magic + try (PooledFileRandomAccessBuffer p = new PooledFileRandomAccessBuffer(dis, fg, tracker)) { + + // Assert + verify(tracker, times(1)).register(moved); + assertNotNull(p.file); + assertEquals(moved.getCanonicalPath(), p.file.getCanonicalPath()); + } + } } private static @NotNull ByteArrayOutputStream getByteArrayOutputStream(File original, long tempId) @@ -435,11 +440,12 @@ void load_whenPersistentTempFileMissingAndNotFound_throwsResumeFailedException() .thenAnswer(invocation -> invocation.getArgument(0)); // Act + Assert - DataInputStream dis = new DataInputStream(new ByteArrayInputStream(baos.toByteArray())); - assertEquals(PooledFileRandomAccessBuffer.MAGIC, dis.readInt()); - assertThrows( - ResumeFailedException.class, - () -> new PooledFileRandomAccessBuffer(dis, fg, mock(PersistentFileTracker.class))); + try (DataInputStream dis = new DataInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + assertEquals(PooledFileRandomAccessBuffer.MAGIC, dis.readInt()); + assertThrows( + ResumeFailedException.class, + () -> new PooledFileRandomAccessBuffer(dis, fg, mock(PersistentFileTracker.class))); + } } @Test diff --git a/src/test/java/network/crypta/tools/AddRefTest.java b/src/test/java/network/crypta/tools/AddRefTest.java index 1db6677b20..5d5015e7c8 100644 --- a/src/test/java/network/crypta/tools/AddRefTest.java +++ b/src/test/java/network/crypta/tools/AddRefTest.java @@ -6,6 +6,7 @@ import java.nio.file.Path; import java.util.List; import java.util.concurrent.TimeUnit; +import network.crypta.fs.AppEnv; import network.crypta.support.SimpleFieldSet; import network.crypta.support.io.LineReadingInputStream; import org.junit.jupiter.api.Test; @@ -98,7 +99,7 @@ void main_whenNoArgs_expectExitCode255AndUsageMessageToStderr() throws IOException, InterruptedException { ProcessResult result = runAddRef(); - assertEquals(255, result.exitCode()); + assertEquals(expectedInvalidArgumentExitCode(), result.exitCode()); assertTrue(result.stderr().contains(USAGE_MESSAGE)); } @@ -107,7 +108,7 @@ void main_whenPathIsDirectory_expectExitCode255AndUsageMessageToStderr() throws IOException, InterruptedException { ProcessResult result = runAddRef(tempDir.toString()); - assertEquals(255, result.exitCode()); + assertEquals(expectedInvalidArgumentExitCode(), result.exitCode()); assertTrue(result.stderr().contains(USAGE_MESSAGE)); } @@ -116,10 +117,15 @@ void main_whenPathDoesNotExist_expectExitCode255AndUsageMessageToStderr() throws IOException, InterruptedException { ProcessResult result = runAddRef(tempDir.resolve("missing.ref").toString()); - assertEquals(255, result.exitCode()); + assertEquals(expectedInvalidArgumentExitCode(), result.exitCode()); assertTrue(result.stderr().contains(USAGE_MESSAGE)); } + private static int expectedInvalidArgumentExitCode() { + // System.exit(-1) appears as -1 on Windows and 255 on POSIX-like systems. + return new AppEnv().isWindows() ? -1 : 255; + } + private static ProcessResult runAddRef(String... args) throws IOException, InterruptedException { String javaBin = Path.of(System.getProperty("java.home")).resolve("bin").resolve("java").toString(); diff --git a/src/test/java/network/crypta/tools/CleanupTranslationsTest.java b/src/test/java/network/crypta/tools/CleanupTranslationsTest.java index 60125a8d60..22eee9c2e6 100644 --- a/src/test/java/network/crypta/tools/CleanupTranslationsTest.java +++ b/src/test/java/network/crypta/tools/CleanupTranslationsTest.java @@ -45,8 +45,11 @@ void main_whenTranslationHasOrphanedKey_removesLineAndRewritesFile() result.stderr().contains("Orphaned string: \"orphaned\""), "Expected orphaned key to be reported on stderr"); assertTrue( - result.stdout().contains("Rewritten src/freenet/l10n/crypta.l10n.fr.properties"), - "Expected rewritten message on stdout"); + result.stdout().contains("Rewritten"), + "Expected rewritten message on stdout, got: " + result.stdout()); + assertTrue( + result.stdout().contains("crypta.l10n.fr.properties"), + "Expected rewritten file name on stdout, got: " + result.stdout()); String rewritten = Files.readString(translation, StandardCharsets.UTF_8); assertEquals( From 92309151e39ef23a73254c5a7acea0963bd19cec Mon Sep 17 00:00:00 2001 From: Leumor <116955025+leumor@users.noreply.github.com> Date: Sun, 22 Feb 2026 07:13:37 +0000 Subject: [PATCH 3/6] fix(crypt): parse full Java major version Fix KeyGenUtils Java version parsing for values without separators (for example 21) so runtime detection no longer falls back to Java 7 behavior. Add regression tests that validate major-only and dotted version parsing through KeyGenUtils.getJavaVersion(). --- .../network/crypta/crypt/KeyGenUtils.java | 8 +-- .../network/crypta/crypt/KeyGenUtilsTest.java | 51 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/main/java/network/crypta/crypt/KeyGenUtils.java b/src/main/java/network/crypta/crypt/KeyGenUtils.java index 4db486d7da..e466071ee9 100755 --- a/src/main/java/network/crypta/crypt/KeyGenUtils.java +++ b/src/main/java/network/crypta/crypt/KeyGenUtils.java @@ -69,12 +69,14 @@ private static int getJavaVersion() { // 9-ea // 9 // 9.0.1 + // 21 int dotPos = version.indexOf('.'); int dashPos = version.indexOf('-'); - int end = 1; - if (dotPos > -1) { + int end = version.length(); + if (dotPos > -1 && dotPos < end) { end = dotPos; - } else if (dashPos > -1) { + } + if (dashPos > -1 && dashPos < end) { end = dashPos; } return Integer.parseInt(version.substring(0, end)); diff --git a/src/test/java/network/crypta/crypt/KeyGenUtilsTest.java b/src/test/java/network/crypta/crypt/KeyGenUtilsTest.java index 3f2d51ed62..680e6e70fe 100755 --- a/src/test/java/network/crypta/crypt/KeyGenUtilsTest.java +++ b/src/test/java/network/crypta/crypt/KeyGenUtilsTest.java @@ -2,6 +2,7 @@ import java.security.*; +import java.lang.reflect.Method; import java.nio.ByteBuffer; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; @@ -148,6 +149,42 @@ void genKeyPair_whenNullType_expectNullPointerException() { assertThrows(NullPointerException.class, () -> KeyGenUtils.genKeyPair(null)); } + @Test + void getJavaVersion_whenVersionHasNoSeparator_expectAllDigitsParsed() throws Exception { + // Arrange + String previousVersion = System.getProperty("java.version"); + + try { + System.setProperty("java.version", "21"); + + // Act + int parsedVersion = invokeGetJavaVersion(); + + // Assert + assertEquals(21, parsedVersion); + } finally { + restoreProperty("java.version", previousVersion); + } + } + + @Test + void getJavaVersion_whenVersionHasDot_expectMajorParsed() throws Exception { + // Arrange + String previousVersion = System.getProperty("java.version"); + + try { + System.setProperty("java.version", "25.0.1"); + + // Act + int parsedVersion = invokeGetJavaVersion(); + + // Assert + assertEquals(25, parsedVersion); + } finally { + restoreProperty("java.version", previousVersion); + } + } + @Test void getPublicKey_whenValidBytes_expectSameEncoding() { for (int i = 0; i < trueKeyPairTypes.length; i++) { @@ -777,4 +814,18 @@ void getKeyPair_whenInvalidPublicOrPrivate_expectIllegalArgumentException() { IllegalArgumentException.class, () -> KeyGenUtils.getKeyPair(KeyPairType.ECP256, truePublicKeys[0], invalidPrv)); } + + private static int invokeGetJavaVersion() throws ReflectiveOperationException { + Method method = KeyGenUtils.class.getDeclaredMethod("getJavaVersion"); + method.setAccessible(true); + return (int) method.invoke(null); + } + + private static void restoreProperty(String key, String previousValue) { + if (previousValue == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previousValue); + } + } } From 0c5eb95ad767b927f2b222d566cbbfd27fae5a63 Mon Sep 17 00:00:00 2001 From: Leumor <116955025+leumor@users.noreply.github.com> Date: Sun, 22 Feb 2026 07:52:23 +0000 Subject: [PATCH 4/6] fix(support): replace BloomFilter Unsafe cleaner Remove reflective use of sun.misc.Unsafe.invokeCleaner from BloomFilter. Use FileChannel.map(..., Arena) with MemorySegment force/close lifecycle and route file-backed Binary/Counting Bloom filters through the shared mapper helper. This keeps deterministic mapped-buffer cleanup while eliminating terminal deprecation warnings on JDK 25 test runs. --- .../crypta/support/BinaryBloomFilter.java | 3 +- .../network/crypta/support/BloomFilter.java | 89 ++++++++++--------- .../crypta/support/CountingBloomFilter.java | 3 +- 3 files changed, 51 insertions(+), 44 deletions(-) diff --git a/src/main/java/network/crypta/support/BinaryBloomFilter.java b/src/main/java/network/crypta/support/BinaryBloomFilter.java index 1fc7cf99f9..0e45407e50 100644 --- a/src/main/java/network/crypta/support/BinaryBloomFilter.java +++ b/src/main/java/network/crypta/support/BinaryBloomFilter.java @@ -5,7 +5,6 @@ import java.io.IOException; import java.io.RandomAccessFile; import java.nio.ByteBuffer; -import java.nio.channels.FileChannel.MapMode; import java.nio.channels.FileChannel; import java.nio.file.FileSystems; import java.nio.file.Files; @@ -78,7 +77,7 @@ public final class BinaryBloomFilter extends BloomFilter { try (RandomAccessFile raf = new RandomAccessFile(file, "rw"); FileChannel channel = raf.getChannel()) { raf.setLength(length / 8); - filter = channel.map(MapMode.READ_WRITE, 0, length / 8).load(); + filter = mapReadWriteBuffer(channel, length / 8L); } } diff --git a/src/main/java/network/crypta/support/BloomFilter.java b/src/main/java/network/crypta/support/BloomFilter.java index 0357568903..2a7ba86935 100644 --- a/src/main/java/network/crypta/support/BloomFilter.java +++ b/src/main/java/network/crypta/support/BloomFilter.java @@ -3,11 +3,12 @@ import java.io.File; import java.io.IOException; import java.io.OutputStream; +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; import java.lang.ref.Cleaner; -import java.lang.reflect.Field; -import java.lang.reflect.Method; import java.nio.ByteBuffer; import java.nio.MappedByteBuffer; +import java.nio.channels.FileChannel; import java.util.Random; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; @@ -38,7 +39,8 @@ */ public abstract class BloomFilter implements AutoCloseable { private static final Cleaner cleaner = Cleaner.create(); - private static final UnsafeCleaner UNSAFE_CLEANER = UnsafeCleaner.load(); + private Arena mappedArena; + private MemorySegment mappedSegment; /** Backing buffer for the filter’s bytes (bits or counters), owned by subclasses. */ protected ByteBuffer filter; @@ -412,8 +414,34 @@ public boolean needRebuild() { *

No effect for heap buffers. */ public void force() { - if (filter instanceof MappedByteBuffer buffer) { - buffer.force(); + ByteBuffer current = filter; + if (current != null) { + forceBuffer(current); + } + } + + /** + * Maps a file region for read/write access and binds its lifecycle to this filter. + * + *

The returned buffer view remains valid until {@link #close()} closes the associated {@link + * Arena}. This method is intended for file-backed implementations. + * + * @param channel source channel + * @param size mapping length in bytes + * @return byte-buffer view over the mapped region + * @throws IOException if mapping fails + */ + protected final ByteBuffer mapReadWriteBuffer(FileChannel channel, long size) throws IOException { + Arena arena = Arena.ofShared(); + try { + MemorySegment segment = channel.map(FileChannel.MapMode.READ_WRITE, 0, size, arena); + segment.load(); + mappedArena = arena; + mappedSegment = segment; + return segment.asByteBuffer(); + } catch (IOException | RuntimeException e) { + arena.close(); + throw e; } } @@ -428,8 +456,8 @@ public void close() { ByteBuffer current = filter; if (current != null) { forceBuffer(current); - UNSAFE_CLEANER.clean(current); } + closeMappedSegment(); filter = null; forkedFilter = null; @@ -512,46 +540,27 @@ public void writeTo(OutputStream cos) throws IOException { } } - private static void forceBuffer(ByteBuffer buffer) { + private void forceBuffer(ByteBuffer buffer) { + if (mappedSegment != null) { + mappedSegment.force(); + return; + } if (buffer instanceof MappedByteBuffer mapped) { mapped.force(); } } - private static final class UnsafeCleaner { - private final Object unsafe; - private final Method invokeCleanerMethod; - - private UnsafeCleaner(Object unsafe, Method invokeCleanerMethod) { - this.unsafe = unsafe; - this.invokeCleanerMethod = invokeCleanerMethod; - } - - static UnsafeCleaner load() { - try { - Class unsafeClass = Class.forName("sun.misc.Unsafe"); - Field theUnsafe = unsafeClass.getDeclaredField("theUnsafe"); - theUnsafe.setAccessible(true); - Object unsafe = theUnsafe.get(null); - Method invokeCleaner = unsafeClass.getMethod("invokeCleaner", ByteBuffer.class); - return new UnsafeCleaner(unsafe, invokeCleaner); - } catch (ReflectiveOperationException | RuntimeException _) { - return new UnsafeCleaner(null, null); - } + private void closeMappedSegment() { + Arena arena = mappedArena; + mappedArena = null; + mappedSegment = null; + if (arena == null) { + return; } - - void clean(ByteBuffer buffer) { - if (!(buffer instanceof MappedByteBuffer)) { - return; - } - if (unsafe == null || invokeCleanerMethod == null) { - return; - } - try { - invokeCleanerMethod.invoke(unsafe, buffer); - } catch (ReflectiveOperationException | RuntimeException _) { - // Best-effort; dropping strong references still allows eventual unmapping via GC. - } + try { + arena.close(); + } catch (RuntimeException _) { + // Best-effort close; references are still dropped to allow eventual cleanup by GC. } } } diff --git a/src/main/java/network/crypta/support/CountingBloomFilter.java b/src/main/java/network/crypta/support/CountingBloomFilter.java index ed2de35326..31c283e31a 100644 --- a/src/main/java/network/crypta/support/CountingBloomFilter.java +++ b/src/main/java/network/crypta/support/CountingBloomFilter.java @@ -5,7 +5,6 @@ import java.io.IOException; import java.io.RandomAccessFile; import java.nio.ByteBuffer; -import java.nio.channels.FileChannel.MapMode; import java.nio.channels.FileChannel; import java.nio.file.FileSystems; import java.nio.file.Files; @@ -105,7 +104,7 @@ public CountingBloomFilter(int length, int k) { try (RandomAccessFile raf = new RandomAccessFile(file, "rw"); FileChannel channel = raf.getChannel()) { raf.setLength(fileLength); - filter = channel.map(MapMode.READ_WRITE, 0, fileLength).load(); + filter = mapReadWriteBuffer(channel, fileLength); } } From 37a893240eef0b9a374abf5a9e720c08e7f18fd2 Mon Sep 17 00:00:00 2001 From: Leumor <116955025+leumor@users.noreply.github.com> Date: Sun, 22 Feb 2026 08:04:27 +0000 Subject: [PATCH 5/6] test(filter): replace misleading escaped space Use \\040 in CSSParserTest text block expectation to preserve spacing semantics while avoiding Error Prone MisleadingEscapedSpace. --- src/test/java/network/crypta/client/filter/CSSParserTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/network/crypta/client/filter/CSSParserTest.java b/src/test/java/network/crypta/client/filter/CSSParserTest.java index 1a63f0a149..de1cfc5447 100644 --- a/src/test/java/network/crypta/client/filter/CSSParserTest.java +++ b/src/test/java/network/crypta/client/filter/CSSParserTest.java @@ -1328,7 +1328,7 @@ class CSSParserTest { """ H1:before { content: "Chapter " counter(chapter) ". "; - counter-increment: chapter; \s + counter-increment: chapter; \040 }\ """); propertyTests.put( From 810460f8852874b488d716efb2d4d903e0d6cd7e Mon Sep 17 00:00:00 2001 From: Leumor <116955025+leumor@users.noreply.github.com> Date: Sun, 22 Feb 2026 12:24:17 +0000 Subject: [PATCH 6/6] fix(runtime): handle lingering jlink process Avoid indefinite waits in createJreImage when jlink does not terminate cleanly on Windows. - Stream and log jlink output via a dedicated pump thread - Add a 10-minute timeout and force cleanup of lingering jlink processes - Treat timeout as success when build/jre is complete - Keep compression enabled by default and allow override via -PjlinkCompression --- .../main/kotlin/cryptad.runtime.gradle.kts | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/build-logic/src/main/kotlin/cryptad.runtime.gradle.kts b/build-logic/src/main/kotlin/cryptad.runtime.gradle.kts index c9f536e63d..f1d1b191a5 100644 --- a/build-logic/src/main/kotlin/cryptad.runtime.gradle.kts +++ b/build-logic/src/main/kotlin/cryptad.runtime.gradle.kts @@ -1,4 +1,5 @@ import java.io.ByteArrayOutputStream +import java.util.concurrent.TimeUnit plugins { java } @@ -166,12 +167,11 @@ val createJreImage by val javaHomePath = launcher.map { it.metadata.installationPath.asFile.absolutePath } val jreDirProvider = layout.buildDirectory.dir("jre") val modulesFileProvider = layout.buildDirectory.dir("jlink").map { it.file("modules.list") } + val jlinkCompressionProvider = providers.gradleProperty("jlinkCompression") doLast { + val osName = System.getProperty("os.name").lowercase() val javaHome = File(javaHomePath.get()) - val jlink = - javaHome.resolve( - "bin/jlink${if (System.getProperty("os.name").lowercase().contains("win")) ".exe" else ""}" - ) + val jlink = javaHome.resolve("bin/jlink${if (osName.contains("win")) ".exe" else ""}") val jmods = javaHome.resolve("jmods") val jreDir = jreDirProvider.get().asFile @@ -179,15 +179,13 @@ val createJreImage by val modulesFile = modulesFileProvider.get().asFile val modulesArg = if (modulesFile.isFile) modulesFile.readText().trim() else "java.base" + val jlinkCompression = + jlinkCompressionProvider.orNull?.trim().takeUnless { it.isNullOrBlank() } ?: "zip-6" val args = - mutableListOf( - jlink.absolutePath, - "-v", - "--strip-debug", - // Use non-deprecated compression syntax (replaces numeric level 2) - "--compress", - "zip-6", + mutableListOf(jlink.absolutePath, "-v", "--strip-debug", "--compress", jlinkCompression) + args.addAll( + listOf( "--no-header-files", "--no-man-pages", "--module-path", @@ -197,10 +195,41 @@ val createJreImage by "--output", jreDir.absolutePath, ) + ) println("Executing jlink: ${args.joinToString(" ")}") - val process = ProcessBuilder(args).inheritIO().start() - val exit = process.waitFor() + val process = ProcessBuilder(args).redirectErrorStream(true).start() + val outputPump = + Thread { + process.inputStream.bufferedReader().useLines { lines -> + lines.forEach { line -> logger.lifecycle(line) } + } + } + .apply { + name = "jlink-output-pump" + isDaemon = true + start() + } + + val completed = process.waitFor(10, TimeUnit.MINUTES) + outputPump.join(2_000) + if (!completed) { + val jreReady = + (jreDir.resolve("release").isFile && + jreDir.resolve("lib/modules").isFile && + (jreDir.resolve("bin/java.exe").isFile || jreDir.resolve("bin/java").isFile)) + if (jreReady) { + logger.warn( + "jlink did not exit, but the runtime image is complete. Terminating lingering jlink process." + ) + process.destroyForcibly() + return@doLast + } + process.destroyForcibly() + throw GradleException("jlink timed out after 10 minutes") + } + + val exit = process.exitValue() if (exit != 0) { throw GradleException("jlink failed with exit code $exit") }