Hi Carlos Garcia,
Thank you for posting your question.
The first thing I’d separate is the worker’s termination from the job’s accounting state. Your successful wait on the worker’s process handle establishes that the worker terminated. The remaining question is why the job still reports an associated process.
There is a documented case that can produce misleading-looking accounting: an attempted job association that fails because of a limit violation. Microsoft states that this increments TotalProcesses and temporarily increments ActiveProcesses. The latter is decremented after the terminated process exits and all references to it are released. Closing your own process and thread handles does not necessarily release references held elsewhere. That makes a rejected second process a plausible explanation, although it does not establish that this is what happened here. JOBOBJECT_BASIC_ACCOUNTING_INFORMATION
TotalTerminatedProcesses = 0 also deserves attention. That field counts terminations caused by limit violations, so I would not consider the rejected-process explanation confirmed without more evidence. A one-active-process limit does not impose a lifetime limit of one process, either.
The different PID is the most useful clue. Assuming the job handle and structure parsing are correct, references to worker PID 11128 cannot explain an entry identifying PID 1416. You need to identify that process, ideally while reproducing the issue. Open a process handle, record its executable path and creation time, check membership using IsProcessInJob with your retained job handle, and check whether the process handle is signaled. A later lookup by PID alone is vulnerable to PID reuse, and an unsuccessful OpenProcess call does not prove termination. IsProcessInJob
Nested jobs could matter because both accounting and the PID list include descendant jobs. However, an inherited outer job does not automatically bring its other processes into your private child job. Membership and accounting flow through the hierarchy in specific directions; nesting alone does not explain an unrelated process appearing in your job. Also, disabling handle inheritance does not disable inheritance of job membership. Nested Jobs, Job Objects
For troubleshooting, I would focus on a few checks:
- Read back the limits and verify that the job is empty before creating the worker.
- Query membership while the worker is still suspended, then again after resumption and termination. This should help locate when the second process appears.
- Verify the exact executable being launched. CPython’s Windows virtual-environment launcher creates another interpreter process, so “one Python worker” does not always mean one Windows process. This is worth checking, although it does not by itself explain your complete result.
- If using
ctypes, verify handle types, structure offsets, and attribute-buffer lifetimes. The reported buffer sizes are consistent with x64, but that alone does not validate the binding. - Associate a completion port before adding processes and record process and limit notifications. Treat these as supporting diagnostics: Microsoft does not guarantee delivery of ordinary job notifications. Completion-port documentation
For termination scope, TerminateJobObject affects the selected job and all its descendant jobs. KILL_ON_JOB_CLOSE applies the corresponding scope when the last job handle closes. A PID snapshot cannot guarantee that membership remains unchanged before termination. The stronger approach is to establish ownership by construction: a trusted supervisor, a private job per workload, controlled assignment and handle access, and an explicit policy covering permitted descendants. TerminateJobObject
One practical consequence is that, with kill-on-close already enabled, closing the last handle—or exiting the process that owns it—can trigger termination even if you never call TerminateJobObject. That needs to be part of the failure-handling design.
If you cannot establish an acceptable termination scope in the current environment, I would move the supervisor and workload into a dedicated disposable VM and use that as the teardown boundary.
Your conservative handling makes sense. I would describe the current result as “worker terminated successfully; job membership and cleanup remain unresolved.” The logs support that distinction, but they do not yet establish the cause of the remaining entry.
If you found my investigation and direction of my support helpful, I’d really appreciate it if you could follow this guide so others with similar issue can benefit as well.
Thank you.