Skip to content

fix(scripts): close connections on the failure path, and make a broken close visible - #189

Merged
Cipher208 merged 1 commit into
masterfrom
fix/scripts-close-on-failure
Nov 3, 2026
Merged

Cipher208 merged 1 commit into
masterfrom
fix/scripts-close-on-failure

Conversation

@Cipher208

Copy link
Copy Markdown
Owner

Что

Тот же класс, что в #186, остался в восьми скриптах и в stdio-пути сервера: asyncio.run(main()), а close_all() — следующей инструкцией, без try/finally. Исключение в рабочем вызове пропускает закрытие; воркеры aiosqlite не-daemon, threading._shutdown джойнит их вечно, процесс печатает traceback и не завершается.

Воспроизведено на точной форме этих скриптов: внешний timeout 8 → exit 124; с finally → exit 1 за секунду.

Одиннадцать мест

файл где
scripts/warm_ner_cache.py module-level
scripts/warm_embedding_cache.py module-level
scripts/dedup_core_twins.py run()
scripts/dedup_episode_twins.py run() и backfill_transitions()
scripts/resolve_conflicts.py run() (включая assert)
scripts/mirror_identity.py run_manifest() и main._run()
scripts/docs_to_wiki.py _run()
scripts/purge_foreign_rows.py main()
mcp_server/server.py stdio-ветка

backfill_transitions — десятое место, найденное verifier'ом на первом круге: оно достижимо из CLI и падало с exit 124.

Stdio — худший случай

mcp.run(transport="stdio") вообще не имел закрытия. Клиент закрывает stdin → сервер остаётся в threading._shutdown, родитель ждёт вечно.

сценарий было стало
mcp.run вернулся (EOF) exit 124 exit 0
mcp.run бросил exit 124 exit 1, traceback

Сломанное закрытие теперь видно

Первая версия использовала contextlib.suppress(Exception) — и verifier доказал, что так настоящий сбой закрытия на успешном пути исчезает: exit 0 там, где база давала 1, и ни строчки в логе.

Теперь в каждом месте:

except Exception:
    logger.warning("close_all() failed during shutdown", exc_info=True)

Исходное исключение по-прежнему уходит неизменным, коды возврата не подменяются.

Попутно

mirror_identity восстанавливает cm.base_dir в finally — на базе исключение оставляло глобальную переменную указывать на последнюю базу манифеста.

Вне объёма

_shutdown_handler с os._exit(0) в server.py не тронут — это осознанный обход того же зависания для http+dashboard, и его изменение отдельное решение.

Найдено, но не тронуто: та же форма в scripts/sync_skills.py, scripts/verify_autohooks.py, scripts/backfill_memory_kind.py.

Проверка

  • 2849 passed;
  • ruff check чисто, ruff format 753 файла, mypy 63 = baseline (набор ошибок сверен с базой — идентичен);
  • blind verifier, два круга. Первый: 11/11 тестов падают на базе, но нашёл backfill_transitions и маскировку сбоя закрытия. Второй: всё закрыто, D1 воспроизведён до/после (124 → 1), лог на success-path подтверждён;
  • непустых изменений git diff -w: +108/−26, остальное — переиндентация под try.

…n close visible

Same class as #186, still present in eight scripts and the stdio server path:
`asyncio.run(main())` with `close_all()` as the NEXT statement, no `try/finally`.
An exception in the work call skipped the close, aiosqlite's worker threads are
non-daemon, `threading._shutdown` joins them forever, and the process prints a
traceback and never exits. Reproduced on the exact shape of these scripts:
external `timeout 8` -> exit 124; with `finally` -> exit 1 in one second.

Fixed at eleven reachable sites:
  scripts/warm_ner_cache.py, scripts/warm_embedding_cache.py (module-level),
  scripts/dedup_core_twins.py::run, scripts/dedup_episode_twins.py::run and
  ::backfill_transitions, scripts/resolve_conflicts.py::run,
  scripts/mirror_identity.py::run_manifest and ::main._run,
  scripts/docs_to_wiki.py::_run, scripts/purge_foreign_rows.py::main,
  and mcp_server/server.py's stdio branch.

The stdio branch matters most: it had NO close at all, so a client closing
stdin left the server parked in `threading._shutdown` while its parent waited.
Measured with a real worker thread: at the base the process exits 124 in both
the normal-return and the raising case; now exit 0 and exit 1 respectively.

The close failure is logged, not suppressed. A bare
`contextlib.suppress(Exception)` would have made a genuinely broken close
invisible on the SUCCESS path — exit 0 where the base exited 1, with nothing in
the log. Each site now does
`except Exception: logger.warning("close_all() failed during shutdown", exc_info=True)`,
so the failure is observable while an in-flight exception still propagates
unchanged and no exit code is rewritten to compensate.

`mirror_identity` also restores `cm.base_dir` in the `finally`: at the base an
exception left the module-global pointing at the last manifest base.

Not touched: `_shutdown_handler`'s `os._exit(0)` in `mcp_server/server.py` — it
is a deliberate way to avoid this hang on the http+dashboard path, and changing
it is a separate decision.

Found but left alone (pre-existing, outside this package): the same shape in
`scripts/sync_skills.py`, `scripts/verify_autohooks.py` and
`scripts/backfill_memory_kind.py`.
@coderabbitai

coderabbitai Bot commented Nov 3, 2026

Copy link
Copy Markdown

Important

  • Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

@github-actions github-actions Bot added the fix label Nov 3, 2026
@Cipher208
Cipher208 merged commit 2cffe18 into master Nov 3, 2026
19 checks passed
@Cipher208
Cipher208 deleted the fix/scripts-close-on-failure branch November 3, 2026 19:22
Cipher208 added a commit that referenced this pull request Nov 3, 2026
… I shipped (#190)

Three scripts still had the #189 class, each in a different shape:

  - `sync_skills.py::sync_one` closed AFTER `sync_external`, so a raise there
    skipped the close entirely (measured at 2cffe18: close never called, the
    process hung and was killed at 20 s);
  - `verify_autohooks.py` closed on the success path only, in the same function
    that ends in `os._exit`. An exception therefore hung until the 45 s
    faulthandler guard fired, and the exit code came from the guard rather than
    from the real failure. The checks moved into `_run_checks()` and `main()`
    now closes in a `finally` before `os._exit` — which stays where it is, on
    purpose, and outside the `try`: inside it, `finally` would never run;
  - `backfill_memory_kind.py` already had `finally`; its defect was a silent
    `contextlib.suppress(Exception)` around the close, which made a genuinely
    broken shutdown indistinguishable from a clean one.

All three now log a failed close (`logger.warning(..., exc_info=True)`) instead
of swallowing it, matching #189.

Separately, this fixes a regression #189 put on master. `test_close_all_on_failure.py`
patched `shared.write_dispatch.get_write_dispatch`, and `core.memory` — which does
`from shared.write_dispatch import get_write_dispatch` at module level — was first
imported lazily INSIDE that patched window. It bound the stub permanently, so
`test_mirror_identity.py` later failed with the stub's own `RuntimeError: boom`:
`pytest tests/test_scripts` read 7 failed on 2cffe18 against 162 passed on
b5060fe. The full suite hid it behind test ordering.

An autouse fixture now snapshots the real function and rebinds it in every loaded
module at teardown. It fixes the mechanism rather than the three instances: any
module imported inside the window is repaired, not just `core.memory`. The tests
are not weakened — reverting any one of the three script fixes still fails its
test, checked for all three.

Co-authored-by: Cipher208 <269750686+Cipher208@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant