@@ -64,6 +64,7 @@ public final class MmapSegment implements QuietCloseable {
6464 public static final int FRAME_HEADER_SIZE = 8 ; // u32 crc + u32 payloadLen
6565 public static final int HEADER_SIZE = 24 ;
6666 public static final byte VERSION = 1 ;
67+ private static final int [] CRC32C_TABLE = buildCrc32cTable ();
6768 private static final Logger LOG = LoggerFactory .getLogger (MmapSegment .class );
6869
6970 private final String path ;
@@ -257,11 +258,31 @@ public static MmapSegment createInMemory(long baseSeq, long sizeBytes) {
257258 * last valid frame) do not log and report {@code 0}.
258259 */
259260 public static MmapSegment openExisting (String path ) {
260- long fileSize = Files .length (path );
261+ return openExisting (FilesFacade .INSTANCE , path );
262+ }
263+
264+ /**
265+ * Facade-aware variant of {@link #openExisting(String)} that takes the file
266+ * length (and open/close) through a {@link FilesFacade} instead of straight
267+ * off {@link Files}. Production uses {@link FilesFacade#INSTANCE}; the seam
268+ * exists so recovery's mmap-fault guard can be regression-tested on any
269+ * filesystem.
270+ * <p>
271+ * The mapping is sized to {@code ff.length(path)} and every recovery read
272+ * runs straight out of it, so a facade that reports a length <em>larger</em>
273+ * than the real file makes the mapping extend past end-of-file. A read of a
274+ * page beyond real EOF raises SIGBUS on <em>every</em> filesystem — the same
275+ * fault an unbacked/sparse page raises on ZFS, but reproduced
276+ * deterministically on ext4/xfs, where a within-EOF hole would instead
277+ * zero-fill and never exercise the guard. See
278+ * {@link FilesFacade#length(String)}.
279+ */
280+ public static MmapSegment openExisting (FilesFacade ff , String path ) {
281+ long fileSize = ff .length (path );
261282 if (fileSize < HEADER_SIZE ) {
262283 throw new MmapSegmentException ("file shorter than header: " + path + " size=" + fileSize );
263284 }
264- int fd = Files .openRW (path );
285+ int fd = ff .openRW (path );
265286 if (fd < 0 ) {
266287 throw new MmapSegmentException ("openRW failed for " + path );
267288 }
@@ -308,7 +329,24 @@ public static MmapSegment openExisting(String path) {
308329 if (addr != Files .FAILED_MMAP_ADDRESS ) {
309330 Files .munmap (addr , fileSize , MemoryTag .MMAP_DEFAULT );
310331 }
311- Files .close (fd );
332+ ff .close (fd );
333+ // The header reads above (magic/version/baseSeq) run before
334+ // scanFrames and are otherwise unguarded. An unbacked page 0 --
335+ // an unflushed header on a truncate-based-allocate filesystem
336+ // after a crash (create() does not msync), or a file truncated
337+ // under the mapping -- faults at the Unsafe intrinsic site as a
338+ // catchable InternalError. Convert it to a MmapSegmentException so
339+ // SegmentRing's per-file catch skips just this .sfa, instead of
340+ // letting the raw InternalError escape to SegmentRing's outer catch
341+ // and abort recovery of every sibling segment. scanFrames and
342+ // detectTornTail already handle their own in-mapping faults; this
343+ // covers the header block and any future reader placed ahead of the
344+ // scan.
345+ if (isMmapAccessFault (t )) {
346+ throw new MmapSegmentException (
347+ "unreadable mapped header page in " + path
348+ + " (unbacked/sparse page 0): " + t .getMessage (), t );
349+ }
312350 throw t ;
313351 }
314352 }
@@ -501,11 +539,47 @@ public long frameCount() {
501539 * fresh segments, memory-backed segments, and cleanly partially-filled
502540 * recovered segments. Operators / tests can read this to tell silent
503541 * truncation (corruption) from a normal partial fill (no incident).
542+ * <p>
543+ * One case this does NOT count: when the scan stops because the bail-out
544+ * region is itself an unbacked mapped page (an in-mapping fault, not a
545+ * backed torn header), that region cannot be probed, so this returns
546+ * {@code 0} even though frames may have been discarded. That outcome is
547+ * surfaced by the {@code WARN} in {@link #scanFrames} instead -- see it for
548+ * the benign-tail-vs-media-error caveat.
504549 */
505550 public long tornTailBytes () {
506551 return tornTailBytes ;
507552 }
508553
554+ /**
555+ * True when {@code t} is the JVM's recoverable signal for a fault while
556+ * accessing a memory-mapped region -- a SIGBUS/SIGSEGV that HotSpot
557+ * translates into an {@code InternalError} at an {@code Unsafe} intrinsic
558+ * site instead of aborting the process. It surfaces when a mapped page is
559+ * not backed by real file blocks: a sparse {@code .sfa} tail on a
560+ * filesystem whose pre-allocation leaves holes (e.g. ZFS, where a
561+ * truncate-based {@code allocate} does not materialize blocks), or a file
562+ * truncated under the mapping. Recovery treats this as an I/O boundary --
563+ * the same way MappedByteBuffer readers do -- not a fatal VM error.
564+ * <p>
565+ * The message is matched on the fragment {@code "unsafe memory access
566+ * operation"}, which is common to both HotSpot wordings and NOT
567+ * version-stable as a whole: pre-21 JDKs (including the shipping/CI JDK 8)
568+ * emit {@code "a fault occurred in a recent unsafe memory access operation
569+ * in compiled Java code"}, while JDK 21+ shortened it to {@code "a fault
570+ * occurred in an unsafe memory access operation"}. Matching the shared
571+ * fragment keeps the guard effective on JDK 8/11/17 as well as 21+, while
572+ * still being specific enough that a genuine VirtualMachineError (real OOM,
573+ * StackOverflow) is never swallowed.
574+ */
575+ private static boolean isMmapAccessFault (Throwable t ) {
576+ if (!(t instanceof InternalError )) {
577+ return false ;
578+ }
579+ String msg = t .getMessage ();
580+ return msg != null && msg .contains ("unsafe memory access operation" );
581+ }
582+
509583 /**
510584 * Forward scan that returns the offset just past the last frame whose
511585 * CRC verifies. A torn-tail frame (declared length runs past EOF, or
@@ -515,26 +589,103 @@ public long tornTailBytes() {
515589 */
516590 private static long scanFrames (long addr , long fileSize ) {
517591 long pos = HEADER_SIZE ;
518- while (pos + FRAME_HEADER_SIZE <= fileSize ) {
519- int crcRead = Unsafe .getUnsafe ().getInt (addr + pos );
520- int payloadLen = Unsafe .getUnsafe ().getInt (addr + pos + 4 );
521- // Defensive: a corrupt length field could be enormous or negative,
522- // both of which would otherwise overrun the mapping.
523- if (payloadLen < 0 || pos + FRAME_HEADER_SIZE + payloadLen > fileSize ) {
524- return pos ;
525- }
526- int crcCalc = Crc32c .update (Crc32c .INIT , addr + pos + 4 , 4 );
527- if (payloadLen > 0 ) {
528- crcCalc = Crc32c .update (crcCalc , addr + pos + FRAME_HEADER_SIZE , payloadLen );
592+ try {
593+ while (pos + FRAME_HEADER_SIZE <= fileSize ) {
594+ int crcRead = Unsafe .getUnsafe ().getInt (addr + pos );
595+ int payloadLen = Unsafe .getUnsafe ().getInt (addr + pos + 4 );
596+ // Defensive: a corrupt length field could be enormous or negative,
597+ // both of which would otherwise overrun the mapping.
598+ if (payloadLen < 0 || pos + FRAME_HEADER_SIZE + payloadLen > fileSize ) {
599+ return pos ;
600+ }
601+ // CRC over the contiguous (payloadLen, payload) pair, folded
602+ // via Unsafe reads rather than the native Crc32c.update.
603+ // Recovery maps to the file's stat length, but a page inside
604+ // that range can be unbacked: a sparse pre-allocation tail (a
605+ // truncate-based allocate that never materialized blocks, as on
606+ // ZFS), or -- via a torn write, since tryAppend writes the
607+ // length field before copying the payload -- a real positive
608+ // payloadLen whose payload spans into an unwritten hole. A raw
609+ // read of an unbacked page raises SIGBUS; HotSpot translates
610+ // that into a catchable InternalError ONLY at an Unsafe
611+ // intrinsic site, NEVER inside JNI native code, so a native
612+ // Crc32c.update over such a page aborts the whole JVM (and,
613+ // empirically on pre-21 JDKs, a preceding Unsafe pre-touch does
614+ // not reliably fault first once an earlier native CRC ran in
615+ // the same scan). Folding over Unsafe keeps every fault
616+ // catchable -- handled below as the boundary of recoverable
617+ // data; a page that instead reads back as zeroes just fails the
618+ // CRC check and ends the scan. Recovery is cold, so the slower
619+ // table CRC here is immaterial.
620+ int crcCalc = crc32cRecovery (addr + pos + 4 , 4L + payloadLen );
621+ if (crcCalc != crcRead ) {
622+ return pos ;
623+ }
624+ pos += FRAME_HEADER_SIZE + payloadLen ;
529625 }
530- if (crcCalc != crcRead ) {
531- return pos ;
626+ } catch (InternalError e ) {
627+ // The read at `pos` hit a mapped page that is not backed by real
628+ // file blocks: the JVM translates the underlying SIGBUS into a
629+ // recoverable InternalError instead of aborting the process. This
630+ // happens when a prior session left a sparse segment tail (a
631+ // truncate-based pre-allocation that does not materialize blocks,
632+ // as on ZFS) or the file was truncated under the mapping. Every
633+ // frame below `pos` already verified; treat the unreadable region
634+ // exactly like unwritten space or a torn tail -- the boundary of
635+ // recoverable data -- rather than letting the error abort recovery
636+ // of the whole slot. Anything that is not the documented mmap
637+ // access fault is a genuine VM error, so rethrow it.
638+ if (!isMmapAccessFault (e )) {
639+ throw e ;
532640 }
533- pos += FRAME_HEADER_SIZE + payloadLen ;
641+ LOG .warn ("SF segment recovery: unreadable mapped page at offset {} (file size {}); "
642+ + "treating it as the end of recoverable data -- any frames beyond this "
643+ + "offset are discarded. The usual cause is a benign sparse pre-allocation "
644+ + "tail (e.g. a truncate-based allocate on ZFS) left by a prior session, "
645+ + "but a mid-file media error (bad sector) is indistinguishable here; "
646+ + "check disk health if this segment was expected to be fully written or "
647+ + "if this recurs." ,
648+ pos , fileSize );
534649 }
535650 return pos ;
536651 }
537652
653+ /**
654+ * CRC-32C (Castagnoli) of {@code [addr, addr + len)} read through
655+ * {@link Unsafe}, seeded like {@code Crc32c.update(Crc32c.INIT, addr, len)}
656+ * and bit-identical to it (verified) -- but every byte load is an Unsafe
657+ * intrinsic, so a fault on an unbacked mapped page is a catchable
658+ * {@link InternalError} instead of the uncatchable JNI SIGBUS the native
659+ * {@link Crc32c} would raise. Byte-at-a-time via a precomputed table
660+ * ({@code ~0.5 GiB/s}); used only on the cold recovery scan, never on the
661+ * append hot path (which stays on the native, hardware-friendly path).
662+ */
663+ private static int crc32cRecovery (long addr , long len ) {
664+ int crc = ~Crc32c .INIT ;
665+ for (long i = 0 ; i < len ; i ++) {
666+ crc = (crc >>> 8 ) ^ CRC32C_TABLE [(crc ^ Unsafe .getUnsafe ().getByte (addr + i )) & 0xFF ];
667+ }
668+ return ~crc ;
669+ }
670+
671+ /**
672+ * Standard reflected CRC-32C byte table (polynomial {@code 0x82F63B78}),
673+ * matching {@code crc32c_table[0]} in the native {@code crc32c.c}. Computed
674+ * at class init to avoid 256 hand-transcribed literals; drives
675+ * {@link #crc32cRecovery}.
676+ */
677+ private static int [] buildCrc32cTable () {
678+ int [] table = new int [256 ];
679+ for (int n = 0 ; n < 256 ; n ++) {
680+ int c = n ;
681+ for (int k = 0 ; k < 8 ; k ++) {
682+ c = (c & 1 ) != 0 ? (0x82F63B78 ^ (c >>> 1 )) : (c >>> 1 );
683+ }
684+ table [n ] = c ;
685+ }
686+ return table ;
687+ }
688+
538689 /**
539690 * Distinguishes "torn tail" (writer attempted a write past the last valid
540691 * frame and failed — partial write, mid-stream corruption, bit rot) from
@@ -553,10 +704,21 @@ private static long detectTornTail(long addr, long lastGood, long fileSize) {
553704 return 0L ;
554705 }
555706 long probe = Math .min (FRAME_HEADER_SIZE , fileSize - lastGood );
556- for (long i = 0 ; i < probe ; i ++) {
557- if (Unsafe .getUnsafe ().getByte (addr + lastGood + i ) != 0 ) {
558- return fileSize - lastGood ;
707+ try {
708+ for (long i = 0 ; i < probe ; i ++) {
709+ if (Unsafe .getUnsafe ().getByte (addr + lastGood + i ) != 0 ) {
710+ return fileSize - lastGood ;
711+ }
559712 }
713+ } catch (InternalError e ) {
714+ // The bail-out region is an unbacked (sparse) mapped page -- see
715+ // scanFrames for the mechanism. An unbacked hole was never written,
716+ // so it is clean unwritten space, not a torn write. Rethrow any
717+ // error that is not the recoverable mmap access fault.
718+ if (!isMmapAccessFault (e )) {
719+ throw e ;
720+ }
721+ return 0L ;
560722 }
561723 return 0L ;
562724 }
0 commit comments