Skip to content

test(tui_gateway): stop reloading server module in fixture teardown#34217

Merged
teknium1 merged 1 commit into
mainfrom
fix/tui-gateway-test-reload-flake
May 29, 2026
Merged

test(tui_gateway): stop reloading server module in fixture teardown#34217
teknium1 merged 1 commit into
mainfrom
fix/tui-gateway-test-reload-flake

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Stops three tui_gateway test fixtures from reloading tui_gateway.server in teardown. The reloads were re-registering the module's atexit hooks on every test, accumulating duplicates across the session, and racing the stderr buffer at interpreter shutdown — which surfaces as a CI flake where "all tests pass" but the slice exits non-zero.

The flake

Affected slices fail with:

204 files, 3572 tests passed, 0 failed (100% complete) in 80.2s
─────────────────────────────────────────────────────────────────
Fatal Python error: _enter_buffered_busy: could not acquire lock for
  <_io.BufferedWriter name='<stderr>'> at interpreter shutdown,
  possibly due to daemon threads
##[error] Process completed with exit code 1

Surfaced today on PR #34193 test slice 1 — same shape was hitting the docker integration tests earlier in the day too. The exit-code-non-zero turns the slice red even though every test reported PASS.

Root cause

tui_gateway/server.py registers two atexit hooks at module load time:

# line 170
_pool = concurrent.futures.ThreadPoolExecutor(...)
atexit.register(lambda: _pool.shutdown(wait=False, cancel_futures=True))

# line 336
atexit.register(_shutdown_sessions)

Three test fixtures called importlib.reload(mod) at teardown to reset per-test session state. Each reload re-runs the module-level code, including the atexit registrations. After N tests using the fixture, the atexit registry has N+1 copies of each hook. At pytest interpreter shutdown all N+1 try to fire in parallel and race for the stderr buffer flush.

Fix

Drop importlib.reload(mod) from the three fixtures. Per-test reset is handled by clearing the mutable session dicts (_sessions, _pending, _answers). _methods is also no longer cleared — it's populated at module import time and would only repopulate via reload, so clearing it without the reload broke session.resume / command.dispatch / slash.exec method registration across tests.

 yield mod
 mod._sessions.clear()
 mod._pending.clear()
 mod._answers.clear()
-mod._methods.clear()
-importlib.reload(mod)
File Before After
tests/tui_gateway/test_review_summary_callback.py reloaded on teardown dict-clear only
tests/tui_gateway/test_goal_command.py reloaded on teardown dict-clear only
tests/tui_gateway/test_protocol.py reloaded on teardown dict-clear only

The second reload in test_protocol.py at line 211 (reload of tui_gateway.transport for the HERMES_TUI_GATEWAY_NO_FLUSH env-var test) is preserved — transport.py has no atexit hooks or threads, so reload is safe there.

Validation

  • tests/tui_gateway/ full directory: 84/84 pass, exit code 0, no Fatal Python error at interpreter shutdown.
  • Targeted re-runs of the specific tests that used to leak (test_session_resume_returns_hydrated_messages, test_slash_exec_*, test_command_dispatch_*): all pass in order-shuffled session.

Origin

This PR is a side-fix from triaging an unrelated CI failure on PR #34193 — the kanban checkpoint reminder feature. The atexit-duplicate issue is independent of any kanban work; it's a test-infrastructure bug that benefits every PR. Splitting it into its own PR so it lands cleanly regardless of whether #34193 ships.

Infographic

tui-gateway-test-reload-flake

tui_gateway.server registers two atexit hooks at module load time:
ThreadPoolExecutor shutdown (line 170) and _shutdown_sessions (line 336).
Three test files reloaded the module on each fixture teardown to reset
per-test state. Each reload re-runs module-level code, including the
atexit registrations — duplicates accumulate across the test session.

At pytest interpreter shutdown the duplicated atexit hooks race the
stderr buffer flush:

    Fatal Python error: _enter_buffered_busy: could not acquire lock
    for <_io.BufferedWriter name='<stderr>'> at interpreter shutdown,
    possibly due to daemon threads

pytest reports 'tests passed but the slice exited non-zero', and the
shard turns red on CI. Surfaced today on PR #34193's test slice 1
(204 files, 3572 tests passed, then Fatal Python error during exit).

Fix: drop importlib.reload(mod) from the three fixtures that have it.
Per-test reset is handled by clearing the mutable session dicts
(_sessions, _pending, _answers). _methods is also no longer cleared —
it's populated at module import time and would only be re-populated by
a reload, so clearing it without reload broke session.resume /
command.dispatch / slash.exec method registration across tests.

