Skip to content

GC fix in #34 doesn't fully cover wrapper-object allocation in row decode (silent process exit on second 1k×20 query) #35

Closed

Description

@proggeramlug

Summary

The malloc-trigger fix in #34 (v0.5.25) hugely improves the bigint-allocation case (8.45 GB → 36 MB on 2M parses, thanks!) but the original @perry/postgres codec-bench scenario from #34 still degrades, just one iteration later and with a different exit signature:

The smaller tiny (SELECT 1) and param-1row workloads now loop 50× cleanly; the big-result one still doesn't survive past 1–2 iterations, and the 10000-row variant still dies on the first attempt.

Repro

@perry/postgres bench/bench-this.ts workload medium-1k-x-20:

// 1000 rows × 20 mixed-type columns (int8, text, numeric, bool repeating).
const sql = `SELECT
    (n*1)::int8 c0, ('row-'||n||'-c1')::text c1,
    (n::numeric/3)::numeric(20,5) c2, (n%2=0)::bool c3,
    /* ... 16 more in the same pattern ... */
    FROM generate_series(1, 1000) n`;

for (let i = 0; i < 50; i++) {
    console.log('# iter', i);
    const r = await conn.query(sql);
    if (r.rows.length !== 1000) throw new Error('bad row count');
}

Output:

# iter 0
# iter 1
# iter 2          ← never logs "iter 3"; process exits 0 here

No exception is thrown. The promise from the iter-2 conn.query(...) never settles. The driver's connection state is still alive (the bench's catch handler doesn't fire), but the event loop has nothing else queued, so the runtime cleanly exits.

Versus #34

The original ticket was a segfault (exit 139) — clearly memory corruption / OOM. After v0.5.25 the symptom became a silent zero-exit. That's actually a regression in observability — at least exit 139 made the failure mode visible. Suggests the GC sweep is now triggering and freeing something that shouldn't have been freed: the connection's state Map, or the pending-query slot, or some node in the awaited promise chain.

What changed between fail boundaries

iters survived Perry version
1 + crash 0.5.24 (pre-#34 fix)
2 + silent exit 0.5.25 (post-#34 fix)

So #34 bought one more iteration. The leak still scales with cells decoded. Each row produces 20 wrapper allocations (Decimal for 5 numeric columns, plus per-row Record<string,unknown> and unknown[] and 20 cell entries each). With 1000 rows that's ~25k allocations per call — eventually collides with whatever the new GC trigger threshold settles to (survivors + 10k per the #34 commit message).

A possible mechanism: a wrapper holds a transient root that doesn't survive a sweep. When the sweep runs in the middle of decoding row N, the prior rows 0..N-1 stored in the resulting array are reachable only through one of those holders, which the sweep frees → the result array's tail entries become dangling → the next deref tears the connection state apart silently.

Suggested investigation

Environment

Activity

  1. added a commit that references this issue on Apr 15, 2026
    b0626a3
  2. added a commit that references this issue on Apr 25, 2026
  3. added 2 commits that reference this issue on May 5, 2026
  4. added a commit that references this issue on May 27, 2026
  5. added a commit that references this issue on Jul 4, 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