Diagnosing mmap SIGBUS on 32-bit Targets

A 32-bit process — an ARMv7 gateway, an old MIPS router build, a Python service on a armhf image — enables a generous PRAGMA mmap_size to skip the read syscall, and then dies with SIGBUS (bus error, signal 7) somewhere inside a SELECT. The crash looks like corruption and is often misfiled as one, but the root cause is almost always the address space: a 32-bit process has only ~2–3 GB of usable virtual address, and SQLite maps the database file into that space in one contiguous region. Ask for a mapping larger than the free address space, or let the mapped file get truncated underneath the process, and the CPU faults on the first access to a page that has no backing — which the kernel delivers as SIGBUS, not a tidy SQLITE_IOERR. This page isolates that exact failure and its fix, as one case within Memory-Mapped I/O Configuration, part of the broader WAL Optimization & Concurrency Tuning discipline. The stakes are that a SIGBUS misread as SQLITE_CORRUPT sends you down a needless recovery path on a database that is perfectly intact.

Diagnosis

SIGBUS from a memory-mapped SQLite database has a recognizable signature, and the first job is to separate it from real disk corruption. They demand opposite responses: an address-space fault needs a smaller mmap_size, while true corruption needs the recovery path.

Start with the crash itself. A mapped-file fault shows up as signal 7 with the faulting address inside the mapped region, and — when the file was truncated under the mapping — a kernel log line naming the file:

# core dump / crash: "SIGBUS (Bus error)" not SIGSEGV; faulting addr is inside the mmap region
dmesg | tail          # look for "possible attempt to map ..." or a truncation/ENOSPC note on the db path
getconf LONG_BIT      # 32 confirms a 32-bit userland: the ~2-3 GB address-space ceiling applies

Then read what SQLite was actually asked to map. A mmap_size larger than the free virtual address space — or larger than the compile-time ceiling — is the smoking gun:

PRAGMA mmap_size;   -- bytes SQLite will try to memory-map; 0 = mmap disabled. Big value on 32-bit = suspect
PRAGMA page_size;   -- mapping is page-aligned; not the cause here but part of the footprint picture

Compare that number against the machine. On a 32-bit target the process address space is the hard limit, and the database file size relative to it decides whether the whole file can be mapped safely:

import os
import sqlite3

db = "/var/lib/telemetry/sensors.db"
try:
    conn = sqlite3.connect(db)
    mmap_bytes = conn.execute("PRAGMA mmap_size;").fetchone()[0]   # what SQLite will map
    file_bytes = os.path.getsize(db)                              # actual file on disk
    bits = 64 if os.sys.maxsize > 2**32 else 32                   # userland pointer width
    print(f"arch={bits}-bit mmap_size={mmap_bytes} file={file_bytes}")
    # On 32-bit, mmap_bytes anywhere near or above ~2 GB, or a file larger than free
    # address space, is the SIGBUS cause -- not corruption.
    if bits == 32 and mmap_bytes > 1_073_741_824:                 # >1 GiB is already risky on 32-bit
        print("mmap_size too large for a 32-bit address space -- cap or disable it")
except sqlite3.Error as exc:
    print(f"open failed: {exc}")

The distinguishing test is decisive: if the very same database file opens and passes an integrity scan on a 64-bit host, or opens cleanly on the 32-bit device once mmap_size is set to 0, the data is fine and the fault was the mapping — not SQLITE_CORRUPT.

Figure — On a 32-bit target a mapping is safe only while it fits the ~2–3 GB address space and the file behind it is intact; oversizing it or truncating the file faults as SIGBUS.

Deciding whether a SQLite mmap crash on a 32-bit target is SIGBUS or corruption A decision flow. Start from a SIGBUS crash. Check the architecture bits: on 32-bit, check whether mmap_size fits the two-to-three gigabyte address space. If it does not fit, or the mapped file was truncated, the fault is a mapping problem to fix by capping mmap_size. If the file is intact and still faults on a 64-bit host, it is genuine corruption needing the recovery path. SIGBUS signal 7 in mmap region 32-bit? mmap_size fits the ~2–3 GB address space + file intact? mapping problem too large, or file truncated under it cap / disable mmap_size data is fine opens on 64-bit or with mmap_size=0 not SQLITE_CORRUPT no yes

Solution

The fix is to bound mmap_size to something a 32-bit address space can actually hold, and to verify the read-back — because a compile-time cap can silently reduce the request to zero, and a request the build ignored leaves you exactly where you started. The routine picks a conservative cap on 32-bit hardware, applies it, confirms the applied value, and (as a safety net) escalates a SIGBUS seen during a read into an explicit integrity check rather than a blind recovery:

import os
import signal
import sqlite3
import logging

logger = logging.getLogger("sqlite_mmap_guard")

# A safe upper bound for a 32-bit user address space. The theoretical ceiling is ~2-3 GB,
# but SQLite needs one *contiguous* free region, so keep the cap well under it.
SAFE_MMAP_32BIT = 256 * 1024 * 1024     # 256 MiB
SAFE_MMAP_64BIT = 1024 * 1024 * 1024    # 1 GiB is fine where address space is abundant


