DETACHED_PROCESS is why your background job pops console windows

5 min read WindowsPythonAutomation

A Python git hook flashed a black console window on every commit. The flag meant to hide it was causing it, but not in the way everyone says: DETACHED_PROCESS opens no window itself. It leaves the child with no console, so every console program that child runs gets a fresh visible one.

TL;DR · THE FIX

DETACHED_PROCESS gives the child no console at all. On its own that is invisible. The cost lands one level down: a process with no console that spawns git, ffmpeg or any console exe makes Windows allocate a new console for it, and that one has a visible window. CREATE_NO_WINDOW (0x08000000) gives the child a console with no window, which grandchildren inherit, so the whole tree stays silent.

The symptom

A black console window appeared on my desktop every time I committed. It flashed up, stole focus, and vanished, and the same happened on every branch switch. On a busy day it was a hundred windows.

The culprit was a post-commit hook that kicked off a background indexing job in Python. Nobody asked to watch that job run, and the Popen call already carried the flag I assumed kept it invisible:

subprocess.Popen(
    [sys.executable, "-m", "reindex", repo],
    creationflags=0x8,          # DETACHED_PROCESS
)

It reads like exactly what you want for a fire-and-forget background job.

What I tried first

I assumed whatever launched the hook was creating the console: the shell, the editor, the tool that ran the commit. The windows correlated with commits and plenty of things touch a commit, so I spent real time trying to make those parents quieter.

Guessing could not settle it, so I counted windows instead. This poller samples the desktop every 10ms and reports visible console windows:

import ctypes, ctypes.wintypes as w, time

u = ctypes.windll.user32
CB = ctypes.WINFUNCTYPE(w.BOOL, w.HWND, w.LPARAM)

def console_windows():
    found = []
    def cb(hwnd, _):
        buf = ctypes.create_unicode_buffer(64)
        u.GetClassNameW(hwnd, buf, 64)
        if buf.value == "ConsoleWindowClass" and u.IsWindowVisible(hwnd):
            found.append(hwnd)
        return True
    u.EnumWindows(CB(cb), 0)
    return found

Wrap a Popen in it, diff the window set before and after, and you have a number instead of an argument.

The first number surprised me. A child launched with DETACHED_PROCESS that just sleeps, or prints and sleeps, produces zero windows. The popular one-line version of this bug, “DETACHED_PROCESS opens a console window”, is not what happens.

One caveat before you run this yourself: the test is only meaningful from a process that owns a real console window. From a redirected or headless parent every case reads 0, including the broken one.

What was happening

DETACHED_PROCESS means the new process does not inherit the parent’s console and does not get one of its own. On its own that is silent, which is why it survives code review. The cost arrives one level down.

A console program needs a console. When a process with no console starts an ordinary console executable, Windows allocates a new console for it, and a freshly allocated console comes with a visible window. So the moment your detached background job shells out to git, ffmpeg, node, or anything else console-subsystem, that grandchild gets its own window on your desktop, and a job that shells out in a loop flashes one per call. The window belonged to the job’s children, which is why it only appeared when the job did something.

Measured from a parent owning a real console, where the child spawns one console grandchild (cmd /c ping -n 2 127.0.0.1):

child launched withchild alonechild that spawns a console grandchild
no flags0 windows0 windows
DETACHED_PROCESS (0x8)0 windows1 window
CREATE_NO_WINDOW (0x08000000)0 windows0 windows

The baseline row is the tell. With no flags the child inherits the parent’s console, so the grandchild inherits it too and there is nothing to allocate. DETACHED_PROCESS breaks that chain, and the next console program down has to start a console from scratch.

CREATE_NO_WINDOW is documented as running a console application without a console window. The difference that matters is that the child still has a console, with no window, and that state is inherited. Grandchildren attach to the same windowless console and stay invisible, which is why one flag fixes the whole process tree.

Combining them is a trap. Microsoft’s process creation flags documentation states that CREATE_NO_WINDOW is ignored when used with CREATE_NEW_CONSOLE or DETACHED_PROCESS, so the belt-and-braces move of passing both leaves you where you started, with a flag in your source that says otherwise.

The fix

One value:

import subprocess, sys

subprocess.Popen(
    [sys.executable, "-m", "reindex", repo],
    creationflags=subprocess.CREATE_NO_WINDOW,   # 0x08000000
)

subprocess.CREATE_NO_WINDOW has been in the standard library since Python 3.7, so you never need the raw hex. The job still runs in the background as before, and the windows are gone, including the ones from everything it shells out to.

Scheduled tasks are the same symptom with a different fix. A Windows scheduled task running a console program under LogonType=Interactive gets a real console window on the interactive desktop, and no process-creation flag helps because you are not the one calling CreateProcess. Two popular answers do not work here. -WindowStyle Hidden on a PowerShell action still flashed windows when I measured it. conhost.exe --headless does hide the window but reports success regardless: a script that exited 42 came back as LastTaskResult=0, which silently breaks anything watching for failed tasks. What worked was launching through a small GUI-subsystem VBScript shim, because WScript.Shell.Run(cmd, 0, True) starts the child hidden and still returns its real exit code:

' hidden.vbs   usage:  wscript.exe //nologo hidden.vbs <exe> <arg> <arg> ...
Option Explicit
Dim sh, cmd, i, a
Set sh = CreateObject("WScript.Shell")
cmd = ""
For i = 0 To WScript.Arguments.Count - 1
  a = WScript.Arguments(i)
  ' quote only what needs it, or you break flags like -NoProfile
  If InStr(a, " ") > 0 And Left(a, 1) <> """" Then a = """" & a & """"
  If i > 0 Then cmd = cmd & " "
  cmd = cmd & a
Next
If cmd = "" Then WScript.Quit 2
' 0 = hidden, True = wait, so the task records the child's real exit code.
WScript.Quit sh.Run(cmd, 0, True)

Point the task’s action at wscript.exe with //nologo <path>\hidden.vbs <your exe> <args>, and the window is gone while LastTaskResult still tells you the truth.

The lesson

DETACHED_PROCESS is about console inheritance and CREATE_NO_WINDOW is about the window. For a silent background child you want the second, alone.

The flag that caused the mess produced no window in isolation and only misbehaved through what it changed for the processes underneath it. My first explanation was about the parent, the real answer was two levels the other way, and fifteen lines of counting settled in one run what several rounds of theorising had not.

Related fixes

Discussion

Powered by GitHub. Sign in to leave a comment.