Skip to content

Make sync wrapper fork-safe#8

Merged
gregsvo merged 1 commit into
ActiveCampaign:mainfrom
yibudak:codex/fork-safe-sync-wrapper
Jun 12, 2026
Merged

Make sync wrapper fork-safe#8
gregsvo merged 1 commit into
ActiveCampaign:mainfrom
yibudak:codex/fork-safe-sync-wrapper

Conversation

@yibudak

@yibudak yibudak commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Fix postmark.sync so its background event-loop thread is safe to use after a process fork.

The sync wrapper currently creates a module-level event-loop thread at import time. In prefork servers, such as Odoo/Gunicorn-style worker deployments, importing postmark.sync before worker fork leaves child processes with a reference to an event loop whose thread no longer exists. A later sync call schedules a coroutine onto that stale loop and waits forever.

This change makes _EventLoopThread lazy and fork-aware. Before each run it checks the current PID and thread liveness, and starts a fresh event loop thread when needed.

Root Cause

asyncio.run_coroutine_threadsafe(...).result() blocks indefinitely if the target loop is not running. After fork(), the background loop thread created before the fork is gone in the child process, but the module-level _loop object still points to the old loop.

Changes

  • Start the sync wrapper event-loop thread lazily.
  • Track the PID that owns the loop thread.
  • Recreate the event loop thread when the PID changes or the thread is no longer alive.
  • Add a regression test that starts the module loop before fork() and verifies the child can still run a sync call.

Validation

  • UV_CACHE_DIR=/tmp/uv-cache uv run --with-editable . --with pytest --with pytest-asyncio --with httpx --with pydantic --with email-validator --with tenacity --with respx pytest
  • Result: 417 passed, 1 warning

The warning is Python's expected os.fork() deprecation warning for multithreaded processes, which is exactly the deployment class this regression protects.

@yibudak yibudak marked this pull request as ready for review June 12, 2026 07:04
@yibudak

yibudak commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@gregsvo

@gregsvo

gregsvo commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the submission! I wouldn't have found this without your heads-up. I'll try and get it tested and merged by Monday.

@yibudak

yibudak commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the submission! I wouldn't have found this without your heads-up. I'll try and get it tested and merged by Monday.

I only noticed it today while porting our codebase from postmarker to postmark-python.

Everything was fine in staging and all tests passed, but the issue appeared after deploying to our multi-worker production server.

It was a useful experiment for me.

@gregsvo

gregsvo commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Your fix is right on. I added one assertion to the fork test that verifies _loop._pid is updated to the child's PID after _ensure_started() runs. But otherwise all good. Applying your commits + that addition to main in a sec...

gregsvo added a commit that referenced this pull request Jun 12, 2026
@gregsvo gregsvo merged commit 8c11e18 into ActiveCampaign:main Jun 12, 2026
@gregsvo

gregsvo commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator
image

Applied via direct merge to main (7411d92). Thanks for the good catch, and making this better for everyone. Patch release incoming...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants