Skip to content

Skip atexit in forked task children rather than running post-fork handlers - #72035

Draft
seanmuth wants to merge 1 commit into
apache:mainfrom
seanmuth:seanmuth/fork-safe-child-exit
Draft

Skip atexit in forked task children rather than running post-fork handlers#72035
seanmuth wants to merge 1 commit into
apache:mainfrom
seanmuth:seanmuth/fork-safe-child-exit

Conversation

@seanmuth

@seanmuth seanmuth commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

A forked task child could hang forever if any library registered an atexit handler that isn't fork-safe -- one that holds a native lock or thread that doesn't survive fork().

Task-SDK's custom exit path in _fork_main clears any atexit handlers inherited from the parent immediately after forking (atexit._clear()), then runs whatever gets registered during the child's own subsequent execution via atexit._run_exitfuncs() before calling os._exit(). That's the actual gap: a library imported for the first time inside the forked child -- after the clear already ran -- re-registers its own atexit handler into what is by then an empty-then-repopulated registry, and that fresh, post-fork registration is what _run_exitfuncs() executes. The _clear() correctly protects against anything inherited from the parent; it does nothing to protect against a fork-unsafe handler the child registers on its own a moment later.

This was hit in production exactly that way: pyiceberg imports pyarrow.fs for the first time inside target() (task execution, i.e. after the clear), which calls atexit.register(ensure_s3_finalized) into the now-repopulating registry. When _run_exitfuncs() later runs it, ensure_s3_finalized deadlocks in pyarrow's AWS-CRT teardown -- confirmed via a symbolized gdb backtrace on a live wedged production process (atexit._run_exitfuncs() -> ensure_s3_finalized -> EnsureS3Finalized -> ... -> futex_wait forever).

The same failure mode applies to any fork-unsafe atexit handler registered by any library during the child's post-fork execution, not just pyarrow's. This skips atexit entirely in the forked child rather than trying to special-case known offenders -- there's no way to tell a safe handler from an unsafe one in advance.

The prior post-fork-atexit behavior was a deliberate choice (see discussion on the internal tracking ticket) to let task code tidy up after the process finishes. This PR trades that intentional cleanup opportunity for guaranteed exit -- happy to discuss alternatives (e.g. running atexit with a timeout) if that tradeoff needs more thought.


Was generative AI tooling used to co-author this PR?
  • Yes -- Claude Code (Sonnet 5)

Generated-by: Claude Code (Sonnet 5) following the guidelines

…dlers

A forked task child could hang forever if any library registered an
atexit handler that isn't fork-safe -- one that holds a native lock or
thread that doesn't survive fork(). Task-SDK's custom exit path ran
these handlers via atexit._run_exitfuncs() before calling os._exit(),
so a single fork-unsafe handler could block that final os._exit() from
ever being reached, leaving the child, and the pod hosting it, stuck
until the pod's grace period expired.

This was hit in production via pyarrow's S3 client finalizer
deadlocking in its AWS-CRT teardown after pyiceberg registered it
post-fork, but the same failure mode applies to any fork-unsafe atexit
handler in any library. There's no way to tell a safe handler from an
unsafe one in advance, so skip atexit entirely rather than try to
special-case known offenders.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant