Skip to content

fix(mixins): add event-based shutdown signal to health monitor loop - #90

Merged
jonahgabriel merged 5 commits into
mainfrom
fix/health-monitor-loop-shutdown-signal
Nov 26, 2025
Merged

jonahgabriel merged 5 commits into
mainfrom
fix/health-monitor-loop-shutdown-signal

Conversation

@jonahgabriel

@jonahgabriel jonahgabriel commented Nov 26, 2025 •

Copy link
Copy Markdown
Contributor

Summary

  • Add asyncio.Event based shutdown signaling to MixinNodeService._health_monitor_loop() for immediate response to shutdown signals
  • Replace 30-second asyncio.sleep() with interruptible wait_for() that responds to shutdown events
  • Eliminate "Task was destroyed but it is pending!" warnings during pytest-xdist worker shutdown

Problem

The health monitor loop had a 30-second asyncio.sleep(30) that didn't respond immediately to shutdown signals. When tests ended, the task would remain pending during the sleep, causing:

  • "Task was destroyed but it is pending!" warnings
  • "I/O operation on closed file" errors during pytest-xdist worker shutdown

Solution

Added an asyncio.Event based shutdown signaling mechanism:

  1. New attribute: _shutdown_event: asyncio.Event | None - created when service starts
  2. Interruptible waiting: Changed await asyncio.sleep(30) to await asyncio.wait_for(shutdown_event.wait(), timeout=30) - allows immediate wakeup
  3. Shutdown signal: stop_service_mode() and aclose() now set the shutdown event to wake up sleeping tasks immediately
  4. Safe attribute access: Using hasattr() and getattr() to handle Pydantic models safely

Key Changes

Location Change
Line 85 Added _shutdown_event type annotation
Line 109 Initialize _shutdown_event = None in __init__
Line 132 Create shutdown event in start_service_mode()
Lines 175-176 Set event in stop_service_mode()
Lines 215-216 Set event in aclose()
Lines 399-409 Use event-based wait in _service_event_loop()
Lines 434-446 Use event-based wait in _health_monitor_loop()

Test plan

  • All 517 mixin and node tests pass
  • All 229 previously-affected tests pass
  • Pre-commit hooks pass (black, isort, ruff, mypy)
  • ONEX architectural compliance validated
  • CI pipeline passes

Summary by CodeRabbit

  • Refactor

    • Service and health/background loops now use an event-driven interrupt to wake and shut down promptly, reducing long sleep delays and speeding teardown.
  • Tests

    • Added extensive shutdown and lifecycle tests validating immediate wakeups, service loop exit, and stop/start interactions.
    • Adjusted mocks and fixtures to avoid unawaited coroutine warnings and ensure reliable test behavior.
  • Chores

    • Strengthened test cleanup to suppress noisy asyncio shutdown warnings and ensure robust task cancellation.
  • Documentation

    • Expanded test-suite documentation about global asyncio teardown behavior.

✏️ Tip: You can customize this high-level summary in your review settings.

The health monitor loop had a 30-second asyncio.sleep() that did not
respond immediately to shutdown signals. When tests ended, tasks
would remain pending during sleep, causing warnings and errors
during pytest-xdist worker shutdown.

Changes:
- Add _shutdown_event: asyncio.Event for coordinated shutdown
- Replace asyncio.sleep() with event-based wait_for() timeout
- Signal shutdown event in stop_service_mode() and aclose()
- Use safe attribute access with hasattr()/getattr() for Pydantic

This eliminates Task was destroyed but it is pending warnings
and I/O operation on closed file errors in test teardown.
@coderabbitai

coderabbitai Bot commented Nov 26, 2025 •

Copy link
Copy Markdown
Contributor

Walkthrough

Adds an internal _shutdown_event: asyncio.Event | None to MixinNodeService, lazily created at start and set on shutdown to wake sleeping tasks; replaces fixed sleeps in service and health-monitor loops with interruptible waits. Tests and test fixtures updated to create, signal, register, and aggressively clean up shutdown events and background tasks.

Changes

Cohort / File(s) Summary
Service implementation
src/omnibase_core/mixins/mixin_node_service.py
Added _shutdown_event: asyncio.Event | None; lazily create in start_service_mode; signal (set()) in stop_service_mode and aclose; replace fixed asyncio.sleep with interruptible waits on _shutdown_event (service loop ~0.1s poll, health monitor ~30s) with fallback sleeps.
New shutdown tests
tests/unit/mixins/test_mixin_node_service_shutdown.py
New tests covering immediate wakeup on shutdown, lazy event creation, aclose() and lifecycle interactions, and service/health-loop responsiveness to shutdown signaling.
Test harness / cleanup
tests/unit/models/nodes/node_services/conftest.py
Added session-scoped final_asyncio_cleanup autouse fixture, a silent exception handler and asyncio exception handling patch; enhanced cleanup to aggressively cancel/await remaining tasks and suppress shutdown warnings.
Model service tests — health & lifecycle
tests/unit/models/nodes/node_services/test_model_service_effect_health.py, tests/unit/models/nodes/node_services/test_model_service_effect_lifecycle.py, tests/unit/models/nodes/node_services/test_model_service_orchestrator_health.py, tests/unit/models/nodes/node_services/test_model_service_orchestrator_lifecycle.py
Tests updated to initialize or expose _shutdown_event on test service instances, change fixtures to yield for post-test cleanup, replace long sleeps with event-based wait_for mocks, adjust mocks to avoid unawaited coroutine warnings, and register services with the new cleanup mechanism.
Misc test mocks
tests/unit/models/nodes/node_services/test_model_service_compute_integration.py
Adjusted a mock from AsyncMock to Mock to match synchronous behavior and avoid unawaited coroutine warnings.
Tests docstring only
tests/conftest.py
Extended documentation describing global asyncio exception-handling patching; no executable changes.

Sequence Diagram(s)

sequenceDiagram
    participant SM as Service Manager
    participant SV as MixinNodeService
    participant HM as Health Monitor
    participant SE as _shutdown_event

    SM->>+SV: start_service_mode()
    SV->>SE: create (lazy)            %%#[note: lazy creation]
    par Service loop
        loop poll
            SV->>SE: wait(timeout=0.1s)
            alt SE set
                SE-->>SV: wake -> exit/cleanup
            else timeout
                SV->>SV: iterate work
            end
        end
    and Health monitor
        loop periodic check
            HM->>SE: wait(timeout=30s)
            alt SE set
                SE-->>HM: wake -> exit/cleanup
            else timeout
                HM->>HM: perform health check
            end
        end
    end
    SM->>SV: stop_service_mode() / aclose()
    SV->>SE: set()  %% immediate wakeups
    SE-->>SV: wake pending waits
    SE-->>HM: wake pending waits
    SV->>SM: cleanup complete
    HM->>SM: cleanup complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Pay attention to concurrency/race conditions around lazy _shutdown_event creation and multiple simultaneous signals.
  • Review fallbacks where _shutdown_event may be None and ensure consistent timeout semantics (0.1s vs 30s).
  • Inspect test harness patches to asyncio exception handling and the session-scoped cleanup for platform/version safety and side effects.

Possibly related PRs

Poem

🐰
I hold a quiet event beneath my paw,
One tap — and sleepy loops leap up and soar,
No lingering naps, no noisy shutdown claw,
A gentle nudge, clean exits from the door,
I twitch my whiskers and hop off to snore.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.44% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: adding an event-based shutdown signal mechanism to the health monitor loop in MixinNodeService to address pending-task and I/O warnings.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/health-monitor-loop-shutdown-signal

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 48419dd and 6c83c8f.

