Skip to content

Wrapper-heavy row decode crashes (SIGSEGV) on second 1000-row × 20-col query, after #34 + #35 fixes #36

Closed

Description

@proggeramlug

Summary

After Perry 0.5.26 (with the GC fixes from #34 and the net.Socket listener root-scan from #35), the @perry/postgres bench can run unbounded small / single-row queries fine, but the second invocation of a wrapper-heavy 1000-row × 20-column query reliably segfaults with exit 139. The 10000-row variant still dies on the first attempt.

The previous symptom from #35 was a silent zero-exit when the GC sweep collected the listener closure mid-decode. That's gone — now we get a hard SIGSEGV in the same workload, with the boundary moved by exactly one iteration.

Repro

Single-file Perry program, no driver internals required. Connect to any local Postgres, run a 1000-row mixed-type SELECT in a loop. (The driver's full repro is at examples/perry-bench-crash-repro.ts in @perry/postgres.)

import { connect, Connection } from '@perry/postgres';

const SQL = (() => {
    const cols: string[] = [];
    for (let i = 0; i < 20; i++) {
        const j = i % 4;
        if (j === 0) cols.push('(n*' + (i+1) + ')::int8 AS c' + i);
        else if (j === 1) cols.push("('row-'||n||'-c" + i + "')::text AS c" + i);
        else if (j === 2) cols.push('(n::numeric/' + (i+1) + ')::numeric(20,5) AS c' + i);
        else cols.push('(n%2=0)::bool AS c' + i);
    }
    return 'SELECT ' + cols.join(', ') + ' FROM generate_series(1, 1000) AS n';
})();

async function main() {
    const conn: Connection = await connect({ host: '127.0.0.1', port: 55432, user: '...', database: '...' });
    for (let i = 0; i < 5; i++) {
        console.log('iter ' + i + ' before');
        const r = await conn.query(SQL);
        console.log('iter ' + i + ' after, rows=' + r.rows.length);
    }
    await conn.close();
}
main();

Output:

iter 0 before
iter 0 after, rows=1000
iter 1 before
[ exit 139 ]

Per-call work: ~25k wrapper-object allocations (5000 BigInt for int8 + 5000 Decimal for numeric + 5000 strings for text + 5000 booleans + 1000 row objects). Two iterations = 50k allocations.

What's different from #34 / #35

Both prior fixes apply and verifiably help:

But the bench still caps at one iteration of this workload. So a third allocation site is reachable from the same wrapper-decode pattern that has either:

Where to look

Educated guesses based on what changes between iter 0 → iter 1:

  • Decimal wrapper allocation. Each Decimal cell is a JS class instance carrying a string field. 5000 per query. The class shape was registered once at first use; the sweep between iterations might be reclaiming the class metadata (keys_array, parent registry entry) but instances allocated in the next pass deref it.
  • Bigint pool / refcount. The BigInt arithmetic and BigInt(string) constructor are unsupported #33 fix added BIGINT_TAG boxing; if the bigint NaN-boxed pointer doesn't have a root scanner reaching it through the per-cell unknown[] and per-row Record<string, unknown> containers, the second iter's sweep could free iter-0's bigints and then iter-0's row objects deref them when they get traversed somewhere (e.g. in the r.rows.length access at the call site).
  • Map<string, Codec> entries. The codec registry is hit per cell via a pickDecoder-cached function reference; if those closures get swept between iters, the next iter's call dereferences freed function pointers.

A faster check than each of those: stick a gc_log_sweep() between iters (or PERRY_LOG_GC=1 if there's a flag) and see what's freed during the sweep that fires during the iter-1 decode.

Versus the other recent issues

symptom fix
#33 int8 returns NaN bigint arithmetic + BigInt() coercion
#34 OOM on bigint burst gc_check_trigger from gc_malloc
#35 silent exit mid-decode scan_net_roots for socket listeners
this hard SIGSEGV on iter-2 of wrapper-heavy decode ?

Each fix peels one layer; this is the next.

Why it matters

Same as #34/#35: this caps any real workload that returns more than one moderately-large result set per process, which is most database use. The bench at @perry/postgres bench/bench-this.ts medium-1k-x-20 catches it; we currently work around with iterations=1 for that workload only.

Environment

  • Perry 0.5.26
  • macOS 26 / Apple Silicon
  • @perry/postgres latest, repro at examples/perry-bench-crash-repro.ts

Activity

  1. proggeramlug commented on Apr 15, 2026

    @proggeramlug
    ContributorAuthor

    Update after Perry 0.5.27

    Re-tested against Perry 0.5.27 (which extended the #35 root-scan to ws/http/events/fastify). Same SIGSEGV pattern. The fix didn't apply because the @perry/postgres data listener on net.Socket was already covered by 0.5.26.

    Narrowing — it's NOT about wrapper allocation

    I distilled the repro further. The original ticket pointed at Decimal/BigInt/PgDate wrapper allocation as the suspect. Wrong — the same crash happens with a 1000-row × 20-col SELECT of all int4 columns, which builds zero wrapper objects and just produces JS numbers + per-row Record<string, unknown>:

    // 1000 rows × 20 int4 columns. Every "value" is just a JS number.
    const sql = "SELECT (g*1)::int4 c0, (g*2)::int4 c1, ... (g*20)::int4 c19 "
                + "FROM generate_series(1, 1000) g";
    for (let i = 0; i < 3; i++) {
        const r = await conn.query(sql);  // ← second call SIGSEGVs
    }

    So the trigger is one of: (a) the per-row {} object literal + dynamic key writes, (b) the per-row unknown[] array, or (c) the rowsRaw Buffer slice accumulator — all heap allocations the row decoder makes regardless of column type.

    Narrowing — row-object count appears bounded by a cumulative budget

    Single-column scaling, run as separate processes (not in sequence):

    query iter 0 iter 1 iter 2
    500 rows × 1 col ✓ ✓ ✓
    1000 rows × 1 col ✓ ✓ ✓
    2000 rows × 1 col ✓ ✓ ✓
    4000 rows × 1 col ✓ ✓ ✓
    20000 rows × 1 col ✓ SIGSEGV —
    100000 rows × 1 col (didn't reach) — —

    But run in sequence in one process (~accumulated row-objects across queries):

    query iter 0 iter 1 iter 2
    4000 rows × 1 col (after 500+1000+2000) SIGSEGV — —

    So the threshold is cumulative row-object allocation count across process lifetime, somewhere between 10–20k rows. Once exhausted, the next decode segfaults.

    Likely cause

    Per-row Record<string, unknown> objects (built via {} + bracket-key writes when no new Function rowctor is available, or via a generated new RowCtor(arr) when one is) live in the GC arena, get rooted via the result's rows: T[] array slot, get released to GC when the await conn.query(...) promise's result falls out of scope at the call site... and then either:

    • (a) the next decode pass dereferences a now-freed shape pointer (consistent with hard SIGSEGV), or
    • (b) the freed-rooted-array's underlying buffer gets recycled by the arena and overlapped with the next allocation.

    Both fit the symptom that ANY type works (since the type doesn't matter; it's the row container) and that the boundary is a row count not a cell count.

    Repros

    Two single-file Perry programs in @perry/postgres for the investigation:

    • examples/perry-bench-crash-repro.ts — original 1000×20 mixed types, iter-2 SIGSEGV.
    • examples/perry-bench-narrow.ts — runs all-int4, all-bool, all-text, all-int8, all-numeric. The all-int4 case (no wrappers) also crashes on iter 1.

    Workaround in @perry/postgres

    We cap medium-1k-x-20 to 1 iteration and skip large-10k-x-20 entirely on Perry. Full benchmarks against pg / postgres.js / tokio-postgres pass; only Perry's row count is bounded.

  2. added a commit that references this issue on Apr 15, 2026
    934f161
  3. added a commit that references this issue on Apr 15, 2026
    80f01c9
  4. added 2 commits that reference this issue on May 27, 2026
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