#6588·xonsh

History flush at exit can deadlock, hanging shell exit indefinitely (regression in 0.23.0, #6249)

Author: manujchandraCreated Sep 1, 2026Updated Sep 1, 2026

xonsh version: 0.23.1 (conda-forge) Python: 3.13.13 OS: Debian 13 (trixie), systemd 257 Backend: json (default)

What happens

On a systemd desktop, xonsh sometimes never exits when it receives SIGTERM at logout or shutdown. systemd waits out the full TimeoutStopSec on the session scope — 45 s here, 90 s by default — and then SIGKILLs. Because every desktop process lives in that one session scope, the whole machine's shutdown is held up. It reproduced on essentially every shutdown.

19:52:33.237780  Stopping session-2.scope - Session 2 of User manuj...
19:53:18.236939  session-2.scope: Stopping timed out. Killing.        ← exactly 45.000s
19:53:18.237279  session-2.scope: Killing process 152894 (xonsh) with signal SIGKILL

A zero-byte history temp file is left behind, timestamped 6 ms before the scope stop began — xonsh had entered a history flush and never completed it:

-rw------- 1 user user 0 2026-08-30 19:52:33.231659857 tmp007n04w_.json.tmp

Cause

JsonHistoryFlusher.__init__ in xonsh/history/json.py, at_exit=True path:

python
if at_exit:
    with self.cond:
        self.cond.wait_for(self.i_am_at_the_front)   # unbounded, no timeout
        self.dump()
        self.queue.popleft()
        self.cond.notify_all()
else:
    self.start()

cond.wait_for(self.i_am_at_the_front) has no timeout. If a background flusher is still in the queue and cannot finish — which is exactly what happens while the session is being torn down and its threads are no longer making progress — the at-exit flush waits forever, and the shell never exits.

Regression point

This is a behaviour change from PR #6249, released in 0.23.0. Before it, the at-exit path never blocked:

python
# 0.22.5 and earlier
if at_exit:
    self.dump()
    queue.popleft()
else:
    self.start()

The PR intentionally made the exit handler "wait for any in-progress background flusher to complete before writing, instead of bypassing the queue entirely". That is reasonable in principle, but the wait is unbounded, which turns a race into a hang. The notify_all() added in the same PR does not help when the preceding flusher never runs to completion.

The same code is still present on main, so 0.24.x is affected too.

Suggested fix

Bound the wait and degrade gracefully rather than blocking forever — exiting without the last history entries is much better than never exiting:

python
if at_exit:
    with self.cond:
        if self.cond.wait_for(self.i_am_at_the_front, timeout=XONSH_HISTORY_EXIT_FLUSH_TIMEOUT):
            self.dump()
            self.queue.popleft()
            self.cond.notify_all()
        # else: give up and let the process exit

A small default (1–2 s), ideally configurable, would keep #6249's benefit while making it impossible for history to block shell exit. It may also be worth having xonsh's own SIGTERM handler guarantee termination even if the flush cannot complete.

Workaround

$XONSH_HISTORY_BACKEND = 'sqlite' avoids it entirely: SqliteHistory inherits the no-op base.History.flush(), so JsonHistoryFlusher is never constructed, and append() already writes each command immediately, so nothing is lost.

Reproduction notes

Hard to reproduce synthetically — a freshly started xonsh always exits promptly on SIGTERM. It needs a long-lived interactive session with accumulated history, receiving SIGTERM while the desktop session is being torn down. The zero-byte *.json.tmp file in $XONSH_DATA_DIR/history_json/ is a reliable fingerprint that a flush was entered and never finished.