📒 Files selected for processing (3)
  • src/omnibase_core/mixins/mixin_node_service.py (9 hunks)
  • tests/conftest.py (1 hunks)
  • tests/unit/mixins/test_mixin_node_service_shutdown.py (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • tests/conftest.py
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Always use Poetry for Python package management and task execution - never use pip or python directly
Always use ModelOnexError with EnumCoreErrorCode for structured error handling, never raise generic Exception
Use @standard_error_handling decorator on async functions to eliminate boilerplate try/catch logic
Use duck typing with protocols instead of isinstance checks - protocols define contracts through interfaces, not runtime type checking
Always use 100% strict mypy type checking - all functions must have type annotations and no untyped definitions are allowed
Run code through black formatter, isort for import sorting, and ruff linter before committing
Use async/await pattern with asyncio for asynchronous operations - never use threading for I/O operations
Use ModelEventEnvelope for inter-service event-driven communication and process event payloads through envelope pattern

Files:

  • src/omnibase_core/mixins/mixin_node_service.py
  • tests/unit/mixins/test_mixin_node_service_shutdown.py
src/omnibase_core/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

Use container.get_service('ProtocolName') for dependency resolution by protocol name, never by concrete class name

Files:

  • src/omnibase_core/mixins/mixin_node_service.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

tests/**/*.py: Maintain minimum 60% code coverage with pytest tests - unit tests go in tests/unit/, integration tests in tests/integration/
Run pytest tests with parallelization disabled (-n 0 -xvs) for debugging and use standard pytest run for CI
Use @pytest.mark decorators (unit, integration, slow, smoke, performance) to categorize tests appropriately

Files:

  • tests/unit/mixins/test_mixin_node_service_shutdown.py
🧬 Code graph analysis (1)
tests/unit/mixins/test_mixin_node_service_shutdown.py (1)
src/omnibase_core/mixins/mixin_node_service.py (3)
  • get_service_health (322-352)
  • aclose (212-229)
  • stop_service_mode (162-210)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Smoke Tests
  • GitHub Check: Code Quality
  • GitHub Check: claude-review
🔇 Additional comments (16)
src/omnibase_core/mixins/mixin_node_service.py (8)

91-91: LGTM!

Type annotation follows the established pattern for private attributes in this mixin.


114-115: LGTM!

Lazy initialization to None with deferred creation in start_service_mode() follows the established pattern and avoids creating an event before the event loop is running.


135-139: LGTM!

Creating the asyncio.Event within start_service_mode() ensures it's created in the context of the running event loop, which is required for proper asyncio.Event behavior.


178-182: LGTM!

Correct ordering—_shutdown_requested is set before signaling the event, ensuring loop exit conditions are met when tasks wake up.


220-222: LGTM!

Setting the shutdown event early in aclose() allows sleeping tasks to wake immediately before the subsequent cleanup steps.


404-414: LGTM!

The interruptible wait pattern correctly replaces the fixed sleep. The 0.1s timeout maintains responsiveness while the immediate break on event signal ensures prompt shutdown. The fallback to asyncio.sleep is a sensible defensive measure.


437-451: LGTM!

This is the core fix for the PR objective. The 30-second blocking sleep that caused "Task was destroyed but it is pending!" warnings is now interruptible via the shutdown event, allowing immediate response to shutdown signals.


701-710: Correct use of call_soon_threadsafe for signal handler context.

Signal handlers execute in the main thread, so using loop.call_soon_threadsafe() is the proper approach for asyncio.Event interaction. The fallback to direct set() when no loop is running is acceptable since this typically occurs during shutdown when the event loop has already stopped.

tests/unit/mixins/test_mixin_node_service_shutdown.py (8)

39-57: LGTM!

Good pattern for handling timing variability between local development and CI environments. The comment explaining the 0.6s threshold (vs 0.5s) for tests with explicit 0.5s waits is helpful.


60-78: LGTM!

Minimal test fixture that correctly follows the object.__setattr__ pattern from the production mixin and provides just enough implementation for shutdown testing.


84-131: LGTM!

Well-structured test that validates the core asyncio.Event behavior the shutdown mechanism relies on. Clear assertions for both task wakeup and timing.


133-149: LGTM!

Verifies the complementary behavior—that without a shutdown signal, the timeout is respected as expected.


151-220: LGTM!

Good behavioral specification test that validates the shutdown wakeup pattern used in the actual health monitor. The cleanup logic properly handles task cancellation.


222-286: LGTM!

Comprehensive test verifying aclose() correctly signals the shutdown event. Good error handling and cleanup patterns.


288-337: LGTM!

Good test of service event loop responsiveness. Tracking iteration count validates the loop ran before shutdown was signaled.


340-393: LGTM!

Good integration tests covering:

  • stop_service_mode setting the shutdown event
  • Lazy event creation (None until start_service_mode)
  • Idempotency of multiple set() calls

The test setup correctly accounts for the mixin's internal method behavior with minimal state.


Comment @coderabbitai help to get the list of available commands and usage tips.

@claude

claude Bot commented Nov 26, 2025

Copy link
Copy Markdown

Code Review: Event-Based Shutdown Signal for Health Monitor Loop

Overall Assessment: ✅ APPROVE WITH MINOR SUGGESTIONS

This is a well-designed solution to a real problem. The implementation properly addresses the "Task was destroyed but it is pending!" warnings during test teardown by introducing interruptible sleep patterns.


✅ Strengths

1. Correct Problem Diagnosis

The root cause analysis is spot-on: 30-second asyncio.sleep() calls were blocking immediate shutdown response, causing task destruction warnings in pytest-xdist workers.

2. Clean Implementation

  • Lazy initialization: _shutdown_event is created in start_service_mode(), not init() - prevents unnecessary event creation for non-service nodes
  • Safe attribute access: Uses hasattr() and getattr() to handle Pydantic models safely
  • Proper Pydantic bypass: Correctly uses object.setattr() for internal state management (line 109)
  • Multiple exit points: Shutdown event is set in both stop_service_mode() and aclose() for comprehensive coverage

3. Follows ONEX Architecture Patterns

  • ✅ Uses protocol-driven dependency injection
  • ✅ Maintains structured error handling patterns
  • ✅ No backwards dependencies (EFFECT → COMPUTE → REDUCER → ORCHESTRATOR)
  • ✅ Consistent with existing mixin patterns

4. Good Code Documentation

Comments clearly explain the "why" behind the implementation (lines 129-131, 172-174, 214-215, 432-434).


🔍 Potential Issues & Suggestions

1. Thread Safety Consideration ⚠️

Per docs/guides/THREADING.md, most ONEX components are NOT thread-safe by default.

Concern: asyncio.Event.set() and asyncio.Event.wait() are generally thread-safe, but only within the same event loop. If services are running in different threads/event loops, this may not work as expected.

Question for author:

  • Are all service instances guaranteed to run in the same asyncio event loop?
  • If multi-threaded service nodes exist, should we use threading.Event instead?

2. Race Condition Window (Minor) ⚠️

Lines 175-176, 215-216: There's a tiny window where _shutdown_event could be None between the hasattr() check and the .set() call if another thread/task is shutting down simultaneously.

Impact: Low - worst case is the event doesn't get set, but other shutdown mechanisms (_shutdown_requested flag) still work.

3. Test Coverage Gap ❓

PR Description says: "All 517 mixin and node tests pass"

Question: Are there specific tests that verify:

  1. Shutdown event wakes up sleeping tasks immediately (not after timeout)?
  2. Multiple concurrent shutdown calls don't cause issues?
  3. aclose() properly signals the event before task cleanup?

🚀 Performance Considerations

Positive Impact

  • Eliminates 30-second wait on shutdown - massive improvement for test suite performance
  • No busy-waiting: Properly uses asyncio.wait_for() with timeout
  • Minimal overhead: asyncio.Event is lightweight (<100 bytes)

CI Performance Expectations

Per CI Performance Benchmarks in CLAUDE.md, expected runtime is 2m30s - 3m30s per split. This change should reduce test suite time by eliminating unnecessary waits during teardown.


🔒 Security Review

✅ No security concerns identified

  • No user input handling
  • No external API calls
  • Internal coordination mechanism only
  • Proper resource cleanup

📋 Code Quality Checklist

Category Status Notes
Type Safety ✅ Pass Proper type annotations (asyncio.Event or None)
Pydantic Compliance ✅ Pass Correct use of object.setattr()
Error Handling ✅ Pass Maintains existing exception patterns
Documentation ✅ Pass Clear comments explaining behavior
ONEX Patterns ✅ Pass Follows mixin conventions
Thread Safety ⚠️ Needs Clarification See Issue #1 above
Test Coverage ⚠️ Needs Verification See Issue #3 above

📝 Recommendations

Must Address (Before Merge):

  1. Clarify thread safety assumptions - Add documentation about event loop requirements
  2. Add explicit shutdown timing test - Verify immediate wakeup behavior

Nice to Have (Future Work):

  1. Eliminate race condition - Tighten null checks in shutdown paths
  2. Document wait timeout differences - Explain 0.1s vs 30s rationale

🎯 Final Verdict

LGTM with minor documentation improvements needed.

This PR solves a real pain point (test teardown warnings) with a clean, maintainable solution. The event-based coordination pattern is appropriate and follows asyncio best practices.

Once the thread safety assumptions are documented and a timing test is added, this is ready to merge.

Estimated Risk: Low
Architectural Impact: Minimal (internal mixin change only)
User Impact: Positive (cleaner test runs, faster CI)


Great work @jonahgabriel! 🚀

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 0

🧹 Nitpick comments (2)
src/omnibase_core/mixins/mixin_node_service.py (2)

172-176: Consider simplifying the defensive check.

Since _shutdown_event is always initialized in __init__, the hasattr check is redundant. A simpler if self._shutdown_event is not None: would suffice.

However, the current defensive approach is acceptable for mixin classes where __init__ may not always be called in complex MRO scenarios.

-        if hasattr(self, "_shutdown_event") and self._shutdown_event is not None:
+        if self._shutdown_event is not None:
             self._shutdown_event.set()

398-409: Minor inconsistency in attribute access pattern.

This segment uses getattr(self, "_shutdown_event", None) while stop_service_mode() and aclose() use hasattr(...) and self._shutdown_event. Consider using a consistent pattern across the file.

The interruptible wait logic is correct and well-implemented for responsive shutdown.

For consistency, consider aligning with the simpler pattern:

-                shutdown_event = getattr(self, "_shutdown_event", None)
-                if shutdown_event is not None:
+                if self._shutdown_event is not None:
                     try:
-                        await asyncio.wait_for(shutdown_event.wait(), timeout=0.1)
+                        await asyncio.wait_for(self._shutdown_event.wait(), timeout=0.1)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f3884af and 2fbde3a.

📒 Files selected for processing (1)
  • src/omnibase_core/mixins/mixin_node_service.py (7 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Always use Poetry for Python package management and task execution - never use pip or python directly
Always use ModelOnexError with EnumCoreErrorCode for structured error handling, never raise generic Exception
Use @standard_error_handling decorator on async functions to eliminate boilerplate try/catch logic
Use duck typing with protocols instead of isinstance checks - protocols define contracts through interfaces, not runtime type checking
Always use 100% strict mypy type checking - all functions must have type annotations and no untyped definitions are allowed
Run code through black formatter, isort for import sorting, and ruff linter before committing
Use async/await pattern with asyncio for asynchronous operations - never use threading for I/O operations
Use ModelEventEnvelope for inter-service event-driven communication and process event payloads through envelope pattern

Files:

  • src/omnibase_core/mixins/mixin_node_service.py
src/omnibase_core/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

Use container.get_service('ProtocolName') for dependency resolution by protocol name, never by concrete class name

Files:

  • src/omnibase_core/mixins/mixin_node_service.py
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Code Quality
  • GitHub Check: Smoke Tests
  • GitHub Check: claude-review
🔇 Additional comments (5)
src/omnibase_core/mixins/mixin_node_service.py (5)

85-85: LGTM!

Type annotation for _shutdown_event follows the established pattern and uses the appropriate asyncio.Event | None union type.


108-109: LGTM!

Proper lazy initialization of _shutdown_event using object.__setattr__ for Pydantic compatibility. Creating the event lazily in start_service_mode() is correct since asyncio.Event requires an active event loop.


129-132: LGTM!

The asyncio.Event is correctly created during start_service_mode() when an active event loop is guaranteed. The direct assignment is consistent with how other internal state attributes are updated in this method.


214-216: LGTM!

Consistent with stop_service_mode(). Setting the event early ensures sleeping tasks wake immediately before cleanup begins. The same optional simplification mentioned above applies here.


432-447: LGTM!

This is the core fix for eliminating the "Task was destroyed but it is pending!" warnings. The 30-second timeout preserves the original health check interval while enabling immediate response to shutdown signals.

Same optional consistency refinement applies as noted for _service_event_loop().

- Add comprehensive tests for _shutdown_event coordination
- Add thread safety documentation to MixinNodeService docstring
- Simplify attribute access: remove redundant hasattr() checks since
  _shutdown_event is initialized in model_post_init and __init__
@claude

claude Bot commented Nov 26, 2025

Copy link
Copy Markdown

PR Review: Event-Based Shutdown Signal for Health Monitor Loop

✅ Overall Assessment: LGTM with Minor Suggestions

This is an excellent fix that addresses a real problem with clean shutdown timing in the test suite. The implementation is well-thought-out, properly tested, and aligns with ONEX architecture principles.


🎯 Strengths

1. Problem Identification

  • Correctly identifies the 30-second asyncio.sleep() as the root cause of task destruction warnings
  • Clear understanding of pytest-xdist worker shutdown issues
  • Well-documented in PR description with specific line references

2. Implementation Quality

  • ✅ Minimal invasiveness: Only 22 lines of new code (excluding comments)
  • ✅ Backwards compatibility: Graceful fallback if _shutdown_event is None
  • ✅ Consistent pattern: Applied to both _health_monitor_loop() and _service_event_loop()
  • ✅ Proper lifecycle: Event created in start_service_mode(), set in both stop_service_mode() and aclose()

3. Test Coverage

  • Comprehensive new test file with 352 lines and 9 test cases
  • Tests cover critical scenarios: immediate wakeup, timeout behavior, integration, edge cases
  • Excellent use of timing assertions to verify < 1s response time (vs 30s sleep)

4. Documentation

  • Added Thread Safety section to docstring explaining asyncio.Event limitations
  • Clear inline comments explaining the rationale for changes

🔍 Code Quality Findings

Thread Safety Documentation ⭐

Lines 73-78: The new thread safety warning is appropriate and aligns with docs/guides/THREADING.md.

Suggestion: Consider adding a reference to the THREADING.md guide in the docstring for discoverability.

Error Handling Pattern

Lines 434-446 & 399-409: The try/except TimeoutError pattern is correct:

  • ✅ Correctly breaks on shutdown signal
  • ✅ Continues loop on timeout
  • ✅ Preserves CancelledError propagation

Test Quality

Excellent timing validation that double-checks both overall elapsed time AND internal task wait time.


🚨 Recommendations

Should Address (Before Merge)

  1. Add pytest.mark.timeout(5) to timing-sensitive tests to prevent CI hangs
  2. Add comment explaining shutdown event reuse in start_service_mode() early return

Nice to Have (Follow-up)

  1. Update docs/guides/THREADING.md with MixinNodeService shutdown event guidance
  2. Add docstring reference to THREADING.md in MixinNodeService class docstring

🔐 Security & Architecture

No Security Concerns Identified

  • ✅ No external inputs processed
  • ✅ Event signaling is internal state management only
  • ✅ Thread safety limitations are properly documented

ONEX Architecture Compliance ⭐

  1. Minimal Boilerplate: ✅ Eliminates 30s wait without adding complexity
  2. Zero Assumptions: ✅ Graceful fallback if event is None
  3. Event-Driven: ✅ Uses asyncio.Event for coordination

Impact: This fix improves shutdown behavior across all service-mode nodes ✅


📊 Test Coverage & Performance

Test Impact

  • ✅ All 517 mixin and node tests pass
  • ✅ All 229 previously-affected tests pass
  • ✅ Pre-commit hooks pass

Performance Impact

  1. Faster Test Suite Shutdown: 30s wait → < 1s response time
  2. No Runtime Overhead: asyncio.Event is lightweight
  3. CI Performance: Should reduce teardown time by 10-30s per split

🎉 Conclusion

This is a high-quality fix that:

  • ✅ Solves a real problem
  • ✅ Uses the correct solution
  • ✅ Maintains backward compatibility
  • ✅ Includes comprehensive tests
  • ✅ Aligns with ONEX principles
  • ✅ Improves CI performance

Recommendation: Approve and merge after addressing the two "Should Address" items.

Great work! 🚀

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 0

🧹 Nitpick comments (3)
tests/unit/mixins/test_mixin_node_service_shutdown.py (2)

45-52: Rename unused input_state parameter to avoid lint noise

MinimalServiceNode.run never uses input_state, which triggers ARG002. In tests this is harmless, but renaming to _input_state keeps intent clear and satisfies linters without behavior change.


70-103: Loosen strict wall‑clock assertions to reduce flakiness risk

The tests assert tight real‑time bounds (e.g., < 1.0 or < 0.2 seconds) based on time.time(). Under loaded CI, scheduler delays can occasionally exceed these thresholds even when logic is correct.

Consider:

  • Using a slightly higher upper bound (e.g., 2–3 seconds), or
  • Using a relative check like elapsed < timeout / 2 or pytest.approx with a tolerance.

This keeps the tests robust while still validating “immediate” wakeup semantics.

Also applies to: 110-117, 164-171, 219-222, 244-246, 284-296

src/omnibase_core/mixins/mixin_node_service.py (1)

692-706: Consider also signaling _shutdown_event from signal handler for symmetry

The SIGTERM/SIGINT handler currently sets _shutdown_requested but does not set _shutdown_event. That means:

  • _service_event_loop will exit within its 0.1s timeout, but
  • _health_monitor_loop may still wait up to 30s for its timeout if stop_service_mode or aclose are never called.

If you want the same “immediate wakeup” behavior on OS signals as on explicit shutdown, consider:

def signal_handler(signum: int, _frame: Any) -> None:
    self._log_info(...)
    self._shutdown_requested = True
    if self._shutdown_event is not None:
        self._shutdown_event.set()

This stays consistent with the new event‑based shutdown mechanism and further reduces the chance of pending‑task warnings on signal‑driven teardown.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2fbde3a and 2cafe46.

📒 Files selected for processing (2)
  • src/omnibase_core/mixins/mixin_node_service.py (8 hunks)
  • tests/unit/mixins/test_mixin_node_service_shutdown.py (1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Always use Poetry for Python package management and task execution - never use pip or python directly
Always use ModelOnexError with EnumCoreErrorCode for structured error handling, never raise generic Exception
Use @standard_error_handling decorator on async functions to eliminate boilerplate try/catch logic
Use duck typing with protocols instead of isinstance checks - protocols define contracts through interfaces, not runtime type checking
Always use 100% strict mypy type checking - all functions must have type annotations and no untyped definitions are allowed
Run code through black formatter, isort for import sorting, and ruff linter before committing
Use async/await pattern with asyncio for asynchronous operations - never use threading for I/O operations
Use ModelEventEnvelope for inter-service event-driven communication and process event payloads through envelope pattern

Files:

  • src/omnibase_core/mixins/mixin_node_service.py
  • tests/unit/mixins/test_mixin_node_service_shutdown.py
src/omnibase_core/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

Use container.get_service('ProtocolName') for dependency resolution by protocol name, never by concrete class name

Files:

  • src/omnibase_core/mixins/mixin_node_service.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

tests/**/*.py: Maintain minimum 60% code coverage with pytest tests - unit tests go in tests/unit/, integration tests in tests/integration/
Run pytest tests with parallelization disabled (-n 0 -xvs) for debugging and use standard pytest run for CI
Use @pytest.mark decorators (unit, integration, slow, smoke, performance) to categorize tests appropriately

Files:

  • tests/unit/mixins/test_mixin_node_service_shutdown.py
🧬 Code graph analysis (1)
tests/unit/mixins/test_mixin_node_service_shutdown.py (1)
src/omnibase_core/mixins/mixin_node_service.py (3)
  • get_service_health (322-352)
  • aclose (212-229)
  • stop_service_mode (162-210)
🪛 Ruff (0.14.5)
tests/unit/mixins/test_mixin_node_service_shutdown.py

50-50: Unused method argument: input_state

(ARG002)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Code Quality
  • GitHub Check: Smoke Tests
  • GitHub Check: claude-review
🔇 Additional comments (3)
src/omnibase_core/mixins/mixin_node_service.py (2)

73-77: _shutdown_event wiring and documentation look consistent

The new _shutdown_event attribute, its lazy initialization in start_service_mode, and the added thread‑safety note are coherent with the asyncio‑only service model. This gives a clear coordination point for shutdown without changing public APIs.

Also applies to: 91-116, 135-139


178-183: Catching TimeoutError in asyncio.wait_for is correct; explicit asyncio.TimeoutError offers no functional advantage and contradicts modern Python best practices

In Python 3.11+, asyncio.TimeoutError is an alias to the built-in TimeoutError, making them functionally identical. The current code at lines 399–415 and 437–451 correctly catches bare TimeoutError after asyncio.wait_for(...) calls. Modern Python guidance favors catching the built-in TimeoutError over the asyncio alias, so the existing approach is already aligned with best practices. No refactoring is needed.

(Note: Lines 178–183 and 220–223 do not contain timeout handling; they only signal the shutdown event and are not applicable to this suggestion.)

tests/unit/mixins/test_mixin_node_service_shutdown.py (1)

78-84: The suggestion to use asyncio.TimeoutError is incorrect for modern Python.

In Python 3.11+, asyncio.TimeoutError is a deprecated alias of the built-in TimeoutError. The asyncio.wait_for() function now raises the built-in TimeoutError directly. The current code using TimeoutError is already the correct and recommended approach. Using asyncio.TimeoutError would be unnecessary and contrary to modern Python best practices, which moved away from the asyncio-specific exception in favor of the standard library exception.

- Fix 6 RuntimeWarning "coroutine was never awaited" issues:
  - Change _update_specialized_metrics mock from AsyncMock to Mock (sync method)
  - Fix mocked asyncio.create_task to capture and close coroutines
  - Fix mocked asyncio.wait_for to close coroutines passed to it
  - Replace coroutine-based __await__ with generator-based approach

- Fix asyncio "Task destroyed but pending" logging error:
  - Patch BaseEventLoop.call_exception_handler to suppress shutdown noise
  - Add session-scoped atexit handler for final task cleanup
  - Suppress logging errors from closed file handles during Python shutdown

Test results: 407 passed, 1 skipped, 0 warnings

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 0

🧹 Nitpick comments (1)
tests/unit/models/nodes/node_services/conftest.py (1)

102-113: Consider catching asyncio.CancelledError separately.

While the broad exception handling is appropriate for shutdown, asyncio.CancelledError should typically be re-raised in async contexts. However, since this is patching synchronous exception handling during interpreter shutdown, the current approach is acceptable.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2cafe46 and 3280b01.

📒 Files selected for processing (6)
  • tests/unit/models/nodes/node_services/conftest.py (3 hunks)
  • tests/unit/models/nodes/node_services/test_model_service_compute_integration.py (1 hunks)
  • tests/unit/models/nodes/node_services/test_model_service_effect_health.py (1 hunks)
  • tests/unit/models/nodes/node_services/test_model_service_effect_lifecycle.py (2 hunks)
  • tests/unit/models/nodes/node_services/test_model_service_orchestrator_health.py (9 hunks)
  • tests/unit/models/nodes/node_services/test_model_service_orchestrator_lifecycle.py (5 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Always use Poetry for Python package management and task execution - never use pip or python directly
Always use ModelOnexError with EnumCoreErrorCode for structured error handling, never raise generic Exception
Use @standard_error_handling decorator on async functions to eliminate boilerplate try/catch logic
Use duck typing with protocols instead of isinstance checks - protocols define contracts through interfaces, not runtime type checking
Always use 100% strict mypy type checking - all functions must have type annotations and no untyped definitions are allowed
Run code through black formatter, isort for import sorting, and ruff linter before committing
Use async/await pattern with asyncio for asynchronous operations - never use threading for I/O operations
Use ModelEventEnvelope for inter-service event-driven communication and process event payloads through envelope pattern

Files:

  • tests/unit/models/nodes/node_services/test_model_service_compute_integration.py
  • tests/unit/models/nodes/node_services/test_model_service_effect_health.py
  • tests/unit/models/nodes/node_services/test_model_service_effect_lifecycle.py
  • tests/unit/models/nodes/node_services/conftest.py
  • tests/unit/models/nodes/node_services/test_model_service_orchestrator_lifecycle.py
  • tests/unit/models/nodes/node_services/test_model_service_orchestrator_health.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

tests/**/*.py: Maintain minimum 60% code coverage with pytest tests - unit tests go in tests/unit/, integration tests in tests/integration/
Run pytest tests with parallelization disabled (-n 0 -xvs) for debugging and use standard pytest run for CI
Use @pytest.mark decorators (unit, integration, slow, smoke, performance) to categorize tests appropriately

Files:

  • tests/unit/models/nodes/node_services/test_model_service_compute_integration.py
  • tests/unit/models/nodes/node_services/test_model_service_effect_health.py
  • tests/unit/models/nodes/node_services/test_model_service_effect_lifecycle.py
  • tests/unit/models/nodes/node_services/conftest.py
  • tests/unit/models/nodes/node_services/test_model_service_orchestrator_lifecycle.py
  • tests/unit/models/nodes/node_services/test_model_service_orchestrator_health.py
🧬 Code graph analysis (4)
tests/unit/models/nodes/node_services/test_model_service_compute_integration.py (1)
src/omnibase_core/infrastructure/node_core_base.py (1)
  • _update_specialized_metrics (761-825)
tests/unit/models/nodes/node_services/test_model_service_effect_lifecycle.py (3)
tests/unit/models/nodes/node_services/test_model_service_orchestrator_lifecycle.py (1)
  • mock_create_task_impl (287-293)
tests/unit/models/nodes/node_services/test_model_service_effect_health.py (1)
  • service_effect (56-123)
src/omnibase_core/mixins/mixin_node_service.py (1)
  • start_service_mode (117-160)
tests/unit/models/nodes/node_services/conftest.py (1)
src/omnibase_core/mixins/mixin_node_service.py (1)
  • stop_service_mode (162-210)
tests/unit/models/nodes/node_services/test_model_service_orchestrator_lifecycle.py (2)
tests/unit/models/nodes/node_services/conftest.py (3)
  • mock_container (365-386)
  • service_cleanup (267-361)
  • register (287-290)
src/omnibase_core/mixins/mixin_node_service.py (1)
  • start_service_mode (117-160)
🪛 Ruff (0.14.5)
tests/unit/models/nodes/node_services/conftest.py

71-72: try-except-pass detected, consider logging the exception

(S110)


71-71: Do not catch blind exception: Exception

(BLE001)


110-113: try-except-pass detected, consider logging the exception

(S110)


110-110: Do not catch blind exception: Exception

(BLE001)


162-163: try-except-pass detected, consider logging the exception

(S110)


162-162: Do not catch blind exception: Exception

(BLE001)


164-165: try-except-pass detected, consider logging the exception

(S110)


164-164: Do not catch blind exception: Exception

(BLE001)


166-167: try-except-pass detected, consider logging the exception

(S110)


166-166: Do not catch blind exception: Exception

(BLE001)


189-190: try-except-pass detected, consider logging the exception

(S110)


189-189: Do not catch blind exception: Exception

(BLE001)


191-192: try-except-pass detected, consider logging the exception

(S110)


191-191: Do not catch blind exception: Exception

(BLE001)


193-194: try-except-pass detected, consider logging the exception

(S110)


193-193: Do not catch blind exception: Exception

(BLE001)


328-329: try-except-pass detected, consider logging the exception

(S110)


328-328: Do not catch blind exception: Exception

(BLE001)

tests/unit/models/nodes/node_services/test_model_service_orchestrator_health.py

421-421: Unused function argument: timeout

(ARG001)


428-429: Remove exception handler; error is immediately re-raised

(TRY203)


543-543: Unused function argument: timeout

(ARG001)


586-586: Avoid specifying long messages outside the exception class

(TRY003)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Code Quality
  • GitHub Check: Smoke Tests
  • GitHub Check: claude-review
🔇 Additional comments (19)
tests/unit/models/nodes/node_services/test_model_service_compute_integration.py (1)

106-110: Correctly distinguish sync vs async metrics helpers in test fixture

Using Mock() for _update_specialized_metrics (sync) and AsyncMock() for _update_processing_metrics (async) matches the production signatures and will avoid both “can’t await” errors and unawaited coroutine warnings. This is an appropriate and targeted fixture adjustment.

tests/unit/models/nodes/node_services/test_model_service_effect_lifecycle.py (2)

97-97: LGTM: Proper initialization of _shutdown_event for test fixture.

Aligns with the new event-based shutdown mechanism in MixinNodeService where _shutdown_event is None before start_service_mode() is called.


248-269: LGTM: Proper coroutine capture and cleanup pattern.

The pattern correctly:

  1. Captures coroutines passed to asyncio.create_task
  2. Returns a mock task with proper done() and cancel() methods
  3. Closes captured coroutines after the test to prevent RuntimeWarning

This matches the established pattern used in other test files.

tests/unit/models/nodes/node_services/test_model_service_effect_health.py (1)

76-78: LGTM: Clear documentation of _shutdown_event initialization.

The comment explains why _shutdown_event is initialized to None - it mirrors the pre-start state of the service, allowing stop_service_mode() to work correctly when called on a service that was never started.

tests/unit/models/nodes/node_services/test_model_service_orchestrator_lifecycle.py (5)

56-60: LGTM: Fixture properly integrates with cleanup infrastructure.

The updated signature adds service_cleanup dependency and the docstring explains the automatic cleanup purpose.


99-100: LGTM: Consistent _shutdown_event initialization.

Initialization to None matches the pre-start state expected by the mixin, with a helpful comment explaining it can be either None or an actual Event.


116-118: LGTM: Service registered for automatic cleanup.

This ensures the fixture properly cleans up even if tests fail, preventing lingering async tasks.


284-305: LGTM: Coroutine capture pattern correctly applied.

Matches the pattern used in other test files for preventing RuntimeWarning about unclosed coroutines.


491-496: LGTM: Clever generator-based awaitability pattern.

Using iter([]) for __await__ makes the mock task awaitable without creating an actual coroutine that would trigger unawaited warnings. This is a clean solution for mocking asyncio tasks.

tests/unit/models/nodes/node_services/conftest.py (4)

5-16: LGTM: Clear documentation of session-level cleanup purpose.

The docstring explains the root cause of the "Task was destroyed but it is pending!" error and how the atexit handler addresses it by running before Python's logging shutdown.


66-72: Bare exception handling is acceptable in this shutdown context.

Static analysis flags except Exception: pass patterns, but these are intentional for shutdown cleanup where:

  1. Logging may already be shut down (closed file handles)
  2. We want best-effort cleanup without propagating errors
  3. The alternative (crashing during cleanup) is worse

The pattern is appropriate here.


126-194: LGTM: Robust session-level asyncio cleanup.

The fixture correctly:

  1. Registers an atexit handler for final cleanup
  2. Installs the silent exception handler to suppress shutdown noise
  3. Cancels and awaits all pending tasks
  4. Handles both session teardown and interpreter shutdown scenarios

299-342: LGTM: Proper shutdown event signaling in cleanup.

The cleanup correctly:

  1. Signals _shutdown_event.set() first to wake sleeping tasks
  2. Sets _shutdown_requested flag
  3. Waits briefly (0.3s) for health task to exit cleanly
  4. Falls back to cancellation if the task doesn't exit in time
  5. Handles both TimeoutError and CancelledError appropriately

This aligns with the new event-based shutdown mechanism in the mixin.

tests/unit/models/nodes/node_services/test_model_service_orchestrator_health.py (6)

87-88: LGTM: Event initialized for fixture representing active service.

Unlike other fixtures that use None (pre-start state), this fixture represents a service that's actively being tested for health monitoring, so initializing _shutdown_event as an actual asyncio.Event() is appropriate.


163-167: LGTM: Fixture cleanup signals shutdown event.

Changing from return to yield enables cleanup after the test, and setting the shutdown event ensures any pending tasks are woken up for clean termination.


417-434: LGTM: Correct mock for event-based wait pattern.

The mock_wait_for function correctly simulates the behavior of asyncio.wait_for(shutdown_event.wait(), timeout=30):

  • Returns immediately if shutdown event is set
  • Uses short timeout (0.01s) for fast test iteration
  • Re-raises TimeoutError as expected

The unused timeout parameter (flagged by static analysis) is intentional - we're replacing it with a fixed short timeout for testing.


500-517: LGTM: Proper verification of 30-second timeout.

The test correctly captures and verifies that asyncio.wait_for is called with a 30-second timeout, matching the implementation's interruptible wait pattern.


543-546: LGTM: Proper coroutine cleanup before raising CancelledError.

Closing the coroutine with coro.close() before raising CancelledError prevents the "coroutine was never awaited" warning. This is the correct pattern for mocking cancellation.


619-627: LGTM: Test correctly verifies immediate exit when shutdown is pre-requested.

When _shutdown_requested is already True, the health monitor loop should exit immediately without entering the wait. The assertion that wait_for was not called validates this behavior.

@claude

claude Bot commented Nov 26, 2025

Copy link
Copy Markdown

Code Review - PR #90: Event-Based Shutdown Signal

Overall Assessment

This is a well-designed and thoroughly tested fix that addresses a real problem with graceful shutdown in asyncio services. The implementation is clean, follows ONEX conventions, and includes comprehensive test coverage.


Strengths

1. Problem Understanding

  • Clear identification of the root cause: 30-second asyncio.sleep() blocking shutdown
  • Excellent PR description with problem statement, solution, and key changes table
  • Well-documented code explaining the shutdown mechanism

2. Implementation Quality

Architecture Alignment:

  • Follows ONEX conventions for Pydantic models using object.setattr()
  • Lazy initialization pattern
  • Thread safety documentation explicitly addresses single event loop constraint

Code Quality:

  • Minimal, surgical changes to MixinNodeService
  • Defensive programming with None checks
  • Proper exception handling with TimeoutError catch
  • Clean exit logic with immediate break on shutdown signal

3. Test Coverage

Comprehensive Test Suite:

  • 352 lines of new tests (8 test cases)
  • Unit tests for shutdown event behavior
  • Integration tests for MixinNodeService lifecycle
  • Timing assertions verify immediate wakeup (< 1 second vs 30 seconds)
  • Edge cases covered

Issues & Recommendations

CRITICAL: Patching asyncio.base_events

Location: tests/unit/models/nodes/node_services/conftest.py:75-123

This monkey-patches the global asyncio base class at module import time. Risky because:

  • Affects ALL event loops in entire test process
  • pytest-xdist interference with 20 parallel splits
  • Suppresses ALL exceptions in exception handlers
  • Future asyncio changes could break this silently

Recommendation: Use pytest filterwarnings instead of global patching.

WARNING: Exception Swallowing

Location: conftest.py:110-113

Bare except Exception: pass could hide real bugs, resource leaks, and state corruption.

Recommendation: Be more specific about which exceptions to ignore.

MINOR: Test Timeout Values

1.0s assertion timeout might be tight on heavily loaded CI runners (20 parallel splits).

Recommendation: Use CI-aware timeouts (2.0s on CI, 1.0s locally) to prevent flakes.


Performance Considerations

Positive Impact:

  • Immediate shutdown: ~30 seconds to <1 second (30x improvement)
  • No busy-waiting: asyncio.wait_for() is efficient
  • Minimal memory overhead

CI Impact:

  • Should reduce test suite runtime
  • May improve CI split balance (currently 2m35s - 3m35s per split)

Recommendations Summary

Must Fix (Before Merge):

  1. Replace global asyncio patching with pytest filterwarnings
  2. Narrow exception handling to avoid swallowing bugs

Should Fix:

  1. Add CI-aware timeouts to test assertions
  2. Add code example to thread safety warning

Final Verdict

Recommendation: APPROVE with requested changes

Excellent software engineering demonstrated here. The only concern is the global asyncio patching in test infrastructure.

Estimated Risk: Low (after addressing asyncio patching)
Code Quality: High
Test Coverage: Excellent

Great work! Looking forward to seeing this merged!

- Add _shutdown_event.set() to signal handler for symmetry with explicit shutdown
- Rename unused input_state parameter to _input_state (fixes ARG002 linter warning)
- Loosen timing assertions from 1.0s to 2.0s threshold for CI stability
@claude

claude Bot commented Nov 26, 2025

Copy link
Copy Markdown

Code Review - PR #90: Event-Based Shutdown Signal

Summary

This PR implements a well-designed solution to eliminate asyncio shutdown warnings by using event-based signaling for immediate task cancellation. The implementation is clean, well-tested, and follows omnibase_core architectural patterns.

✅ Strengths

1. Excellent Problem Analysis

The PR correctly identifies the root cause: a 30-second asyncio.sleep() that doesn't respond to shutdown signals, causing "Task was destroyed but it is pending!" warnings during test teardown.

2. Clean Implementation

# Lazy initialization - event created only when service starts
self._shutdown_event = asyncio.Event()  # Line 138

# Event-based interruptible wait with timeout
await asyncio.wait_for(self._shutdown_event.wait(), timeout=30)  # Lines 443

The use of asyncio.wait_for() with Event.wait() is the idiomatic pattern for interruptible sleeps in asyncio.

3. Comprehensive Test Coverage

  • 357 lines of new shutdown timing tests
  • Tests verify immediate wakeup (< 2s threshold accounting for CI delays)
  • Both unit tests (isolated event behavior) and integration tests (full mixin behavior)
  • Good use of time measurements to validate performance improvements

4. Proper Shutdown Symmetry

The shutdown event is set consistently across all shutdown paths:

  • stop_service_mode() - lines 181-182
  • aclose() - lines 221-222
  • _register_signal_handlers() - lines 703-704

This ensures immediate wakeup regardless of how shutdown is triggered.

5. Thread Safety Documentation

Lines 73-77 clearly document that asyncio.Event is only safe within a single event loop, with guidance to use threading.Event for multi-threaded scenarios.

6. Fixture Improvements

The conftest.py changes add robust cleanup:

  • Signals shutdown event BEFORE attempting cancellation (line 306)
  • Allows 300ms for graceful exit before forcing cancellation
  • Patches asyncio exception handling at module level to suppress shutdown noise

⚠️ Areas for Improvement

1. Signal Handler Thread Safety Concern ⚠️

Location: Lines 696-704 (_register_signal_handlers)

def signal_handler(signum: int, _frame: Any) -> None:
    self._log_info(f"Received signal {signum}, initiating graceful shutdown")
    self._shutdown_requested = True
    if self._shutdown_event is not None:
        self._shutdown_event.set()  # ⚠️ Called from signal handler thread

Issue: Signal handlers run in the main thread, but asyncio.Event.set() is not thread-safe when called from outside the event loop thread. This could cause race conditions or undefined behavior if signals arrive while the event loop is running.

Risk: Medium - signal handling during shutdown is a common scenario

Recommendation:

def signal_handler(signum: int, _frame: Any) -> None:
    self._log_info(f"Received signal {signum}, initiating graceful shutdown")
    self._shutdown_requested = True
    
    # Thread-safe: schedule event.set() on the event loop
    if self._shutdown_event is not None:
        loop = asyncio.get_event_loop()
        if loop and not loop.is_closed():
            loop.call_soon_threadsafe(self._shutdown_event.set)

Or use threading.Event for signal-triggered shutdowns:

# In __init__:
self._signal_shutdown = threading.Event()

# In signal handler:
self._signal_shutdown.set()  # Thread-safe

# In loops:
if self._signal_shutdown.is_set():
    if self._shutdown_event:
        self._shutdown_event.set()
    break

2. Conftest Global Patching ⚠️

Location: Lines 75-123 (conftest.py)

# Apply the patch at module import time
_patch_asyncio_exception_handling()  # Line 123

Issue: This patches asyncio.base_events.BaseEventLoop.call_exception_handler globally at module import. This affects ALL asyncio code in the test process, not just the tests in this module.

Concerns:

  • Could mask real bugs in unrelated tests
  • Violates test isolation principle
  • May interact unexpectedly with pytest-asyncio or other pytest plugins

Recommendation:

  • Consider scoping the patch to specific test modules using pytest fixtures
  • Add clear documentation at file level warning about global side effects
  • Consider using pytest.mark.filterwarnings instead for less invasive suppression
@pytest.fixture(scope="module")
def suppress_shutdown_warnings():
    """Temporarily patch asyncio exception handling for this module only."""
    _patch_asyncio_exception_handling()
    yield
    # Restore original if needed

3. Test Timing Thresholds

Location: Multiple test files

The tests use 2-second thresholds to account for CI scheduler delays:

assert elapsed < 2.0, f"Expected immediate wakeup (<2s), got {elapsed:.2f}s"

Issue: While this accounts for CI variability, it's quite loose. A true immediate wakeup should be < 100ms under normal conditions.

Recommendation:

  • Use tighter thresholds (< 0.5s) for local development
  • Only relax to 2s when running in CI environment
import os
CI_THRESHOLD = 2.0 if os.getenv("CI") else 0.5
assert elapsed < CI_THRESHOLD

4. Missing None Check Pattern

Location: Lines 405-414, 440-450

The code correctly checks if self._shutdown_event is not None, but the fallback to regular asyncio.sleep() (line 414, 450) seems unnecessary given that:

  • _shutdown_event is always created in start_service_mode() before loops start
  • The loops only run when service is started

Recommendation: Consider asserting instead of fallback:

assert self._shutdown_event is not None, "Shutdown event must be initialized"
await asyncio.wait_for(self._shutdown_event.wait(), timeout=30)

Or add a comment explaining when None case would occur.


🔍 Code Quality Assessment

Adherence to omnibase_core Standards: ✅ Excellent

  • ✅ Uses object.__setattr__() for Pydantic model attributes (line 115)
  • ✅ Proper type annotations including | None (line 91)
  • ✅ Clear docstrings with multi-line explanations
  • ✅ Follows ONEX architectural patterns
  • ✅ OmniNode metadata headers in test files

Type Safety: ✅ Strong

All new code is fully typed:

_shutdown_event: asyncio.Event | None  # Line 91
async def _health_monitor_loop(self) -> None:  # Line 424

Error Handling: ✅ Robust

  • Proper use of TimeoutError exception handling (lines 410, 446)
  • Graceful fallback in signal handler (try/except at line 694)
  • CancelledError properly re-raised without logging (line 416)

🐛 Potential Bugs

None Found ✅

The core logic is sound. The event-based pattern is correctly implemented.


🔒 Security Considerations

No Security Concerns ✅

This is infrastructure code for test cleanup and service lifecycle. No user input, no data handling, no security implications.


⚡ Performance Considerations

Performance Improvements: ✅ Excellent

Before: Shutdown takes up to 30 seconds (waiting for health monitor sleep)
After: Shutdown takes < 2 seconds (immediate wakeup)

15x+ performance improvement in shutdown time during testing!

CI Impact

With 12,198 tests creating/destroying service nodes, this could save significant CI time if many tests involve service lifecycle.


📊 Test Coverage

Test Statistics

  • 357 new test lines in test_mixin_node_service_shutdown.py
  • 210 new lines in improved conftest.py fixtures
  • 8 comprehensive test cases covering:
    • Event-based wakeup behavior
    • Integration with MixinNodeService
    • Multiple shutdown paths
    • Edge cases (multiple signals, lazy init)

Coverage Quality: ✅ Excellent

The tests cover:

  • ✅ Core functionality (event wakeup)
  • ✅ Integration (full mixin behavior)
  • ✅ Edge cases (multiple sets, None checks)
  • ✅ Performance (timing assertions)

📝 Documentation

Code Documentation: ✅ Strong

  • Clear inline comments explaining the pattern (lines 135-137, 437-439)
  • Thread safety warning in class docstring (lines 73-77)
  • Well-documented test cases with clear intent

Missing Documentation:

  • Consider adding this pattern to docs/guides/THREADING.md
  • Add entry to CHANGELOG.md documenting the performance improvement

🎯 Recommendations Summary

Must Fix (Before Merge)

  1. Signal Handler Thread Safety: Use loop.call_soon_threadsafe() when setting event from signal handlers (lines 703-704)

Should Fix (High Priority)

  1. Document Conftest Patching: Add module-level warning about global asyncio patching side effects
  2. Tighten Test Thresholds: Use environment-aware timing assertions (< 0.5s locally, < 2s in CI)

Nice to Have (Low Priority)

  1. Remove unnecessary None fallbacks (lines 414, 450) or document when they occur
  2. Add CHANGELOG.md entry
  3. Add pattern to threading documentation

✅ Overall Assessment

Verdict: APPROVE with minor suggestions

This PR demonstrates:

  • ✅ Clear problem identification and elegant solution
  • ✅ Comprehensive test coverage with performance validation
  • ✅ Proper adherence to omnibase_core patterns
  • ✅ Significant performance improvement (15x faster shutdown)
  • ⚠️ One thread safety concern in signal handler (easily fixable)

The event-based shutdown pattern is the correct approach and is well-implemented. The signal handler thread safety issue should be addressed, but the core functionality is sound.

Recommendation: Address the signal handler thread safety concern, then merge. The other suggestions are improvements but not blockers.


📚 References

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (5)
src/omnibase_core/mixins/mixin_node_service.py (2)

404-414: Consider clarifying the fallback path.

The logic is correct, but the else branch (lines 413-414) that falls back to asyncio.sleep(0.1) seems overly defensive. The _shutdown_event is always created in start_service_mode() before this loop runs, so this fallback "shouldn't happen" as the inline comment suggests.

Consider either:

  1. Removing the else branch entirely and relying on the None check to raise an error if the event is missing (fail-fast)
  2. Adding a more explicit comment explaining hypothetical scenarios where the event might be None

The current implementation is safe and defensive, so this is a minor maintainability suggestion.


437-451: LGTM! This is the key improvement for shutdown timing.

Replacing the fixed 30-second sleep with an interruptible wait is the primary value of this PR, enabling immediate response to shutdown signals instead of waiting up to 30 seconds. The comment clearly explains the benefit.

Minor note: The same optional refactoring suggestion from the service event loop applies here regarding the fallback else branch (lines 449-451), but this doesn't impact correctness.

tests/unit/mixins/test_mixin_node_service_shutdown.py (3)

62-104: Test is correct but timing threshold is quite loose.

The test correctly validates immediate wakeup behavior. However, the 2.0-second threshold (lines 100, 103-104) is quite generous. While the PR objectives explain this accounts for "CI scheduler delays under heavy load," this loose threshold might hide actual performance regressions.

Consider adding a comment referencing the CI stability rationale for the 2.0s threshold, or tracking this as a known limitation in case tighter bounds become feasible in the future.


106-119: Upper bound is too loose for timeout verification.

The upper bound check of 2.0 seconds (line 119) is excessively loose for validating a 0.5-second timeout. This could mask scenarios where the timeout mechanism isn't working correctly. An elapsed time of 1.5+ seconds would still pass but indicates something is wrong.

Consider tightening to assert 0.4 < elapsed < 0.8 unless there's specific evidence of CI instability requiring the 2.0s bound.


191-251: LGTM! Comprehensive test of aclose() behavior.

The test correctly validates that aclose() triggers immediate wakeup. Cleanup handling is thorough with proper task cancellation on both success (lines 238-244) and failure paths (lines 231-235).

Minor note: The cleanup code is duplicated between the failure path and after the completion check. This is acceptable for test clarity, though it could be extracted to a helper if more tests follow this pattern.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3280b01 and 48419dd.

📒 Files selected for processing (2)
  • src/omnibase_core/mixins/mixin_node_service.py (9 hunks)
  • tests/unit/mixins/test_mixin_node_service_shutdown.py (1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Always use Poetry for Python package management and task execution - never use pip or python directly
Always use ModelOnexError with EnumCoreErrorCode for structured error handling, never raise generic Exception
Use @standard_error_handling decorator on async functions to eliminate boilerplate try/catch logic
Use duck typing with protocols instead of isinstance checks - protocols define contracts through interfaces, not runtime type checking
Always use 100% strict mypy type checking - all functions must have type annotations and no untyped definitions are allowed
Run code through black formatter, isort for import sorting, and ruff linter before committing
Use async/await pattern with asyncio for asynchronous operations - never use threading for I/O operations
Use ModelEventEnvelope for inter-service event-driven communication and process event payloads through envelope pattern

Files:

  • src/omnibase_core/mixins/mixin_node_service.py
  • tests/unit/mixins/test_mixin_node_service_shutdown.py
src/omnibase_core/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

Use container.get_service('ProtocolName') for dependency resolution by protocol name, never by concrete class name

Files:

  • src/omnibase_core/mixins/mixin_node_service.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

tests/**/*.py: Maintain minimum 60% code coverage with pytest tests - unit tests go in tests/unit/, integration tests in tests/integration/
Run pytest tests with parallelization disabled (-n 0 -xvs) for debugging and use standard pytest run for CI
Use @pytest.mark decorators (unit, integration, slow, smoke, performance) to categorize tests appropriately

Files:

  • tests/unit/mixins/test_mixin_node_service_shutdown.py
🧬 Code graph analysis (1)
tests/unit/mixins/test_mixin_node_service_shutdown.py (1)
src/omnibase_core/mixins/mixin_node_service.py (3)
  • get_service_health (322-352)
  • aclose (212-229)
  • stop_service_mode (162-210)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Smoke Tests
  • GitHub Check: Code Quality
  • GitHub Check: claude-review
🔇 Additional comments (9)
src/omnibase_core/mixins/mixin_node_service.py (5)

91-91: LGTM! Clean lazy initialization pattern.

The type annotation correctly reflects the optional nature, and initialization to None with a clear comment about lazy creation in start_service_mode() is well-documented.

Also applies to: 114-115


135-138: LGTM! Proper lazy initialization of shutdown event.

The event is created at the right time (when the service starts and event loop is available), and the comment clearly explains the immediate-wakeup benefit for sleeping tasks.


178-182: LGTM! Correct shutdown signaling with defensive check.

The None check before calling set() is appropriate defensive programming, and the comment clearly explains the benefit of avoiding the 30-second sleep interval.


220-222: LGTM! Consistent shutdown signaling in aclose().

The pattern matches stop_service_mode(), ensuring immediate wakeup before cleaning up the health task. This is the correct sequence for async cleanup.


701-704: LGTM! Correct signal handler integration.

Setting the shutdown event in the signal handler ensures consistent immediate-wakeup behavior whether shutdown is triggered by signals (SIGTERM/SIGINT) or explicit calls to stop_service_mode()/aclose(). The defensive None check is appropriate here.

tests/unit/mixins/test_mixin_node_service_shutdown.py (4)

38-56: LGTM! Clean test helper implementation.

The MinimalServiceNode provides just enough implementation to test shutdown behavior without unnecessary complexity. The use of object.__setattr__ for _node_id is consistent with the pattern in MixinNodeService.


121-189: LGTM! Good integration test with proper cleanup.

This test validates the actual health monitor loop pattern used in MixinNodeService. The manual state initialization (lines 131-134) is necessary for isolated testing, and the cleanup logic (lines 177-183) properly handles task cancellation.


253-301: LGTM! Clean test of service event loop behavior.

The test correctly validates that the service event loop responds immediately to shutdown signals despite the short 0.1s iteration timeout. Tracking loop iterations (line 297) adds useful validation that the loop actually executed.


304-357: LGTM! Comprehensive integration test coverage.

These integration tests cover important scenarios:

  • Event is properly set during shutdown (lines 307-329)
  • Lazy initialization pattern is verified (lines 331-337)
  • Idempotency of multiple signals (lines 339-357)

The tests are clear, focused, and validate key properties of the shutdown event mechanism.

Comment thread src/omnibase_core/mixins/mixin_node_service.py
- Add global asyncio patching warning to conftest.py docstring
- Fix signal handler thread safety with loop.call_soon_threadsafe()
- Implement environment-aware timing thresholds (0.6s local, 2.0s CI)
- Add type narrowing for shutdown event in test closures
@claude

claude Bot commented Nov 26, 2025

Copy link
Copy Markdown

Code Review: Event-Based Shutdown Signal for Health Monitor Loop

Overall Assessment: ✅ EXCELLENT - This is a high-quality fix that addresses a real problem with a well-designed solution. The implementation follows ONEX best practices and includes comprehensive test coverage.


🎯 Problem & Solution

Problem: Health monitor loop had a 30-second asyncio.sleep(30) that didn't respond to shutdown signals, causing:

  • "Task was destroyed but it is pending!" warnings during pytest teardown
  • Delayed test suite shutdown (waiting up to 30s for sleep to complete)

Solution: Event-based shutdown signaling using asyncio.Event for immediate task wakeup. Elegant and idiomatic asyncio pattern.


✅ Strengths

1. Excellent Architecture

  • ✅ Clean use of asyncio.Event for coordination within the same event loop (correct per ONEX threading guidelines)
  • ✅ Lazy initialization of _shutdown_event in start_service_mode() - proper lifecycle management
  • ✅ Thread-safe signal handler implementation using call_soon_threadsafe() (lines 706-710)
  • ✅ Multiple shutdown paths covered: stop_service_mode(), aclose(), and signal handlers

2. Comprehensive Test Coverage (393 lines!)

  • ✅ Tests verify immediate wakeup behavior (< 0.6s locally, < 2.0s in CI)
  • ✅ Environment-aware timing thresholds (get_timing_threshold())
  • ✅ Edge cases covered: multiple shutdown signals, lazy initialization, timeout behavior
  • ✅ Integration tests for actual MixinNodeService behavior

3. Excellent Code Quality

  • ✅ Type hints: _shutdown_event: asyncio.Event | None follows Python 3.12+ standards
  • ✅ Proper exception handling: TimeoutError for normal timeouts vs shutdown
  • ✅ Clear comments explaining the "why" (not just "what")
  • ✅ Consistent with ONEX patterns: uses object.__setattr__() for Pydantic bypass

4. Documentation & Safety

  • ✅ Thread safety warning in docstring (lines 72-77)
  • ✅ Comprehensive conftest documentation explaining global patching side effects
  • ✅ Clear PR description with problem/solution/test plan

🔍 Code Quality Details

Implementation Pattern (Perfect ✨)

# ✅ Interruptible wait pattern - idiomatic asyncio
if self._shutdown_event is not None:
    try:
        await asyncio.wait_for(self._shutdown_event.wait(), timeout=30)
        break  # Shutdown signaled
    except TimeoutError:
        pass  # Normal timeout - continue

This is exactly how asyncio shutdown coordination should be done.

Signal Handler Safety (Excellent 👍)

# ✅ Thread-safe signal handler (lines 704-710)
loop = asyncio.get_running_loop()
loop.call_soon_threadsafe(self._shutdown_event.set)

Correctly uses call_soon_threadsafe() since signal handlers run on main thread but Event.set() must be called from event loop thread.


⚠️ Minor Observations (Not Blocking)

1. Fallback Branch Unreachable (mixin_node_service.py:450)

else:
    # Fallback to simple sleep if no shutdown event (shouldn't happen)
    await asyncio.sleep(30)

Analysis: This branch should be unreachable since _shutdown_event is created in start_service_mode() before health loop starts. Consider:

Option A (Defensive): Keep as-is for robustness
Option B (Explicit): Replace with raise RuntimeError("Shutdown event not initialized")

Recommendation: Keep as-is for defensive programming. The comment correctly notes it "shouldn't happen."

2. Test Timing Assertions Could Be More Descriptive

# Current (lines 122-129)
assert elapsed < threshold, f"Expected immediate wakeup (<{threshold}s), got {elapsed:.2f}s"

Enhancement: Consider adding CI detection info to failure messages:

ci_info = "(CI mode)" if is_ci else "(local mode)"
assert elapsed < threshold, f"Expected <{threshold}s {ci_info}, got {elapsed:.2f}s"

Impact: Low - current messages are already clear.

3. Global Patching Documentation (Tests Conftest)

The tests/conftest.py now has excellent documentation (lines 15-46) explaining global asyncio exception handler patching. This is best practice - no changes needed, just noting it's exemplary.


🔒 Security & Safety

✅ No security concerns identified

  • Event-based coordination is safe within single event loop
  • Proper thread safety for cross-thread signal handling
  • No race conditions in shutdown sequence

✅ Thread Safety Compliance (per ONEX CLAUDE.md)

  • Correctly documents single event loop requirement
  • Follows ONEX patterns: asyncio.Event for same-loop coordination

📊 Performance Impact

✅ Positive Impact

  • Test Suite: Eliminates up to 30s wait per service node shutdown
  • Production: Immediate response to shutdown signals (was: up to 30s delay)
  • Memory: Minimal overhead (asyncio.Event is lightweight)

✅ Compliance with ONEX Standards

Standard Status Evidence
Poetry Usage ✅ Pass Pre-commit checks use poetry
Type Hints ✅ Pass _shutdown_event: asyncio.Event | None
Error Handling ✅ Pass Proper exception handling in signal handlers
Thread Safety ✅ Pass Documented in docstring + correct implementation
Test Coverage ✅ Pass 393 lines of new tests
Documentation ✅ Pass Clear docstrings and comments

🎯 Recommendations

Required Before Merge

NONE - This PR is ready to merge as-is.

Optional Enhancements (Future)

  1. Consider extracting get_timing_threshold() to a shared test utility (DRY principle)
  2. Add metrics for shutdown timing in production (observability)
  3. Consider backport to other projects using MixinNodeService

📝 Final Verdict

Status: ✅ APPROVED - Ready to Merge

Rationale:

  • Solves a real, annoying problem (test suite warnings)
  • Implementation is clean, idiomatic, and well-tested
  • No breaking changes to public API
  • Follows all ONEX architectural patterns
  • Comprehensive test coverage (8 test methods covering edge cases)
  • Excellent documentation and comments

Nice work on this fix! 🎉 The event-based approach is the correct pattern for asyncio shutdown coordination, and the implementation is thorough and professional.


Review completed by: Claude Code (Sonnet 4.5)
Review date: 2025-11-26
Files reviewed: 9 files, +846/-67 lines
Test coverage: 393 new test lines

@jonahgabriel
jonahgabriel merged commit 60c7610 into main Nov 26, 2025
26 checks passed
@jonahgabriel
jonahgabriel deleted the fix/health-monitor-loop-shutdown-signal branch November 26, 2025 16:26
jonahgabriel added a commit that referenced this pull request Feb 23, 2026
…ar snapshot [OMN-2655]

Add three new Pydantic event models as the Core-layer view for workflow
automation event types defined in OMN-2655 Part 2 (omnibase_core scope):

- ModelGitHubPRStatusEvent — GitHub PR triage event (topic: onex.evt.github.pr-status.v1)
- ModelGitHookEvent — Git hook invocation event (topic: onex.evt.git.hook.v1)
- ModelLinearSnapshotEvent — Linear workspace snapshot event (topic: onex.evt.linear.snapshot.v1)

All models extend ModelRuntimeEventBase with frozen=True, extra="forbid".
All SPI contract fields (ContractGitHubPRStatusEvent, ContractGitHookEvent,
ContractLinearSnapshotEvent from omnibase_spi PR #90) are present as a
superset, satisfying the SPI ⊆ Core alignment invariant.

Topic constants added to constants_event_types.py. All three models and
their topic constants exported from models/events/__init__.py.
jonahgabriel added a commit that referenced this pull request Feb 24, 2026
…ar snapshot [OMN-2655] (#531)

* feat(events): add Core event models for GitHub PR, Git hook, and Linear snapshot [OMN-2655]

Add three new Pydantic event models as the Core-layer view for workflow
automation event types defined in OMN-2655 Part 2 (omnibase_core scope):

- ModelGitHubPRStatusEvent — GitHub PR triage event (topic: onex.evt.github.pr-status.v1)
- ModelGitHookEvent — Git hook invocation event (topic: onex.evt.git.hook.v1)
- ModelLinearSnapshotEvent — Linear workspace snapshot event (topic: onex.evt.linear.snapshot.v1)

All models extend ModelRuntimeEventBase with frozen=True, extra="forbid".
All SPI contract fields (ContractGitHubPRStatusEvent, ContractGitHookEvent,
ContractLinearSnapshotEvent from omnibase_spi PR #90) are present as a
superset, satisfying the SPI ⊆ Core alignment invariant.

Topic constants added to constants_event_types.py. All three models and
their topic constants exported from models/events/__init__.py.

* chore: reflow pre-commit hook description and bump lockfile to 0.19.0

Reformats the check-local-paths hook description to a single
paragraph (no mid-sentence line-break) and regenerates uv.lock
to reflect the 0.19.0 package version.

* fix(events): convert TriageState Literal to StrEnum per governance check
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.

1 participant