Repository navigation
Wrapper-heavy row decode crashes (SIGSEGV) on second 1000-row × 20-col query, after #34 + #35 fixes #36
Description
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:
- Memory leak / OOM in codec decode path on large result sets (segfault on second 1k×20 query, first 10k×20) #34 (gc_check_trigger from gc_malloc) bounded the bigint accumulator burst case — pre-fix that grew unbounded.
- GC fix in #34 doesn't fully cover wrapper-object allocation in row decode (silent process exit on second 1k×20 query) #35 (root-scan for
net.Socketlistener closures) stopped the silent freeing of the data-event callback during a sweep mid-decode.
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:
- (a) a stale GC root that the sweep doesn't see, or
- (b) a heap-corruption pattern that the sweep introduces (unlike GC fix in #34 doesn't fully cover wrapper-object allocation in row decode (silent process exit on second 1k×20 query) #35's "freed live root" pattern, this one yields a hard fault rather than silent unwind).
Where to look
Educated guesses based on what changes between iter 0 → iter 1:
- Decimal wrapper allocation. Each
Decimalcell 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_TAGboxing; if the bigint NaN-boxed pointer doesn't have a root scanner reaching it through the per-cellunknown[]and per-rowRecord<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 ther.rows.lengthaccess at the call site). Map<string, Codec>entries. The codec registry is hit per cell via apickDecoder-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
feat: parseTypes: 'minimal' opt-out — return strings for int8/numeric…
proggeramlug commentedon Apr 15, 2026
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
datalistener onnet.Socketwas 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>:So the trigger is one of: (a) the per-row
{}object literal + dynamic key writes, (b) the per-rowunknown[]array, or (c) the rowsRawBufferslice 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):
But run in sequence in one process (~accumulated row-objects across queries):
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 nonew Functionrowctor is available, or via a generatednew RowCtor(arr)when one is) live in the GC arena, get rooted via the result'srows: T[]array slot, get released to GC when theawait conn.query(...)promise's result falls out of scope at the call site... and then either: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.
bench: narrowing repros for PerryTS/perry#36
bench: minimal Perry-side crash repro for PerryTS/perry#36
fix: register module-level globals as GC roots (closes PerryTS#36, v0…
bench: Perry 0.5.28 fixed #36 — drop iteration overrides, full matrix
fix(runtime): #36/#321 — class extends function-value inherits static…
fix(runtime): bind this to receiver for inherited symbol-keyed method…
fix(runtime): resolve `[Symbol.iterator]` on a class declaration (#6454…
fix(runtime): static-member CALL walks the parent-closure chain (#6475)
fix: static/namespace resolution for effect Tag & Schema — web.ts ser…