Skip to content

pbc: heap corruption crashes large-tree analysis (non-deterministic; original stack-overflow diagnosis was wrong) #3

Description

@jimmykirk

Summary

pbc segfaults partway through parsing a large source tree when the process
has the common default 8 MB stack. It is a C-stack overflow, not an OOM, and
raising RLIMIT_STACK is enough to get past it.

I lost a fair amount of time treating this as a memory-size problem, so the
elimination evidence is below in case it saves someone else the trip.

Reproduction

Parsing a tree of ~5,500 .sr* files (a mix of .srd, .srw, .sru) inside
a container with the default 8 MB stack:

pbc --input <tree> --db out.duckdb --sql-worker-python python
-> Segmentation fault (core dumped), exit 139

It dies during phase A0, after a different number of files each run:

816 / 1421 / 2476 / 2577 files parsed before the crash

It is not memory, and not parallelism

Same input, varying only the RTS flags — every one segfaults:

flags result
-M4G -N4 SIGSEGV, 41s
-M8G -N4 SIGSEGV, 41s
-M12G -N4 SIGSEGV, 45s
-M16G -N1 SIGSEGV, 46s
-M16G -N2 SIGSEGV, 13s
-M16G -N4 SIGSEGV, 44s

A genuine heap overflow aborts cleanly with Heap exhausted, and -M4G would
certainly hit that ceiling first if this were one. The crash ignores the heap
cap entirely, and -N1 rules out a data race in the parallel path.

It is the stack

Changing nothing but RLIMIT_STACK:

stack result
8 MB (default) SIGSEGV after 816–2577 of 5473 files
unlimited 5446 of 5473 files parsed

This also explains the non-determinism: how deep the parser recurses depends
on which object it is on when cumulative depth crosses the limit, so the
crash lands on a different file each run, and larger trees fail more often
simply because they offer more chances to hit a deep object.

Workaround

docker run --ulimit stack=536870912:536870912 ...

A finite value rather than unlimited matters: glibc sizes a new thread's
stack from RLIMIT_STACK, but falls back to a fixed 8 MB default when it is
unlimited, so unlimited fixes only the main thread and leaves -N workers
at the broken size.

What I could not narrow down

I could not build a minimal synthetic reproducer. These all parse cleanly at
8 MB, so the deep recursion does not appear to be in expression parsing:

  • a 50,000-term flat concatenation ("a" + "b" + ...)
  • 30,000-deep explicit paren nesting
  • 4,000 trivially-simple generated window objects

Splitting a real tree by file type, at 8 MB stack, also did not isolate it:
the .srd files alone parsed clean, and the .sru files alone parsed clean.
The trigger seems to need a mix. I am not able to share the tree that
reproduces it.

Offer: pbc ships stripped, so the cores I have are addresses only. I am
happy to rebuild with --disable-executable-stripping --enable-debug-info
and post a backtrace — that would presumably identify the recursive function
outright and save you reproducing this at all. Say the word and I will run it.
I can also test any candidate patch against the tree that reproduces it, which
gets you a verification path without needing the source.

Environment

  • pbc built from haskell:9.10.2-bullseye, GHC 9.10.2, per the versions in
    the project's own CI workflow
  • Linux x86_64, 64 GB RAM, 16 cores
  • Container memory limit well above peak usage in every run above

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions