Skip to content

post-#591 regression: exec(INSERT, [str, Buffer]) after SELECT segfaults the runtime #609

Description

@proggeramlug

Summary

Following up on #591 (which fixed argon2 / bcrypt FFI by flipping NATIVE_MODULE_TABLE entries from NA_F64 to NA_STR — landed in v0.5.699): a follow-on regression now crashes the runtime when a parameterized INSERT carrying a Buffer parameter follows a SELECT on the same Pool. The standalone form (single direct pool.query) works; routing through a small exec(sql, params) helper that calls getPool().query(sql, params) segfaults.

Process exits with code 139 (SIGSEGV). No JS-level error; nothing in stderr beyond the trace logs we placed.

Reproducer

// repro.ts
import { Pool } from "@perryts/mysql";
import * as crypto from "crypto";

let pool: Pool | null = null;
function initPool(dsn: string) { pool = new Pool({ url: dsn }); }
async function exec(sql: string, params: unknown[] = []): Promise<void> {
  await pool!.query(sql, params);
}
async function query(sql: string, params: unknown[] = []): Promise<unknown[]> {
  const r = await pool!.query(sql, params);
  return r.rows;
}

async function main() {
  initPool("mysql://root:@127.0.0.1:3306/shopadmin");

  await exec("DROP TABLE IF EXISTS cryptoKeys");
  await exec(`CREATE TABLE cryptoKeys (
    kid VARCHAR(64) NOT NULL,
    algorithm VARCHAR(32) NOT NULL,
    wrappedKey VARBINARY(2048) NOT NULL,
    status ENUM('active','retired') NOT NULL,
    createdAt DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    PRIMARY KEY (kid)
  ) ENGINE=InnoDB`);

  // SELECT first (returns 0 rows on fresh table).
  const rows = await query("SELECT kid FROM cryptoKeys LIMIT 1");
  console.log("rows.length=" + rows.length);

  // Build a 64-byte Buffer. Same pattern as `Buffer.concat([dek, mac])`.
  const dek = crypto.randomBytes(32);
  const mac = crypto.createHmac("sha256", crypto.randomBytes(32)).update(dek).digest();
  const wrapped = Buffer.concat([dek, mac]);
  console.log("wrapped.length=" + wrapped.length); // 64

  // INSERT with the Buffer as a parameter — segfaults here.
  await exec(
    "INSERT INTO cryptoKeys (kid, algorithm, wrappedKey, status) VALUES (?, 'aes-256-gcm', ?, 'active')",
    ["dek-1", wrapped],
  );
  console.log("inserted ok"); // never reached
}
main().catch((e) => { console.error("FATAL:", e); process.exit(2); });
$ perry compile repro.ts -o repro && ./repro
rows.length=0
wrapped.length=64
$ echo "exit: $?"
exit: 139

A single-statement reproducer without the SELECT-then-INSERT pattern, and without the indirection through the exec helper, runs fine:

// inline pool.query, no helper, no preceding SELECT — works
const pool = new Pool({...});
await pool.query("CREATE TABLE probe (kid VARCHAR(64), body VARBINARY(2048))");
await pool.query("INSERT INTO probe (kid, body) VALUES (?, ?)", ["k", buf]); // ok

So the segfault appears tied to the combination of (a) a small wrapper that re-invokes pool.query from a different stack frame, (b) a prior parameterized SELECT on the same Pool, and (c) a Buffer parameter to the subsequent INSERT.

Discovery context

Hit this in our app's boot path:

[1/6] init MySQL pool                            ✓
[2/6] run migrations                              ✓ (5/5 applied)
     applied: 0001_init.sql ... 0005_shop_timezone.sql
[3/6] ensure active crypto KID
[trace] ensureActiveDek: A getActiveCryptoKey    ✓ (returns null on fresh DB)
[trace] ensureActiveDek: B existing=no
[crypto] WARNING: Perry stdlib lacks AES; ...
[trace] ensureActiveDek: C build kid             ✓
[trace] ensureActiveDek: D kid=dek-...            ✓
[trace] ensureActiveDek: E dek len=32             ✓
[trace] ensureActiveDek: F wrapped len=64         ✓
                                                  ✗ (SIGSEGV before G)

The await insertCryptoKey(kid, wrapped) on the next line never logs G ("inserted") — the runtime is gone.

The exact same code path runs cleanly under tsx server/main.ts (24 e2e suites green).

Environment

  • perry 0.5.706
  • @perryts/mysql 0.1.3
  • --target macos, arm64
  • MySQL 9.6

Why we filed

Last gap blocking the native server binary deploy after #591's argon2 fix landed. With #591 the binary boots through migrations and the crypto KID step if there's already an active key in cryptoKeys (existing branch — uses cache, no INSERT). On a fresh DB the boot dies at the first INSERT into cryptoKeys. Same symptom blocks any boot path that does parameterized INSERT with a Buffer following a SELECT through a wrapper — which is most of our auth/session/idempotency code.

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