Dev Investigation Notes

When you launch one Python program but Task Manager shows two pythonw.exe processes — identifying Windows venv launcher stubs

This is a translated article. Read the original in Korean ↗

I was building a personal voice assistant (voice-assistant) that stays running on Windows, transcribes speech when it hears a wake word, gets an answer from the Claude Code CLI, and reads it aloud. It runs in the background without a window using the venv's (virtual environment's) pythonw.exe. When the assistant stopped answering, I checked the processes and found two pythonw.exe processes each for the assistant itself and the dashboard running alongside it . I suspected duplicate instances, but the two processes were a parent and child belonging to a single program . (This article covers "why there are two" and "how to tell whether they're actually duplicates." The real reason the assistant stopped answering was never established; the limits of that investigation are described at the end of ## Cause.)

Conclusion

In a Windows venv, two pythonw.exe (or python.exe) processes are normal if they have a parent-child relationship and different executable paths. The venv's Scripts\pythonw.exe is a launcher stub, not the actual interpreter. It starts the original installed Python as a child process and waits for it to exit. That's why one script shows up as two processes.

To tell them apart, check these three things together.

  1. Parent PID — If one process's ParentProcessId matches the other's ProcessId, they form one set
  2. Executable path — The parent is ...\venv\Scripts\pythonw.exe; the child is in the original Python installation used to create the venv
  3. CPU usage — The parent (stub) stays near 0; the child does the actual work. In this case, the parent was at 0 and the child at 79.9

Paste the following directly into PowerShell to see items 1 and 2 together. If your program runs with python.exe, replace Name='pythonw.exe' with Name='python.exe'.

Get-CimInstance Win32_Process -Filter "Name='pythonw.exe'" |
  Select-Object ProcessId, ParentProcessId, ExecutablePath, CommandLine

CPU usage, item 3, isn't included in that output, so check it separately. The following command queries performance counters that measure CPU usage per process. PercentProcessorTime treats one core as 100, so it can exceed 100 on a multicore system. The values fluctuate from moment to moment, so run it two or three times. (For python.exe, change the filter to LIKE 'python%'.)

Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -Filter "Name LIKE 'pythonw%'" |
  Select-Object IDProcess, PercentProcessorTime

Match IDProcess against ProcessId from the previous command. You can also see the same information by enabling the PID and CPU columns on Task Manager's Details tab.

A real duplicate means two independent sets (four processes). With a Windows mutex, exit if GetLastError() returns 183 after calling CreateMutexW(None, False, "고유이름") (고유이름 means "unique name"; see item 3 under ## Fix). This assistant's second launch also logged "이미 실행 중" ("already running") and exited .

When stopping it, don't pick a single PID. Narrow the target by its command line (CommandLine) and stop both the stub and its child . The procedure for stopping them safely is in item 2 under ## Fix.

Situation

The voice assistant is a Python program that stays running on the PC. It waits for a wake word, transcribes speech, and gets an answer by calling the claude -p CLI from Python. That CLI call runs as a node.exe process .

The startup chain is a shortcut in the Startup folder → a hidden VBS script → a batch file → the venv's pythonw.exe . The venv was created from a Python 3.14 distribution . A web dashboard server runs separately in the background using the same approach.

Symptom

It was natural to suspect that two instances were competing for the microphone or CLI.

Cause

Checking the parent PIDs of the two pythonw.exe processes showed that the assistant's PID 2724 had PID 33660 as its parent, and 33660 was the venv's launcher stub . The parent was at CPU 0; only child process 2724 was doing actual work, at CPU 79.9 . This was one program appearing as two processes, not a duplicate launch.

This structure comes from a change in CPython. Windows venvs used to copy python.exe, but later switched to copying a redirector (a modified py.exe launcher) that launches the original interpreter on their behalf. These launchers live in the original installation as venvlauncher.exe / venvwlauncher.exe and are copied into the venv as python.exe / pythonw.exe when it is created (this is general knowledge about CPython's behavior, not part of the session record). Because the names are identical, Task Manager makes it look as though the same program is running twice.

The same illusion was confirmed again later. After moving the assistant to Task Scheduler, the pythonw count was 2 even with only one instance running . After directly launching a second instance to test whether the mutex blocked it, the count remained 2 . In other words, "two pythonw processes" is the normal baseline.

The real reason the assistant stopped answering was not these two processes. Running claude -p directly returned a valid response in 6.2 seconds , narrowing the problem to child process 2724, which was stuck doing something at 79.9% CPU. At the time, that process had no live claude -p (node) children beneath it . Earlier logs also showed repeated audio crashes (no driver installed, MME error 6) and claude CLI 호출 실패 ("claude CLI call failed") messages . What was occupying the CPU was never established during this investigation. So this article does not provide a fix for the missing responses; its conclusion stops at "seeing two processes is not the cause."

Fix

1. Identify duplicates by their relationships, not their count. Use the Get-CimInstance command under ## Conclusion to inspect ParentProcessId and ExecutablePath. One parent-child pair is normal. It takes two unrelated pairs to constitute a real duplicate.

2. Before restarting, inspect the targets and stop only the matching processes. Run all the commands below directly at a PowerShell prompt. They won't run unchanged in cmd, which interprets quotes and $_ differently.

First, inspect what the filter matches without stopping anything. '*src.main*' was a string in this assistant's command line . For your program, replace it with a string that appears only in your program's command line and no other app's. Pick one from the earlier CommandLine output. A common module name such as src.main can also match other projects using the same name, so narrow it by including the project folder path, as in '*내프로젝트폴더*src.main*' (내프로젝트폴더 means "my project folder").

$pattern = '*src.main*'   # Replace with a string that matches only your program
Get-CimInstance Win32_Process -Filter "Name='pythonw.exe'" |
  Where-Object { $_.CommandLine -like $pattern } |
  Select-Object ProcessId, ParentProcessId, ExecutablePath, CommandLine

Proceed only if the output contains exactly two rows—the stub and child, linked as parent and child—and the paths belong to your program. If another project appears, narrow $pattern further. Once you've checked, use the same filter to stop them in the same PowerShell window, with $pattern still defined.

Get-CimInstance Win32_Process -Filter "Name='pythonw.exe'" |
  Where-Object { $_.CommandLine -like $pattern } |
  ForEach-Object { Stop-Process -Id $_.ProcessId -Force }

Then launch the program again. This assistant used wscript.exe to run the hidden VBS script in the Startup folder, but that was specific to this case. Use the command you normally use to launch your program.

Start-Sleep 2
# Example: & '<venv 경로>\Scripts\pythonw.exe' -m src.main   ← Replace with your launch command

Here, <venv 경로> means "<venv path>".

The assistant's actual restart command combined these steps into a single powershell -Command "..." invocation, ending with wscript.exe '<경로>\start_assistant_hidden.vbs' (<경로> means "<path>"). At the time, a protection policy prevented the AI tool from terminating processes on its own, so the command was handed over to be run manually . The goal from the outset was to "stop both the launcher stub and the worker child, then launch them again" .

Stopping processes by name alone (pythonw.exe) would also kill the dashboard. That's why you need to filter by a command-line string.

3. To prevent real duplicates, add a single-instance guard inside the app. This assistant added a Windows mutex. If an instance is already running, it logs the reason and exits on its own . This works alongside Task Scheduler's "이미 실행 중이면 새 실행 무시" ("ignore a new launch if already running") setting, but the guard was tested separately with a direct launch that bypassed the scheduler, rather than relying solely on the scheduler setting .

Below is a minimal example using the same approach (for illustration, not the assistant's actual code). There are three things to decide when writing the guard.

One more thing: print output is invisible under pythonw. Without a console, standard output doesn't appear anywhere, so write the exit reason to a log file.

import ctypes, sys, logging

logging.basicConfig(filename="assistant.log", level=logging.INFO)  # Use your log file path

ERROR_ALREADY_EXISTS = 183
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateMutexW.restype = ctypes.c_void_p

# Keep the handle in a module global so the mutex lasts for the lifetime of the process
_mutex = kernel32.CreateMutexW(None, False, "Local\\MyAssistantSingleInstance")
if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
    logging.info("이미 실행 중인 인스턴스가 있어 종료함")
    sys.exit(0)

# --- Heavy initialization starts here (loading models, opening devices, etc.) ---

이미 실행 중인 인스턴스가 있어 종료함 means "Exiting because an instance is already running."

The mutex is created inside the child process that runs the actual code, not the stub. The stub therefore doesn't cause the guard to run twice.

4. Verify that the guard works. Follow this sequence.

  1. Launch one instance as usual and note the current end of the log file (its line count).
  2. Bypass the scheduler and shortcut, and run the launch command from the restart example directly in PowerShell one more time. You must launch it directly to distinguish whether the guard blocked it or the scheduler's "중복 실행 무시" ("ignore duplicate launches") setting did it instead .
  3. Check whether a new "이미 실행 중인 인스턴스가 있어 종료함" ("Exiting because an instance is already running") line appears in the log. If it does, the guard worked. This assistant's second instance also logged that message and exited immediately .
  4. Run the earlier Get-CimInstance query again. There should still be one stub-child pair (two processes). This assistant's count also remained at 2 after the second launch .

Bonus — counting by name gets other things wrong, too

During the same investigation, 13 claude.exe processes were suspected of being zombies left behind by the assistant, but they belonged to the Claude desktop app and the claude-code session being used for debugging. The assistant's claude -p calls run as node.exe . Counting them by name and stopping them all would have cut off the debugging tools as well. Before stopping anything, walk up the parent chain to see whose child it is.

Reporter's Notes — what looked like two was one program

Park Doeun
You know that voice assistant I keep running on Windows? It transcribes what I say after the wake word, gets an answer from Claude CLI, and reads it out. Today it just keeps playing the waiting sound without answering. The log stops at 호출어 감지됨 ("wake word detected"), too. It heard me, but it can't get to the next step
Jung Bada
Have you checked Task Manager? There are two pythonw.exe processes for the assistant and two for the dashboard. Could two instances be competing for the microphone or CLI calls?
Jung Haneul
Two processes with the same name don't necessarily mean duplicate instances. Check their parent PIDs—who launched whom—along with CPU usage, and you can tell.

We pulled the parent PID and CPU columns from the process list.

Park Doeun
Got it. The parent of the assistant's PID 2724 is 33660, and 33660 is the venv launcher. 33660 is at CPU 0, and 2724 is doing all the work by itself at 79.9
Jung Haneul
Then it's not a duplicate. The venv's pythonw.exe is a launcher stub: a small executable that launches the real Python and just waits alongside it. Those two belong to one program
Jung Bada
Then here's something else suspicious. There are 13 claude.exe processes. Could the assistant have called the CLI and failed to clean them up, leaving zombies?
Jung Haneul
Let's trace their parents, too. If the assistant launched them, there should be a pythonw ancestor.
Park Doeun
There isn't. They all belong to the Claude desktop app and the claude-code session we're using to debug right now. The assistant's claude -p runs as node.exe, and right now there isn't a single one of those node.exe processes
Park Dohyun
If we'd killed all 13 based on the name alone, we'd have cut off our own tools first. So now I'm wondering whether it's the CLI itself. There were failures in the log, right?
Jung Bada
Yes, there were several claude CLI 호출 실패 ("claude CLI call failed") messages. Could the CLI be broken?
Jung Haneul
We can find out by running claude -p directly without the assistant. If that fails too, it's a CLI problem. If it works, it's on the assistant's side.
Park Doeun
Running it directly gave me a valid answer in 6.2 seconds
Jung Haneul
The CLI is fine. We've narrowed it down to 2724, which is stuck at CPU 79.9 without even launching a CLI child. We still don't know what it's stuck on
Jung Bada
Then we can just kill 2724 and launch it again.
Park Dohyun
If you pick just one PID to stop, it's easy to lose track of whether the stub or child is left behind. If they belong to one program, we should stop them together.
Jung Haneul
That's why we filter by command line, not name. Selecting only pythonw processes containing src.main lets us stop the assistant's stub and child together without touching the dashboard. Before stopping them, query the filter to check exactly what it matches

A few days later, we moved the assistant to Task Scheduler and added duplicate-instance protection.

Park Doeun
I launched it again and counted: one instance still means 2 pythonw processes. When I forced a second launch, it logged "이미 실행 중" ("already running") and exited immediately. The count stayed at 2
Jung Haneul
The mutex—a named lock that lets only the first process to acquire it stay running—is working properly. And we've confirmed that "two pythonw processes" is the normal baseline on this PC
Park Dohyun
The process count isn't evidence. Judge duplicates by the parent relationships, stop the whole program by filtering its command line, and prevent real duplicates with a lock inside the app.