Repository navigation
fix: reset background event loops in forked child processes - #1069
Conversation
|
All contributors have signed the DCO ✍️ ✅ |
|
Fern preview: https://nvidia-preview-pr-1069.docs.buildwithfern.com/nemo/datadesigner
|
|
Thanks for the careful fix and the detailed write-up, @binaryaaron. The epoll note about why the hooks drop the loop instead of closing it is especially useful. SummaryThis PR registers
FindingsWarnings — Worth addressing
Suggestions — Take it or leave it
What Looks Good
Residual Risk
Structural ImpactThe one reported import-direction violation ( Structural Impact (graphify 0.9.90, 8.2s)Risk: HIGH (1 import direction violation(s))
Import Direction Violations (1)Legal direction: interface -> engine -> config
High-Connectivity Changes
Cross-Package Dependencies
VerdictNeeds changes. There is one item before merge:
Everything else looks solid, and the loop-reset approach is right. This review was generated by an AI assistant. |
[Medium impact] The PR appears safe to merge; no new actionable issues were found. SummaryThis PR lets forked children start fresh engine and MCP background loops.
|
| self._active_run_ids.clear() | ||
| self._active_dataset_runs.clear() | ||
| self._dataset_progress_value = None | ||
| self._active_request_attempts.clear() |
There was a problem hiding this comment.
Child counts vanished requests
If the process forks while another thread has an active model request, the child clears _active_request_attempts but keeps _active_model_requests and its meter provider. The copied counter still includes the parent's request, but completion and run cleanup can no longer find the entry needed to subtract it. The child's endpoint can therefore report active requests after all child work finishes.
Reset the child's active-request measurements together with their bookkeeping, and test a fork during an active request.
Knowledge Base Used: Observability instrumentation
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/data-designer/src/data_designer/integrations/opentelemetry.py
Line: 291
Comment:
**Child counts vanished requests**
If the process forks while another thread has an active model request, the child clears `_active_request_attempts` but keeps `_active_model_requests` and its meter provider. The copied counter still includes the parent's request, but completion and run cleanup can no longer find the entry needed to subtract it. The child's endpoint can therefore report active requests after all child work finishes.
Reset the child's active-request measurements together with their bookkeeping, and test a fork during an active request.
**Knowledge Base Used:** [Observability instrumentation](https://app.greptile.com/nvidia-public-github/-/custom-context/knowledge-base/nvidia-nemo/datadesigner/-/docs/observability-instrumentation.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in #1070: the child now discards the inherited meter provider and instruments, so data_designer.model.request.active starts from zero in the child.
|
Issue #1068 received the |
nabinchha
left a comment
There was a problem hiding this comment.
Thanks for the focused fork-safety fix and regression tests, @binaryaaron.
Summary
The engine and MCP hooks restart background loops after a fork, matching the PR's intent. The metrics hook still retains SDK state that can deadlock a child. Reviewed commit 26ca25ef81548c0d04f4490bf1fdf0b1875491e2.
Validation: all 98 tests in the three affected test modules pass on CPython 3.13.3; Ruff lint and formatting checks pass for all six changed Python files.
Findings
Critical — Let's fix these before merge
packages/data-designer/src/data_designer/integrations/opentelemetry.py:287 — [P2] Recreate the metrics SDK state in forked children
- What: The hook replaces the runtime's lock and server but retains
_initialized,_meter_provider, the instruments and registry. Those objects contain additional locks used by the parent's metrics collection and recording threads. If another thread holds one when the process forks, its child copy stays locked permanently. - Why: I reproduced this with the singleton runtime initialized in the parent and its create-duration histogram aggregation lock held by another thread at fork. The child entered
observe_create()and bound a fresh server, but scraping timed out and exiting the context hung in_record_create_duration(). Releasing the lock in the parent did not unblock the child, which needed termination. The same probe without the held lock exited successfully, and the parent's endpoint remained healthy. Holding the SDK collection lock instead caused child scrapes to time out even though the create context completed. The existing fork test only forks while these SDK locks are idle. - Suggestion: Discard inherited SDK/provider/registry/instrument state as well, and initialize fresh metrics state lazily in the child. Avoid shutting down the inherited provider through potentially locked SDK methods, and remove the inherited metric log handler before registering its replacement. Add a bounded fork regression with a metrics lock held by another thread that verifies both child context completion and a successful child scrape.
What Looks Good
- Replacing loop locks handles forks during loop startup, with a direct engine regression test.
- Real fork tests verify child execution and continued parent operation with bounded waits.
- The architecture notes explain why inherited event loops must not be closed.
Residual Risk
The inherited listening-socket issue and stale active-request measurements already have feedback in the existing review and inline thread. They are not duplicated as new findings here. Validation covered the affected modules and targeted probes, rather than the full suite.
Verdict
Needs changes. Reset the retained metrics SDK state and cover a fork while a metrics lock is held.
This review was generated by an AI assistant.
A process that has run DataDesigner once holds a process-wide asyncio loop on a daemon thread (async_concurrency) and, if MCP tools were used, a second one in MCPIOService. After os.fork() only the forking thread survives, yet the copied loop still reports is_running() == True, so ensure_async_engine_loop() and MCPIOService._ensure_loop() hand the child a loop nothing drives and the child's first preview()/create() blocks in future.result(). Register os.register_at_fork(after_in_child=...) hooks that drop the inherited loop, thread and lock (and the MCP sessions bound to that loop). The loop is dropped rather than closed: the child shares the parent's epoll instance and close() would unregister the parent loop's wakeup pipe. Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Record in the architecture docs that the engine and MCP background loops are dropped in a forked child and restarted on first use. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Forking the pytest worker copied everything earlier tests left behind: DuckDB starts about one thread per CPU per connection and reserves large jemalloc mappings, so fork() failed with ENOMEM on a CI runner. Run each scenario through a small fresh parent instead, with a shared probe helper. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Covers the user-facing scenario from #1068 end to end: a sampler-only preview in the parent, then a preview in a fork-started child. Runs in a fresh interpreter like the other fork-safety tests. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
26ca25e to
27296f4
Compare
|
@nabinchha Agreed, and reproduced: holding the create-duration histogram's aggregation lock at fork hangs the child on its first record. I've moved the OpenTelemetry fix out of this PR into #1070. There the child drops all inherited SDK state without calling into it and initializes fresh state on its next run, and a fresh-interpreter regression test holds those locks at fork. This PR is now the engine and MCP loops only. |
|
The socket finding is fixed in #1070: the child closes its copy of the listening socket, and a test checks that the parent can rebind its port while a forked child is still alive. The duplicated harness is gone here: the engine fork tests share The |
|
I have read the DCO document and I hereby sign the DCO. |
nabinchha
left a comment
There was a problem hiding this comment.
Thanks for separating the OpenTelemetry work and strengthening the fork regressions, @binaryaaron.
Summary
Reviewed commit 27296f4554044b2fa1114704401037ca2fb3f612 against base 9a09fce628be7c0af5de91e68a6cd402c3d3df72. PR #1069 now resets only the engine and MCP loops, matching its revised description. The OpenTelemetry change that triggered my earlier request for changes is no longer in this PR; it is tracked separately in #1070.
Findings
No new findings. My previous blocking finding no longer applies to the current diff. Registering _MCP_IO_SERVICE._reset_after_fork is appropriate internal use: the callback is wired up inside the same module that defines and owns the singleton, so the private method does not become a public API dependency.
What Looks Good
- Fresh-interpreter fork probes avoid inheriting unrelated pytest-worker state while preserving bounded failure handling and parent-loop checks.
- The new interface regression exercises the reported failure through actual parent and child
DataDesigner.preview()calls. - The production hooks still replace inherited loop locks and clear MCP session/cache state without closing the parent's shared event-loop selector registrations.
Residual Risk
All 67 tests in the three affected test modules pass locally on CPython 3.13.3, and Ruff lint and formatting checks pass for all six changed Python files. The full suite was not rerun. Inherited MCP stdio descriptors remain a documented limitation of the approach. This follow-up does not review or approve the separate OpenTelemetry implementation in #1070.
Verdict
Ship it. No outstanding findings for the revised scope of PR #1069.
This review was generated by an AI assistant.
📋 Summary
A child forked after the parent has run DataDesigner hangs on its first
preview()orcreate(), because it inherits background event loops whose threads did not survive the fork. This PR addsos.register_at_fork(after_in_child=...)hooks that drop the inherited engine and MCP loops, so the child starts fresh ones. The OpenTelemetry metrics server has the same problem and is fixed separately in #1070.🔗 Related Issue
Fixes #1068
🔄 Changes
async_concurrency.py: the hook clears_loopand_threadand replaces_lock, which also covers a fork while_lockis held during loop startup. (The test holds it from the forking thread; the child's copy is locked either way.)mcp/io.py:MCPIOService._reset_after_fork()does the same for the MCP loop, then clears sessions, in-flight tasks and the tools cache via_reset_state(), since they are bound to the dead loop.hasattr(os, "register_at_fork")(absent on Windows).architecture/mcp.md,architecture/dataset-builders.md: one line each on fork behavior.The hooks drop the inherited loop rather than
close()it: parent and child share one epoll instance, and closing the child's copy unregisters the parent loop's wakeup pipe. In a probe, the parent's nextrun_coroutine_threadsafe(...).result(3)timed out after a child closed its copy.spawnandforkserverchildren are unaffected. No public API changes. A side effect: a forked child that exits throughsys.exitno longer spends up to 10 s in the MCPatexitshutdown waiting on the dead loop.Residual risk: the child does not close inherited MCP stdio pipes, because they belong to transports bound to the dead loop. A long-lived child can delay end-of-file to an MCP server the parent shuts down.
🧪 Testing
make testpasses: one pre-existing failure, see belowFork tests run each scenario in a fresh interpreter (
tests/engine/fork_probe.py) instead of forking the pytest worker. Forking the worker copied whatever earlier tests left behind, including DuckDB thread pools and memory reservations sized from the machine's CPU and memory limits. That is the likely cause of the first CI run'sENOMEMinfork().test_fork_safety.py: the Forked child process hangs on preview()/create() after the parent has run DataDesigner #1068 reproduction end to end,preview()in a child forked after a parentpreview().test_async_concurrency.py: engine loop after parent use, and with_lockheld at fork.test_mcp_io.py: MCP loop after parent use.All four fail on
mainand pass with this change.make test(CPython 3.14.0): one failure,test_create_dataset_e2e_with_file_contents_seed_source_unreadable_file_raises_generation_error, which also fails on unmodifiedmainwhen run as root (chmod(0)does not block root's reads). Ruff check and format pass.✅ Checklist
triaged: N/A🤖 Generated with Claude Code