Affected fixtures:
- tests/tui_gateway/test_goal_command.py
- tests/tui_gateway/test_protocol.py
- tests/tui_gateway/test_review_summary_callback.py

The second reload in test_protocol.py at line 211 (reload of
tui_gateway.transport) is preserved — transport.py has no atexit hooks
or threads, so reload is safe there.

Tests: 84/84 in tests/tui_gateway/ pass cleanly with exit code 0; no
Fatal Python error at interpreter shutdown.
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Lint report: fix/tui-gateway-test-reload-flake vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9579 on HEAD, 9579 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 5047 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@teknium1 teknium1 merged commit 300140e into main May 29, 2026
25 checks passed
@teknium1 teknium1 deleted the fix/tui-gateway-test-reload-flake branch May 29, 2026 01:16
Bryce-huang pushed a commit to wbkunlun/hermes-agent that referenced this pull request May 29, 2026
…ousResearch#34217)

tui_gateway.server registers two atexit hooks at module load time:
ThreadPoolExecutor shutdown (line 170) and _shutdown_sessions (line 336).
Three test files reloaded the module on each fixture teardown to reset
per-test state. Each reload re-runs module-level code, including the
atexit registrations — duplicates accumulate across the test session.

At pytest interpreter shutdown the duplicated atexit hooks race the
stderr buffer flush:

    Fatal Python error: _enter_buffered_busy: could not acquire lock
    for <_io.BufferedWriter name='<stderr>'> at interpreter shutdown,
    possibly due to daemon threads

pytest reports 'tests passed but the slice exited non-zero', and the
shard turns red on CI. Surfaced today on PR NousResearch#34193's test slice 1
(204 files, 3572 tests passed, then Fatal Python error during exit).

Fix: drop importlib.reload(mod) from the three fixtures that have it.
Per-test reset is handled by clearing the mutable session dicts
(_sessions, _pending, _answers). _methods is also no longer cleared —
it's populated at module import time and would only be re-populated by
a reload, so clearing it without reload broke session.resume /
command.dispatch / slash.exec method registration across tests.

Affected fixtures:
- tests/tui_gateway/test_goal_command.py
- tests/tui_gateway/test_protocol.py
- tests/tui_gateway/test_review_summary_callback.py

The second reload in test_protocol.py at line 211 (reload of
tui_gateway.transport) is preserved — transport.py has no atexit hooks
or threads, so reload is safe there.

Tests: 84/84 in tests/tui_gateway/ pass cleanly with exit code 0; no
Fatal Python error at interpreter shutdown.
#AI commit#
KKT-OPT pushed a commit to KKT-OPT/hermes-agent that referenced this pull request May 31, 2026
…ousResearch#34217)

tui_gateway.server registers two atexit hooks at module load time:
ThreadPoolExecutor shutdown (line 170) and _shutdown_sessions (line 336).
Three test files reloaded the module on each fixture teardown to reset
per-test state. Each reload re-runs module-level code, including the
atexit registrations — duplicates accumulate across the test session.

At pytest interpreter shutdown the duplicated atexit hooks race the
stderr buffer flush:

    Fatal Python error: _enter_buffered_busy: could not acquire lock
    for <_io.BufferedWriter name='<stderr>'> at interpreter shutdown,
    possibly due to daemon threads

pytest reports 'tests passed but the slice exited non-zero', and the
shard turns red on CI. Surfaced today on PR NousResearch#34193's test slice 1
(204 files, 3572 tests passed, then Fatal Python error during exit).

Fix: drop importlib.reload(mod) from the three fixtures that have it.
Per-test reset is handled by clearing the mutable session dicts
(_sessions, _pending, _answers). _methods is also no longer cleared —
it's populated at module import time and would only be re-populated by
a reload, so clearing it without reload broke session.resume /
command.dispatch / slash.exec method registration across tests.

Affected fixtures:
- tests/tui_gateway/test_goal_command.py
- tests/tui_gateway/test_protocol.py
- tests/tui_gateway/test_review_summary_callback.py

The second reload in test_protocol.py at line 211 (reload of
tui_gateway.transport) is preserved — transport.py has no atexit hooks
or threads, so reload is safe there.

Tests: 84/84 in tests/tui_gateway/ pass cleanly with exit code 0; no
Fatal Python error at interpreter shutdown.
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