History flush at exit can deadlock, hanging shell exit indefinitely (regression in 0.23.0, #6249)
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 SIGKILLA 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.tmpCause
JsonHistoryFlusher.__init__ in xonsh/history/json.py, at_exit=True path:
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:
# 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:
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 exitA 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.
Source: xonsh/xonsh