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.
- Parent PID — If one process's
ParentProcessIdmatches the other'sProcessId, they form one set - Executable path — The parent is
...\venv\Scripts\pythonw.exe; the child is in the original Python installation used to create the venv - 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
- After restarting the dashboard server, the assistant responded to the wake word but never answered, repeatedly playing its "처리 중" ("processing") sound effect instead
- The log stopped at
호출어 감지됨("wake word detected") - The process list showed two
pythonw.exeprocesses each for the assistant itself (src.main) and the dashboard
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.
- Placement: Put it in the program's entry file (the
src.mainmodule in this case), before expensive initialization with side effects, such as loading models or opening audio devices. The second instance must exit before it starts doing heavy work. - Name: Choose a unique string for each program.
MyAssistantSingleInstancein the example is a placeholder; replace it with something like내앱이름SingleInstance(내앱이름means "my app name"). Different programs with the same mutex name will block each other. - The
Local\prefix: This makes the name valid only within the same login session. That's enough for a personal program used by one user in one session. To block instances across other login sessions, such as remote sessions, useGlobal\, but creation may be denied depending on permissions, so that is outside this article's scope.
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.
- Launch one instance as usual and note the current end of the log file (its line count).
- 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 .
- 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 .
- Run the earlier
Get-CimInstancequery 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
호출어 감지됨 ("wake word detected"), too. It heard me, but it can't get to the next step pythonw.exe processes for the assistant and two for the dashboard. Could two instances be competing for the microphone or CLI calls? We pulled the parent PID and CPU columns from the process list.
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 claude.exe processes. Could the assistant have called the CLI and failed to clean them up, leaving zombies?pythonw ancestor.claude -p runs as node.exe, and right now there isn't a single one of those node.exe processes claude CLI 호출 실패 ("claude CLI call failed") messages. Could the CLI be broken? 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.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.
pythonw processes. When I forced a second launch, it logged "이미 실행 중" ("already running") and exited immediately. The count stayed at 2