Embedding Xonsh

Warning

This page is a work-in-progress stub. It currently documents only the controlling-terminal handshake for embedded use cases. A fuller guide — covering xonsh.main.setup(), custom Shell subclasses, teardown, thread safety, and integration patterns for LLM agents / TUI apps / REPLs — will appear in a later release. If you are embedding xonsh and need guidance that is not covered here, please open an issue or discussion on GitHub.

Xonsh exposes itself as a Python library in addition to the standalone xonsh command. Third-party projects can create a xonsh session, load xontribs and rc files, and drive the execer / shell from their own code. The public entry point for this is xonsh.main.setup(), documented in xonsh.main.

Calling setup() Off the Main Thread

xonsh.main.setup() is safe to call from a non-main thread. Python disallows installing signal handlers from worker threads, so xonsh skips that step when it detects it is not on the main thread. Historically this path raised ValueError: signal only works in main thread during load (see xonsh#3689).

Consequences for embedders:

  • setup() completes normally from any thread; the execer, session, environment and aliases work as expected.

  • The atexit() history-flush registration still runs, so history is flushed on normal interpreter shutdown.

  • Termination-signal handlers that would flush history on SIGTERM, SIGHUP, SIGQUIT, SIGTSTP etc. are not installed in this case — the host process owns signals in embedded scenarios. If you need signal-driven flushes, call setup() once on the main thread at startup or install your own handlers there.

Controlling Terminal Handshake for Embedded Interactive Shells

When xonsh runs as the standalone xonsh command, its entry point (xonsh.main.main()) automatically performs a startup handshake that makes xonsh the foreground process group of its controlling terminal. This avoids a class of startup crashes and hangs in environments where the parent process does not arrange TTY ownership correctly — Flatpak / Bubblewrap sandboxes, build systems, nested containers, systemd --user services, some IDE terminals.

See Controlling Terminal and Foreground Process Group for the full rationale, the failure modes it fixes (BlockingIOError on asyncio wakeup pipe, termios.error EINTR, and related), the three-step signal policy, and the XONSH_NO_FG_TAKEOVER escape hatch.

When the handshake runs

The handshake is invoked automatically on both of these paths:

It is not invoked from xonsh.main.setup(). That means an embedded project that builds its own interactive shell by calling setup() (or by instantiating Shell, Execer and XonshSession directly) will not get the handshake for free. If your embedded xonsh runs in one of the affected environments, you need to invoke it yourself.

How to invoke it from embedded code

The helper currently lives in xonsh.main as a module-private function: _setup_controlling_terminal. Its signature and name may change in future releases, so wrap the call in try / except — or reach out to the xonsh maintainers if you need a stable public entry point for your embedding scenario.

Call it before you start your interactive shell loop — ideally as early in your program’s startup as possible, so that any xonshrc or xontrib code your embedder runs already has foreground ownership:

# embedded_launcher.py
from xonsh.main import setup

# Acquire foreground of controlling TTY (idempotent, safe to
# call multiple times — only the first call does real work).
# No-op on Windows, in non-TTY contexts (pytest, piped input,
# redirected stderr), and when XONSH_NO_FG_TAKEOVER=1 is set.
try:
    from xonsh.main import _setup_controlling_terminal
    _setup_controlling_terminal()
except Exception:
    pass

# Your existing xonsh setup stays unchanged.
setup(
    shell_type="prompt_toolkit",
    # ... your ctx, env, xontribs, aliases, etc.
)

# Your custom shell / REPL / agent loop starts here.

What the helper does

The call has three possible outcomes, all handled internally:

  1. Fast path — xonsh is already the foreground process group (standard case when launched from a well-behaved terminal). The function short-circuits, installs a Python no-op handler for SIGTTIN / SIGTTOU as a safety net, and returns.

  2. Full handshake — xonsh transfers TTY foreground ownership to its own process group via setpgid(0, 0) plus tcsetpgrp(tty, getpgrp()). Registers an atexit restorer so the previous foreground group is handed back on process exit.

  3. Sandbox fallback — if the handshake cannot complete (exotic sandbox, cross-session TTY, missing CAP_SYS_ADMIN for certain cases), the no-op handler installed in step 1 is replaced with SIG_IGN so that the kernel drops SIGTTIN / SIGTTOU at delivery time and they never reach Python’s asyncio wakeup pipe.

All outcomes are safe; every error path degrades cleanly. The helper never raises under normal use.

When you should not call it

  • Headless embedding. If your embedder uses setup(shell_type="none") (the default) and never starts an interactive prompt, you do not need the handshake. There is no TTY to acquire. Calling the helper in this case is harmless (it will fall into the non-TTY gate and do nothing), but it is also pointless.

  • You manage TTY / job control yourself. If your embedder deliberately keeps xonsh in the background of its controlling TTY — for example, you run xonsh under a PTY proxy that multiplexes multiple children — taking foreground ownership will break your design. Set XONSH_NO_FG_TAKEOVER=1 in the environment before starting xonsh, or simply skip the call.

  • You are running tests. Most test runners capture stderr, in which case the helper’s internal isatty gate makes the call a no-op. If you use pytest -s and really want the handshake disabled for tests, export XONSH_NO_FG_TAKEOVER=1 for the test session.