Skip to content

Startup ConfigurationError never terminates the process: sys.exit(1) blocks forever in wait_for_thread_shutdown() #211

Description

@com3run

Summary

When a ConfigurationError is raised during startup, the bot logs the error but the process never exits. It sits idle at 0% CPU indefinitely. sys.exit(1) runs, but interpreter finalization blocks forever waiting on the aiosqlite connection pool's non-daemon threads, which are never shut down on this code path.

This is worse than a plain crash, because process supervisors see a live PID and consider the service healthy. launchd reports state = running with last exit code = (never exited), so KeepAlive never fires. The same applies to systemd Restart=. The bot appears to be up while being permanently dead.

Environment

  • claude-code-telegram v1.6.0 (installed via uv tool install)
  • Python 3.12.13
  • aiosqlite 0.21.0
  • macOS 15 (Darwin 25.5.0), arm64

Reproduction

  1. Configure .env with ALLOWED_USERS empty, ENABLE_TOKEN_AUTH=false, and DEVELOPMENT_MODE=false.
  2. Run claude-telegram-bot.
  3. The log ends with:
{"event": "Storage system initialized", "logger": "src.storage.facade", "level": "info"}
{"error": "No authentication providers configured", "event": "Configuration error", "logger": "src.main", "level": "error"}
  1. The process stays alive forever. It has to be killed manually.

Cause

In src/main.py, create_application() initializes storage before validating auth providers:

  • line 103-104: storage = Storage(config.database_url) then await storage.initialize(), which starts a 5 connection aiosqlite pool
  • line 125-126: elif not providers: raise ConfigurationError("No authentication providers configured")

The exception propagates out of create_application() to the handler in main() (line 395), which calls sys.exit(1). The only call to await storage.close() lives in run_application()'s shutdown block (line 362), which is never reached, because the failure happened before run_application() was ever called. The pool is left running.

aiosqlite.Connection subclasses threading.Thread and calls super().__init__() without setting daemon, so those 5 connections are non-daemon threads parked on self._tx.get(). They only stop when close() posts _STOP_RUNNING_SENTINEL. Non-daemon threads are joined during interpreter shutdown, so Py_FinalizeEx waits on them forever.

sample of the stuck process, main thread:

Py_Exit  (in python3.12) + 20
  Py_FinalizeEx  (in python3.12) + 72
    wait_for_thread_shutdown  (in python3.12) + 104
      lock_PyThread_acquire_lock  (in python3.12) + 56
        PyThread_acquire_lock_timed  (in python3.12) + 432
          _pthread_cond_wait  (in libsystem_pthread.dylib) + 980

and five identical worker threads:

thread_run  (in python3.12) + 148
  _queue_SimpleQueue_get  (in python3.12) + 220
    _queue_SimpleQueue_get_impl  (in python3.12) + 232
      PyThread_acquire_lock_timed  (in python3.12) + 432
        _pthread_cond_wait  (in libsystem_pthread.dylib) + 980

For contrast, an invalid TELEGRAM_BOT_TOKEN exits cleanly. InvalidToken is raised from bot.initialize() inside run_application(), where the shutdown block does run. It is specifically failures raised between storage.initialize() and run_application() that wedge.

Impact

Anyone running this under launchd, systemd, Docker with a restart policy, or any other supervisor gets a silently dead bot after a config mistake, with no restart and no crash to alert on. It also affects the two ENABLE_PROJECT_THREADS errors at lines 239 and 259, and any other exception escaping create_application() after storage init.

Suggested fix

Close storage when application construction fails, for example:

async def create_application(config: Settings) -> Dict[str, Any]:
    storage = Storage(config.database_url)
    await storage.initialize()

    try:
        # ... existing body ...
        return {...}
    except BaseException:
        await storage.close()
        raise

Validating auth providers before initializing storage would also avoid this particular instance, but the try/except covers every failure between pool startup and run_application(), which seems more robust. Marking the aiosqlite threads as daemon would mask the hang but leave connections unclosed, so it looks like the weaker option.

Happy to open a PR if that would help.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions