Skip to content

Cold-cache bundle intermittently SIGBUS in SQLite after duplicate cache initialization #746

Description

@kvz

Summary

edge-runtime bundle intermittently terminates with SIGBUS (Docker exit 135) on
a fresh cache while bundling a local CommonJS import. No Supabase project,
credentials, network imports, or third-party npm package is required.

The reduced control below failed 6/90 times across three GitHub-hosted x64
runners. A separate SDK consumer produced three native crash dumps, all pointing
to SQLite WAL shared-memory access during Deno cache initialization.

Environment

  • Image: public.ecr.aws/supabase/edge-runtime:v1.76.2@sha256:edd22bef4477b900d5c300e287ce9b18bff9b81a0291bee14ee0b7c7b71a2899
  • Embedded Deno: 2.1.4.
  • GitHub-hosted Ubuntu 24.04 x64; kernel 6.17.0-1022-azure.
  • Observed on AMD EPYC 9V74 and 7763 hosts across the diagnostic runs.
  • Docker records OOMKilled: false; ample host RAM and disk were available.
  • Enlarging /dev/shm did not prevent the crash. A local ARM control did not
    reproduce it; that is not proof the bug is x64-only.

Reduced reproduction

Save as repro.ts, then run node repro.ts with Node 24 and local Docker.
It uses fresh containers and deletes only its own temporary directory/containers.
Repeat on a native Linux x64 host; the failure is timing-sensitive.

import { execFileSync, spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

const image = 'public.ecr.aws/supabase/edge-runtime:v1.76.2@sha256:edd22bef4477b900d5c300e287ce9b18bff9b81a0291bee14ee0b7c7b71a2899'
const root = mkdtempSync(join(tmpdir(), 'edge-cache-repro-'))
let failures = 0

try {
  mkdirSync(join(root, 'node_modules/control'), { recursive: true })
  writeFileSync(join(root, 'package.json'), '{"private":true,"type":"module"}\n')
  writeFileSync(join(root, 'deno.json'), '{"nodeModulesDir":"manual","lock":false}\n')
  writeFileSync(join(root, 'node_modules/control/package.json'), '{"name":"control","main":"index.cjs"}\n')
  writeFileSync(join(root, 'node_modules/control/index.cjs'), "exports.value = 'control'\n")
  writeFileSync(join(root, 'index.ts'), "import { value } from './node_modules/control/index.cjs'\nDeno.serve(() => new Response(value))\n")

  execFileSync('docker', ['pull', image], { stdio: 'inherit' })
  for (let attempt = 1; attempt <= 30; attempt++) {
    const id = execFileSync('docker', [
      'create', '--workdir', '/work',
      '--env', 'RUST_LOG=deno::cache::cache_db=trace',
      image, 'bundle', '--entrypoint', '/work/index.ts',
      '--output', '/work/function.eszip', '--timeout', '120',
    ], { encoding: 'utf8' }).trim()
    try {
      execFileSync('docker', ['cp', `${root}/.`, `${id}:/work`])
      const result = spawnSync('docker', ['start', '--attach', id], {
        encoding: 'utf8', timeout: 150_000,
      })
      console.log({ attempt, exitCode: result.status })
      if (result.status !== 0) {
        failures++
        console.log(result.stdout, result.stderr)
        console.log(execFileSync('docker', ['inspect', '--format', '{{json .State}}', id], { encoding: 'utf8' }))
      }
    } finally {
      execFileSync('docker', ['rm', '--force', id])
    }
  }
} finally {
  rmSync(root, { recursive: true, force: true })
}
console.log({ failures })
process.exitCode = failures === 0 ? 0 : 1

Trace and native evidence

All six reduced-control crashes log duplicate openings of both analysis caches,
then an initialization retry and a database is locked error before deletion:

DEBUG Opening cache /root/.cache/deno/dep_analysis_cache_v2...
DEBUG Opening cache /root/.cache/deno/node_analysis_cache_v2...
DEBUG Opening cache /root/.cache/deno/dep_analysis_cache_v2...
DEBUG Opening cache /root/.cache/deno/node_analysis_cache_v2...
Could not initialize cache database '.../node_analysis_cache_v2', retrying...
  Error code 14: Unable to open the database file
Could not initialize cache database '.../node_analysis_cache_v2', deleting and retrying...
  Error code 5: The database file is locked

Representative native stack from an uninstrumented SDK bundle crash:

Program terminated with signal SIGBUS, Bus error.
#0  walIndexAppend at sqlite3/sqlite3.c:66237
#1  walFrames
#2  sqlite3WalFrames
#3  pagerWalFrames
...
#14 rusqlite::raw_statement::RawStatement::step
#16 rusqlite::Connection::execute_batch
#17 deno::cache::cache_db::CacheDB::initialize_connection at deno/cache/cache_db.rs:237
#18 CacheDB::open_connection_and_init at deno/cache/cache_db.rs:270
#20 deno::cache::cache_db::open_connection at deno/cache/cache_db.rs:419
...
#31 CacheDB::spawn_eager_init_thread

The signal is SIGBUS / BUS_ADRERR; its address falls inside the mapped
dep_analysis_cache_v2-shm or node_analysis_cache_v2-shm file. Line 419 is the
retry after removing the database. A syscall trace from another run shows one
thread unlinking the DB while another still uses it, followed by both opening
and truncating the same -shm pathname. That traced run itself completed; the
three crashes were captured as bounded core dumps and analyzed offline.

Suspected mechanism

EmitterFactory::caches()
creates a fresh Caches on every call and eagerly initializes both databases.
module_graph_builder()
calls it and then calls module_info_cache(), which calls it again. Their mutexes
are independent but their file paths are identical.

open_connection
retries after creating the parent directory, then deletes the database after
another initialization error, including SQLITE_BUSY. Replacing an open SQLite
database lets different DB inodes share WAL/shm paths, an unsafe pattern also
documented in SQLite's guidance.

Sharing the factory's cache instance and not treating SQLITE_BUSY as a reason to
unlink an in-use database appear to be the appropriate fixes. These fixes have
not been implemented or tested here.

Counterfactuals, not a claimed upstream fix

Across three x64 runners, the same SDK graph/image passed 90/90 times with
--tmpfs /root/.cache/deno:size=268435456. This also pre-creates the directory, so
it does not distinguish filesystem behavior from initialization timing. Twelve
runs still logged busy errors; none logged deletion. Merely preheating a disk
cache also gave 90/90 passes, but 39 runs still deleted/recreated a database, so
that does not appear to be a safe solution.

The reduced test contains no application secrets and all failures were retained;
these were not retries used to turn a failing CI job green.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions