DEV Community

Cover image for PyInstaller silently kills every ConPTY child because it doesn't bundle OpenConsole.exe
Arsh Singh
Arsh Singh

Posted on

PyInstaller silently kills every ConPTY child because it doesn't bundle OpenConsole.exe

I wrote a terminal emulator in Python. It ran fine from source. After
packaging it with PyInstaller, every shell it opened exited the instant it
started. No exception, no log line, no dialog. This post is the explanation
I wish had existed when I went looking for one.

The setup

MultiTerm runs several Windows
shells side by side. Each pane is a child process attached to a
pseudo-console, which on Windows 10 and later means
ConPTY.
The spawning goes through pywinpty:

from winpty import PtyProcess
proc = PtyProcess.spawn(["cmd.exe"], cwd=cwd, env=env, dimensions=(rows, cols))
Enter fullscreen mode Exit fullscreen mode

With a normal Python install this works for cmd, PowerShell, Git Bash and
WSL.

The symptom

Build a one-file exe:

pyinstaller --onefile --windowed --name MultiTerm main.py
Enter fullscreen mode Exit fullscreen mode

Run it and every pane shows "process exited" before a prompt appears. The
exit code is 3221225786, which is 0xC000013A, which is
STATUS_CONTROL_C_EXIT. The shell is saying it was killed by Ctrl+C. Nobody
pressed Ctrl+C.

Every shell fails the same way, so the shell is not the problem. It fails
before the first prompt, so it is not something the user did. And it only
fails in the packaged build, so packaging is the difference. That narrows it
down to "something PyInstaller left behind", which is still a lot of things.

What ConPTY actually is

ConPTY is not just a library call. CreatePseudoConsole starts a separate
console host process. That process owns the screen buffer, does the VT
translation, and sits between your program and the shell. The shell is a
child of the console host, not of you.

pywinpty does not rely on the console host built into Windows. Next to its
extension module it ships conpty.dll and OpenConsole.exe, the
open-source console host from the Windows Terminal project, so that ConPTY
behaves the same on every Windows 10 build instead of depending on whatever
conhost.exe the OS happens to have. You can see this in the DLL itself:
its string table contains both \conhost.exe and OpenConsole.exe. The
chain when a pane opens is

your app  ->  conpty.dll  ->  OpenConsole.exe  ->  cmd.exe
Enter fullscreen mode Exit fullscreen mode

Take OpenConsole.exe away and the DLL still returns a process handle, so
the Python side sees a successful spawn. The shell then finds itself with no
console host behind it and exits. A Windows console process that loses its
console exits with STATUS_CONTROL_C_EXIT, the same code as an unhandled
Ctrl+C, which is why the exit code points you at the wrong thing. That last
step is my reading of the behaviour, not something I traced through the
kernel, but it matches what you see.

Why PyInstaller misses it

PyInstaller finds binary dependencies by reading DLL import tables. It sees
that the winpty extension imports conpty.dll and winpty.dll and
bundles them. It cannot see that conpty.dll will later CreateProcess an
exe sitting next to it, because that is a string inside the DLL, not an
import. So the two DLLs travel and the two exes (OpenConsole.exe, and
winpty-agent.exe for the older winpty backend) stay in site-packages.

There is no hook for winpty in pyinstaller-hooks-contrib as of the
version I have (2026.7), so nothing fills the gap for you.

From source, the whole winpty folder is present and the exes are found.
Packaged, they are not. Same code, different neighbours.

The fix

Add the helpers yourself. This is the build script MultiTerm ships,
trimmed:

import os, subprocess, sys

HELPERS = ("OpenConsole.exe", "winpty-agent.exe", "conpty.dll", "winpty.dll")

def winpty_dir():
    import winpty
    return os.path.dirname(os.path.abspath(winpty.__file__))

def main():
    wp = winpty_dir()
    cmd = [sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean",
           "--onefile", "--windowed", "--name", "MultiTerm",
           "--hidden-import", "winpty"]
    for helper in HELPERS:
        path = os.path.join(wp, helper)
        if os.path.isfile(path):
            cmd += ["--add-binary", "%s;winpty" % path]
    cmd.append("main.py")
    return subprocess.call(cmd)
Enter fullscreen mode Exit fullscreen mode

The line that matters is --add-binary <path>;winpty. The destination has
to be the winpty folder so the exe lands next to conpty.dll inside the
extracted bundle, which is where the DLL looks. Drop it at the bundle root
and the shells die exactly as before.

In a spec file, the equivalent is listing the four paths in binaries=[...]
with 'winpty' as the destination.

A second problem you hit right after

With the console host in place, cmd and PowerShell work. Git Bash still
dies. Different cause.

PyInstaller's one-file bootloader extracts everything to a temp folder,
puts that folder at the front of PATH, and exports _MEIPASS2 and a few
_PYI_* variables so the frozen interpreter can find itself. A child shell
inherits all of it. Git Bash then resolves some of its own DLLs to the
bundled copies at the front of PATH and crashes on load.

Strip that out before spawning anything:

def child_env():
    env = dict(os.environ)
    bundle = getattr(sys, "_MEIPASS", None)
    if bundle:
        norm = os.path.normcase(os.path.abspath(bundle))
        env["PATH"] = os.pathsep.join(
            p for p in env["PATH"].split(os.pathsep)
            if p and os.path.normcase(os.path.abspath(p)) != norm)
        for key in list(env):
            if key.startswith("_PYI") or key in ("_MEIPASS", "_MEIPASS2"):
                del env[key]
    return env
Enter fullscreen mode Exit fullscreen mode

This applies to any frozen Python program that launches child processes,
not only terminals.

How to recognise it

  • Works from source, fails packaged.
  • Child exit code 3221225786 (0xC000013A).
  • PtyProcess.spawn raised nothing.
  • While the packaged app is running, dir %TEMP%\_MEI*\winpty shows two DLLs and no exes.

If all four match, add the binaries and move on with your day.


MultiTerm is a free, MIT-licensed multi-pane terminal for Windows with
workspaces that remember their folders and startup commands, and broadcast
typing across panes. The full build script is
tools/build_exe.py.

Top comments (0)