Skip to content

[fix] Keep inherited Ray lock handles alive in forked children - #202

Open
OutstanderWang wants to merge 1 commit into
Ascend:mainfrom
OutstanderWang:fix/fork-keeps-lock-handles
Open

OutstanderWang wants to merge 1 commit into
Ascend:mainfrom
OutstanderWang:fix/fork-keeps-lock-handles

Conversation

@OutstanderWang

@OutstanderWang OutstanderWang commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

After tq.init(), a process that forks (for example datasets.map(num_proc=N)) can break both the child and the parent when it holds handles to the KV global lock actors.

The child inherits the parent's Ray actor handles for the lock: _owned, _managers, and the handles held by the leases in _leases. _reset_after_fork() used to just reassign these globals to empty containers. That dropped the last reference to the inherited handles, so they were released inside the child. Releasing a Ray handle there runs the parent's core worker in the forked process, which causes three problems:

  1. The child crashes in _int_free.
  2. The parent's lock actors can be reported out of scope and get killed. In our repro, every TransferQueueLockManager died.
  3. The parent's later GCS calls time out, for example ray.get_actor(...).

When it triggers

Only a process that holds lock-actor handles when it forks is affected:

  • the process whose tq.init() started TransferQueue with TQ_ENABLE_KV_GLOBAL_LOCK set, since _owned holds the actors it created; or
  • any other process that has already called kv_global_lock, which looks the actors up and caches them in _managers. If it forks while holding a lock, _leases carries handles too.

Without the global lock, tq.init() creates no lock actors and _owned, _managers and _leases stay empty, so this bug cannot trigger. kv_local_lock is in-process only and holds no Ray handles.

Fix

Before resetting its state, _reset_after_fork() now moves the inherited _owned, _managers and _leases into a module-level _inherited_after_fork list, and nothing ever releases that list. The child still starts with a clean lock state, but the inherited handles keep a reference and are never garbage-collected in the child, so Ray never tries to release them there.

The cost is that a forked child holds on to a few stale handle objects for its whole lifetime. That is negligible. Within TransferQueue it is the only option, because an inherited handle cannot be released safely from a fork. Callers can avoid the issue entirely by not forking after tq.init(), for example by using the spawn start method.

This relies on the child not running module teardown. A child started with multiprocessing's fork method exits through os._exit, so the list is never collected. A child that instead exits through normal interpreter shutdown could still release the handles during teardown. That is Ray's general limitation on forking after ray.init(), and it is out of scope here.

Changes

  • transfer_queue/kv_lock.py: adds _inherited_after_fork and moves the inherited state into it in _reset_after_fork().
  • tests/test_kv_lock.py: adds test_fork_child_keeps_inherited_lock_handles_referenced. It runs _reset_after_fork() and gc.collect(), then checks two things: the live state is reset, and the objects inherited through _owned and _managers are still alive (checked through weakref). _leases is parked the same way but is not covered by the test.

How to reproduce (before this fix)

  1. Set TQ_ENABLE_KV_GLOBAL_LOCK for the tq.init() that starts TransferQueue. Then fork either from that process, or from any process that has already called kv_global_lock.
  2. Run datasets.Dataset.map(..., num_proc>1).
  3. Result: the child processes crash, the TransferQueueLockManager actors die, and a later ray.get_actor in the parent times out.

Test

pytest tests/test_kv_lock.py

…leasing them

Releasing an inherited Ray actor handle in a fork child runs the parent's core
worker there. The child crashes in _int_free, the parent's lock actors can be
reported out of scope and die, and the parent's later GCS calls time out. A
datasets map with num_proc after tq.init() hit all three: every
TransferQueueLockManager died and ray.get_actor in the parent timed out.

_reset_after_fork now parks the inherited _owned, _managers and _leases in
_inherited_after_fork, which is never released, before resetting the state.

Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-22T12:28:04.653754Z 170f6f1 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 170f6f1eec

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread transfer_queue/kv_lock.py
_managers: dict = {} # shard -> TransferQueueLockManager handle, looked up on first use
_owned: list = [] # the lock actors this process's tq.init() created, killed by its tq.close()
_closes = 0 # this process's tq.close() calls: a lease created before the latest one is given up
_inherited_after_fork: list = [] # a forked child's copies of the parent's handles, never released

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Delete the redundant one-line comment

This trailing comment is a one-line description of what the variable contains and how long it lives, not the reason that retaining the references is necessary; the scoped comment rule requires a concise 2–4-line “why” explanation. _reset_after_fork already provides that rationale immediately before the append, so delete this duplicate rather than leave two explanations that can drift.

AGENTS.md reference: AGENTS.md:L24-L26

Useful? React with 👍 / 👎.

@OutstanderWang OutstanderWang changed the title [fix] a forked child keeps the lock handles it inherits instead of re… [fix] Keep inherited Ray lock handles alive in forked children Oct 22, 2026
@OutstanderWang
OutstanderWang force-pushed the fix/fork-keeps-lock-handles branch from 170f6f1 to fbd4236 Compare October 22, 2026 12:36
Comment thread tests/test_kv_lock.py
fake_lock_actors.options.assert_not_called()


def test_fork_child_keeps_inherited_lock_handles_referenced(monkeypatch):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be helpful to add a test for handles retained through _leases as well?

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