diff --git a/src/main/docs/project-requirements.adoc b/src/main/docs/project-requirements.adoc index ab6f064..1926a54 100644 --- a/src/main/docs/project-requirements.adoc +++ b/src/main/docs/project-requirements.adoc @@ -31,8 +31,9 @@ store in `PosixAPIHolder.POSIX_API`. | POSIX-FN-003 | Deterministic error handling -| Translate every native failure into a `PosixRuntimeException`; -expose `lastError()` and `strerror(int)`. +| Translate unexpected native failures into a `PosixRuntimeException`; +operations with a documented boolean failure result return `false` for +their expected errors; expose `lastError()` and `strerror(int)`. | POSIX-FN-004 | File-descriptor lifecycle @@ -44,7 +45,10 @@ expose `lastError()` and `strerror(int)`. | Support `mmap`, `munmap`, `madvise`, `msync`, `mlock`, `mlock2`, `mlockall`; degrade gracefully on unsupported kernels/VMs; provide full flag coverage via `MMapProt`, `MMapFlag`, `MAdviseFlag`, -`MSyncFlag`, `MclFlag`. +`MSyncFlag`, `MclFlag`. Memory locking returns `false` for resource, +permission, transient and unsupported-operation errors; other errors +raise `PosixRuntimeException` with the saved `errno`. Raw `mlock2` +syscalls are used only for recognised Linux CPU ABIs. | POSIX-FN-006 | File space pre-allocation diff --git a/src/main/java/net/openhft/posix/PosixAPI.java b/src/main/java/net/openhft/posix/PosixAPI.java index 003b9e3..aecdfb3 100644 --- a/src/main/java/net/openhft/posix/PosixAPI.java +++ b/src/main/java/net/openhft/posix/PosixAPI.java @@ -195,6 +195,8 @@ default boolean mlock(long addr, long length) { * first access when {@code lockOnFault} is {@code true}. The default * implementation returns {@code false}. Failure reasons mirror those of * {@code mlock} and also include lack of kernel support for {@code mlock2}. + * The JNR provider returns {@code false} for Linux {@code ENOSYS}; on macOS, + * where {@code mlock2} is absent, it performs an eager {@code mlock} instead. * * @param addr start address * @param length number of bytes to lock diff --git a/src/main/java/net/openhft/posix/internal/jnr/JNRPosixAPI.java b/src/main/java/net/openhft/posix/internal/jnr/JNRPosixAPI.java index a280ae0..9965699 100644 --- a/src/main/java/net/openhft/posix/internal/jnr/JNRPosixAPI.java +++ b/src/main/java/net/openhft/posix/internal/jnr/JNRPosixAPI.java @@ -10,12 +10,13 @@ import jnr.ffi.provider.FFIProvider; import net.openhft.posix.*; import net.openhft.posix.internal.UnsafeMemory; -import net.openhft.posix.internal.core.Jvm; import net.openhft.posix.internal.core.OS; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; +import java.util.Locale; +import java.util.Objects; import java.util.function.IntSupplier; import static net.openhft.posix.internal.UnsafeMemory.UNSAFE; @@ -23,9 +24,9 @@ /** * Implementation of {@link PosixAPI} using JNR (Java Native Runtime). * - *
Where a JNR binding is absent this class issues raw syscalls using - * hard-coded numbers chosen for common architectures. If the kernel does not - * recognise a number the call gracefully falls back to the available wrapper.
+ *Where the {@code mlock2} JNR binding is absent this class issues a raw + * syscall only for a recognised Linux CPU ABI. Unsupported kernels, operating + * systems and ABIs return {@code false} rather than invoking an unrelated syscall.
*/ public final class JNRPosixAPI implements PosixAPI { @@ -40,17 +41,17 @@ public final class JNRPosixAPI implements PosixAPI { static final int LOCK_EX = 2; static final int LOCK_UN = 8; static final int MLOCK_ONFAULT = 1; - static final int SYS_mlock2; // mlock2 syscall value - - static { - // These cover the main cases. Full list under https://github.com/torvalds/linux/tree/master/arch - SYS_mlock2 = Jvm.isArm() ? 390 - : Jvm.is64bit() ? 325 : 376; - } + static final int MLOCK2_UNAVAILABLE = Integer.MIN_VALUE; + static final int SYS_MLOCK2 = mlock2SyscallNumber( + NATIVE_PLATFORM.getOS(), System.getProperty("os.arch", "")); // JNR interface for POSIX functions private final JNRPosixInterface jnr; + private final IntSupplier lastErrorSupplier; + + private final int mlock2SyscallNumber; + // Supplier for gettid method private final IntSupplier gettid; @@ -58,7 +59,14 @@ public final class JNRPosixAPI implements PosixAPI { * Constructs a JNRPosixAPI instance and initializes the JNR interface and gettid supplier. */ public JNRPosixAPI() { - jnr = LibraryUtil.load(JNRPosixInterface.class, STANDARD_C_LIBRARY_NAME); + this(LibraryUtil.load(JNRPosixInterface.class, STANDARD_C_LIBRARY_NAME), + RUNTIME::getLastError, SYS_MLOCK2); + } + + JNRPosixAPI(JNRPosixInterface jnr, IntSupplier lastErrorSupplier, int mlock2SyscallNumber) { + this.jnr = Objects.requireNonNull(jnr); + this.lastErrorSupplier = Objects.requireNonNull(lastErrorSupplier); + this.mlock2SyscallNumber = mlock2SyscallNumber; gettid = getGettid(); } @@ -117,8 +125,8 @@ public int close(int fd) { * @param msg The message to include in the exception. * @return A {@link PosixRuntimeException} with the specified message and last error. */ - private static RuntimeException throwPosixException(String msg) { - final int lastError = RUNTIME.getLastError(); + private RuntimeException throwPosixException(String msg) { + final int lastError = lastErrorSupplier.getAsInt(); for (Errno errno : Errno.values()) { if (errno.intValue() == lastError) throw new PosixRuntimeException(msg + "error " + errno, lastError); @@ -132,7 +140,7 @@ public long mmap(long addr, long length, int prot, int flags, int fd, long offse final Pointer wrap = addr == 0 ? NULL : Pointer.wrap(RUNTIME, addr); final long mmap = jnr.mmap(wrap, length, prot, flags, fd, offset); if (mmap == 0 || mmap == -1) { - final int lastError = RUNTIME.getLastError(); + final int lastError = lastErrorSupplier.getAsInt(); for (Errno errno : Errno.values()) { if (errno.intValue() == lastError) throw new PosixRuntimeException(errno.toString(), lastError); @@ -155,37 +163,78 @@ private int mlock2Native(long addr, long length, boolean lockOnFault) { if (!lockOnFault || OS.isMacOSX()) return jnr.mlock(addr, length); - // Older glibc versions do not include a wrapper for mlock2, so use syscall for generality - return jnr.syscall(SYS_mlock2, addr, length, MLOCK_ONFAULT); + try { + return jnr.mlock2(addr, length, MLOCK_ONFAULT); + } catch (UnsatisfiedLinkError unavailable) { + if (mlock2SyscallNumber < 0) + return MLOCK2_UNAVAILABLE; + return jnr.syscall(mlock2SyscallNumber, addr, length, MLOCK_ONFAULT); + } } @Override public boolean mlock(long addr, long length) { - if(Jvm.isAzul()) { - LOGGER.warn("mlock called but ignored for Azul"); - return true; // no-op on Azul, ignore - } - - int err = jnr.mlock(addr, length); - if (err == 0) - return true; - if (err == Errno.ENOMEM.intValue()) - return false; - throw throwPosixException("mlock length: " + length + " "); + return handleMlockResult(jnr.mlock(addr, length), "mlock length: " + length + " "); } @Override public boolean mlock2(long addr, long length, boolean lockOnFault) { - if(Jvm.isAzul()) { - LOGGER.warn("mlock2 called but ignored for Azul"); - return true; // no-op on Azul, ignore - } - int err = mlock2Native(addr, length, lockOnFault); - if (err == 0) + return handleMlockResult(mlock2Native(addr, length, lockOnFault), "mlock2 length: " + length + " "); + } + + /** + * Interprets an {@code mlock}/{@code mlock2} native result honestly. + *+ * POSIX {@code mlock} returns {@code 0} on success and {@code -1} on failure with the reason in + * {@code errno} - so the previous {@code result == ENOMEM} comparison could never match (the + * result is {@code -1}, not the errno value) and every failure was thrown. It also short-circuited + * to {@code return true} on Azul without locking anything, reporting success while the memory was + * not locked. Both are corrected here: call the native op on every vendor, read {@code errno} on + * failure, and map the expected "cannot lock" conditions (permission / limit / transient) to + * {@code false} while still throwing on genuinely unexpected errors. This is what unblocked the + * original Azul/Zing {@code ENOMEM} case (Posix#15) - gracefully, without a false success. + */ + private boolean handleMlockResult(int result, String msg) { + if (result == 0) return true; - if (err == Errno.ENOMEM.intValue()) + if (result == MLOCK2_UNAVAILABLE) { + LOGGER.warn(msg + "not locked: mlock2 unavailable for architecture " + + System.getProperty("os.arch", "unknown")); return false; - throw throwPosixException("mlock2 length: " + length + " "); + } + final int lastError = lastErrorSupplier.getAsInt(); + if (lastError == Errno.ENOMEM.intValue() + || lastError == Errno.EPERM.intValue() + || lastError == Errno.EAGAIN.intValue() + || lastError == Errno.ENOSYS.intValue()) { + LOGGER.warn(msg + "not locked: " + errnoName(lastError)); + return false; + } + throw throwPosixException(msg); + } + + private static String errnoName(int lastError) { + for (Errno errno : Errno.values()) + if (errno.intValue() == lastError) + return errno.toString(); + return "errno " + lastError; + } + + static int mlock2SyscallNumber(Platform.OS operatingSystem, String architecture) { + if (operatingSystem != Platform.OS.LINUX) + return -1; + final String arch = architecture.toLowerCase(Locale.ROOT); + if (arch.equals("x86_64") || arch.equals("amd64")) + return 325; + if (arch.equals("x86") || arch.matches("i[3-6]86")) + return 376; + if (arch.equals("aarch64") || arch.equals("arm64")) + return 284; + if (arch.equals("arm") || arch.equals("arm32") || arch.matches("armv[5-8].*")) + return 390; + if (arch.equals("riscv64")) + return 284; + return -1; } @Override @@ -218,7 +267,7 @@ private void tryMLockAll(int flags) { continue; final long kb = mapping.length() / 1024; if (ret != 0) { - final int lastError = RUNTIME.getLastError(); + final int lastError = lastErrorSupplier.getAsInt(); for (Errno errno : Errno.values()) { if (errno.intValue() == lastError) System.out.println(mapping + "len: " + kb + " KiB " + " " + errno); diff --git a/src/test/java/net/openhft/posix/PosixAPIHolderTest.java b/src/test/java/net/openhft/posix/PosixAPIHolderTest.java index 584891b..83d71e7 100644 --- a/src/test/java/net/openhft/posix/PosixAPIHolderTest.java +++ b/src/test/java/net/openhft/posix/PosixAPIHolderTest.java @@ -24,8 +24,14 @@ public void loadPosixApiInitialisesProviderOnce() { @Test public void useNoOpPosixApiSwitchesToNoOp() { - PosixAPIHolder.useNoOpPosixApi(); - assertTrue(PosixAPIHolder.POSIX_API instanceof NoOpPosixAPI); + PosixAPIHolder.loadPosixApi(); + final PosixAPI original = PosixAPIHolder.POSIX_API; + try { + PosixAPIHolder.useNoOpPosixApi(); + assertTrue(PosixAPIHolder.POSIX_API instanceof NoOpPosixAPI); + } finally { + PosixAPIHolder.POSIX_API = original; + } } @Test diff --git a/src/test/java/net/openhft/posix/internal/jnr/MlockLocksMemoryTest.java b/src/test/java/net/openhft/posix/internal/jnr/MlockLocksMemoryTest.java new file mode 100644 index 0000000..4986f58 --- /dev/null +++ b/src/test/java/net/openhft/posix/internal/jnr/MlockLocksMemoryTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2013-2025 chronicle.software; SPDX-License-Identifier: Apache-2.0 + */ +package net.openhft.posix.internal.jnr; + +import net.openhft.posix.MMapFlag; +import net.openhft.posix.MMapProt; +import net.openhft.posix.OpenFlag; +import net.openhft.posix.PosixAPI; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assume.assumeTrue; + +/** + * Regression test for Posix#15: {@code mlock} must not report success without actually locking memory. + *
+ * The previous implementation returned {@code true} on Azul JVMs without calling {@code mlock} at all, + * and (on every vendor) compared the native return value directly with {@code ENOMEM} instead of reading + * {@code errno} after a {@code -1}. This test proves the reported outcome matches reality by reading the + * process's locked-memory count ({@code VmLck} in {@code /proc/self/status}) before and after the call. + */ +public class MlockLocksMemoryTest { + + private static final long LOCK_LEN = 4096; + + @Test + public void mlockReportedSuccessImpliesMemoryActuallyLocked() throws IOException { + assumeTrue("enable with -Dposix.mlock.integration=true", + Boolean.getBoolean("posix.mlock.integration")); + assumeTrue("needs Linux /proc/self/status", new File("/proc/self/status").exists()); + final PosixAPI posix = PosixAPI.posix(); + assumeTrue("needs the JNR provider", posix instanceof JNRPosixAPI); + final JNRPosixAPI jnr = (JNRPosixAPI) posix; + + final Path file = Files.createTempFile("mlock", ".test"); + final String filename = file.toAbsolutePath().toString(); + final int fd = jnr.open(filename, OpenFlag.O_RDWR, 0666); + try { + assertEquals(0, jnr.ftruncate(fd, 1L << 16)); + final long addr = jnr.mmap(0, 1L << 16, MMapProt.PROT_READ_WRITE, MMapFlag.SHARED, fd, 0L); + assertNotEquals(-1, addr); + try { + final long lockedBefore = vmLckKb(); + final boolean locked = jnr.mlock(addr, LOCK_LEN); + final long lockedAfter = vmLckKb(); + + if (locked) { + // The honest contract: a true result means the pages are genuinely locked. + assertNotEquals("mlock returned true but VmLck did not increase (false success)", + lockedBefore, lockedAfter); + } else { + // A false result (e.g. RLIMIT_MEMLOCK exhausted) must not have locked anything. + assertEquals("mlock returned false but VmLck increased", lockedBefore, lockedAfter); + assumeTrue("environment cannot lock memory (RLIMIT_MEMLOCK); nothing to prove", false); + } + } finally { + jnr.munmap(addr, 1L << 16); + } + } finally { + jnr.close(fd); + Files.deleteIfExists(file); + } + } + + /** Locked memory in KiB from {@code /proc/self/status} ({@code VmLck}), or 0 if absent. */ + private static long vmLckKb() throws IOException { + for (String line : Files.readAllLines(new File("/proc/self/status").toPath())) + if (line.startsWith("VmLck:")) + return Long.parseLong(line.replaceAll("[^0-9]", "")); + return 0; + } +} diff --git a/src/test/java/net/openhft/posix/internal/jnr/MlockResultTest.java b/src/test/java/net/openhft/posix/internal/jnr/MlockResultTest.java new file mode 100644 index 0000000..47c859e --- /dev/null +++ b/src/test/java/net/openhft/posix/internal/jnr/MlockResultTest.java @@ -0,0 +1,194 @@ +/* + * Copyright 2013-2025 chronicle.software; SPDX-License-Identifier: Apache-2.0 + */ +package net.openhft.posix.internal.jnr; + +import jnr.constants.platform.Errno; +import jnr.ffi.Platform; +import net.openhft.posix.PosixRuntimeException; +import org.junit.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.*; + +public class MlockResultTest { + + @Test + public void expectedResultsAreInterpretedForMlockAndMlock2() { + assertResult(0, 0, true); + assertResult(-1, Errno.ENOMEM.intValue(), false); + assertResult(-1, Errno.EPERM.intValue(), false); + assertResult(-1, Errno.EAGAIN.intValue(), false); + assertResult(-1, Errno.ENOSYS.intValue(), false); + assertFailure(-1, Errno.EINVAL.intValue()); + assertFailure(-1, 9999); + } + + @Test + public void syscallNumbersAreSelectedByActualAbi() { + assertEquals(325, linuxSyscallNumber("x86_64")); + assertEquals(325, linuxSyscallNumber("amd64")); + assertEquals(376, linuxSyscallNumber("x86")); + assertEquals(376, linuxSyscallNumber("i686")); + assertEquals(390, linuxSyscallNumber("arm")); + assertEquals(390, linuxSyscallNumber("armv7l")); + assertEquals(284, linuxSyscallNumber("aarch64")); + assertEquals(284, linuxSyscallNumber("arm64")); + assertEquals(284, linuxSyscallNumber("riscv64")); + assertEquals(-1, linuxSyscallNumber("ppc64le")); + } + + @Test + public void linuxSyscallNumbersAreNeverUsedOnAnotherUnixAbi() { + assertEquals(-1, JNRPosixAPI.mlock2SyscallNumber(Platform.OS.DARWIN, "amd64")); + assertEquals(-1, JNRPosixAPI.mlock2SyscallNumber(Platform.OS.FREEBSD, "amd64")); + assertEquals(-1, JNRPosixAPI.mlock2SyscallNumber(Platform.OS.SOLARIS, "amd64")); + } + + @Test + public void mlock2PrefersLibcBinding() { + NativeCalls calls = new NativeCalls(); + calls.mlock2Result = 0; + JNRPosixAPI api = calls.api(0, 284); + + assertTrue(api.mlock2(1L, 4096L, true)); + assertEquals(1, calls.mlock2Calls.get()); + assertEquals(0, calls.syscallCalls.get()); + } + + @Test + public void missingBindingUsesAbiSpecificSyscall() { + NativeCalls calls = new NativeCalls(); + calls.mlock2Unavailable = true; + calls.syscallResult = 0; + JNRPosixAPI api = calls.api(0, 284); + + assertTrue(api.mlock2(1L, 4096L, true)); + assertEquals(1, calls.mlock2Calls.get()); + assertEquals(1, calls.syscallCalls.get()); + assertEquals(284, calls.lastSyscallNumber); + } + + @Test + public void missingBindingOnUnknownAbiDoesNotInvokeRawSyscall() { + NativeCalls calls = new NativeCalls(); + calls.mlock2Unavailable = true; + JNRPosixAPI api = calls.api(0, -1); + + assertFalse(api.mlock2(1L, 4096L, true)); + assertEquals(1, calls.mlock2Calls.get()); + assertEquals(0, calls.syscallCalls.get()); + } + + @Test + public void lockWithoutOnFaultUsesMlockWrapper() { + NativeCalls calls = new NativeCalls(); + calls.mlockResult = 0; + JNRPosixAPI api = calls.api(0, 284); + + assertTrue(api.mlock2(1L, 4096L, false)); + assertEquals(1, calls.mlockCalls.get()); + assertEquals(0, calls.mlock2Calls.get()); + } + + private static void assertResult(int nativeResult, int errno, boolean expected) { + NativeCalls mlockCalls = new NativeCalls(); + mlockCalls.mlockResult = nativeResult; + assertEquals(expected, mlockCalls.api(errno, 284).mlock(1L, 4096L)); + + NativeCalls mlock2Calls = new NativeCalls(); + mlock2Calls.mlock2Result = nativeResult; + assertEquals(expected, mlock2Calls.api(errno, 284).mlock2(1L, 4096L, true)); + } + + private static int linuxSyscallNumber(String architecture) { + return JNRPosixAPI.mlock2SyscallNumber(Platform.OS.LINUX, architecture); + } + + private static void assertFailure(int nativeResult, int errno) { + NativeCalls mlockCalls = new NativeCalls(); + mlockCalls.mlockResult = nativeResult; + assertThrowsErrno(errno, () -> mlockCalls.api(errno, 284).mlock(1L, 4096L)); + + NativeCalls mlock2Calls = new NativeCalls(); + mlock2Calls.mlock2Result = nativeResult; + assertThrowsErrno(errno, () -> mlock2Calls.api(errno, 284).mlock2(1L, 4096L, true)); + } + + private static void assertThrowsErrno(int errno, Runnable operation) { + try { + operation.run(); + fail("expected PosixRuntimeException for errno " + errno); + } catch (PosixRuntimeException expected) { + assertEquals(errno, expected.errno()); + } + } + + private static final class NativeCalls implements InvocationHandler { + final AtomicInteger mlockCalls = new AtomicInteger(); + final AtomicInteger mlock2Calls = new AtomicInteger(); + final AtomicInteger syscallCalls = new AtomicInteger(); + int mlockResult; + int mlock2Result; + int syscallResult; + boolean mlock2Unavailable; + int lastSyscallNumber = -1; + + JNRPosixAPI api(int errno, int syscallNumber) { + JNRPosixInterface proxy = (JNRPosixInterface) Proxy.newProxyInstance( + JNRPosixInterface.class.getClassLoader(), + new Class>[]{JNRPosixInterface.class}, this); + return new JNRPosixAPI(proxy, () -> errno, syscallNumber); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + switch (method.getName()) { + case "mlock": + mlockCalls.incrementAndGet(); + return mlockResult; + case "mlock2": + mlock2Calls.incrementAndGet(); + if (mlock2Unavailable) + throw new UnsatisfiedLinkError("mlock2 missing"); + return mlock2Result; + case "syscall": + syscallCalls.incrementAndGet(); + lastSyscallNumber = (Integer) args[0]; + return syscallResult; + case "gettid": + return 1; + case "toString": + return "NativeCalls"; + default: + return defaultValue(method.getReturnType()); + } + } + + private static Object defaultValue(Class> type) { + if (!type.isPrimitive()) + return null; + if (type == boolean.class) + return false; + if (type == byte.class) + return (byte) 0; + if (type == short.class) + return (short) 0; + if (type == int.class) + return 0; + if (type == long.class) + return 0L; + if (type == float.class) + return 0.0f; + if (type == double.class) + return 0.0d; + if (type == char.class) + return '\0'; + return null; + } + } +}