def is_32bit() -> bool:
    return os.sys.maxsize <= 2**32       # pointer width of THIS process, not the kernel


def open_with_bounded_mmap(db_path: str, timeout: float = 15.0) -> sqlite3.Connection:
    target = SAFE_MMAP_32BIT if is_32bit() else SAFE_MMAP_64BIT
    conn = sqlite3.connect(db_path, timeout=timeout, isolation_level=None)
    try:
        conn.execute("PRAGMA journal_mode=WAL;")           # unrelated to the fault; keep the baseline
        conn.execute(f"PRAGMA mmap_size={target};")        # cap the mapping to what the arch can hold
        # Read the value back: a compile-time SQLITE_MAX_MMAP_SIZE can clamp this below `target`,
        # or a build without mmap support returns 0. Either way, know the real applied size.
        applied = conn.execute("PRAGMA mmap_size;").fetchone()[0]
        if applied != target:
            logger.warning("mmap_size clamped: requested=%d applied=%d (SQLITE_MAX_MMAP_SIZE?)",
                           target, applied)
        if is_32bit() and applied > SAFE_MMAP_32BIT:
            # Refuse to run past the safe bound on 32-bit rather than risk another SIGBUS.
            conn.execute("PRAGMA mmap_size=0;")            # 0 = disable mmap; fall back to read()/pread()
            logger.error("applied mmap_size %d unsafe on 32-bit; disabled mapping", applied)
        return conn
    except sqlite3.Error:
        conn.close()
        logger.exception("mmap-bounded open failed for %s", db_path)
        raise

The load-bearing choices: SAFE_MMAP_32BIT is set to 256 MiB — far below the ~2–3 GB ceiling — because SQLite needs one contiguous free region and a long-running 32-bit process fragments its address space, so the largest hole is much smaller than the total free bytes. os.sys.maxsize tests the pointer width of the running process, which is what matters (a 32-bit build on a 64-bit kernel is still limited). And when the read-back proves the build honored an unsafe size anyway, the code sets mmap_size=0 to fall back to ordinary pread(), trading the syscall saving for a process that does not fault. The mapping-versus-cache trade-off behind choosing zero is developed in Tuning cache_size for Embedded Linux.

Verification

Confirm the mapping is bounded, then confirm the database it was blamed for is actually sound.

First, assert the applied mmap_size is within the safe bound for the architecture — catching a build whose SQLITE_MAX_MMAP_SIZE differs from your request:

conn = open_with_bounded_mmap("/var/lib/telemetry/sensors.db")
applied = conn.execute("PRAGMA mmap_size;").fetchone()[0]
if is_32bit():
    assert applied <= SAFE_MMAP_32BIT, f"mmap_size {applied} exceeds the 32-bit safe bound"
assert applied >= 0, "mmap_size read-back returned a nonsense value"

Second, prove the crash was the mapping and not corruption by scanning the database with the mapping out of the way. A clean result exonerates the file:

conn.execute("PRAGMA mmap_size=0;")                      # scan without the mapping in play
rows = conn.execute("PRAGMA integrity_check;").fetchall()
assert rows == [("ok",)], f"genuine corruption, not a mapping fault: {rows}"

If integrity_check returns ok with mmap_size=0, the data was never damaged and the SIGBUS was purely an address-space fault — the bounded mmap_size from the solution is the whole fix. If it returns anything else, you have real corruption and must move to the recovery path in Recovering a Malformed Database Image.

Failure Modes & Gotchas

A compile-time SQLITE_MAX_MMAP_SIZE silently clamps your PRAGMA. SQLite enforces a build-time maximum mapping size, and many embedded distributions set it low or to zero. PRAGMA mmap_size=268435456 on such a build may quietly resolve to a smaller value — or to 0 — so the request you think protects you does nothing, or the mapping you rely on never forms. The read-back assertion is the only way to know the real applied size; never assume the write took. When the build’s ceiling is unknown, treat PRAGMA mmap_size output as authoritative and size the rest of your memory plan around it.

ENOSPC under a mapping turns an ordinary disk-full into a SIGBUS. With mmap_size enabled, SQLite writes to the database by storing into the mapping; if the filesystem is out of space when the kernel tries to write back a dirty mapped page, that I/O error is delivered to the process as SIGBUS rather than a catchable SQLITE_FULL. On a flash device that fills up this reads exactly like corruption. Watch free space independently, and where a target routinely runs near full, disabling mmap (mmap_size=0) restores the normal path where a full disk surfaces as SQLITE_FULL you can handle.

A checkpoint or external truncation invalidates a live mapping. The mapping is a window onto the file as it was sized when mapped; if the file shrinks underneath it — a VACUUM, an aggressive WAL checkpoint that truncates, or another process rewriting the file — an access to a now-unbacked page faults as SIGBUS. Keep file-mutating operations coordinated with the mapped readers, avoid truncating VACUUM while mappings are live on a 32-bit target, and prefer the parent guide’s guidance in Memory-Mapped I/O Configuration for pairing mmap with a checkpoint cadence that does not pull the file out from under a reader.