Windows Job Object reports ActiveProcesses=1 after worker exit and an unexpected PID

Carlos Garcia 0 Reputation points
2026-10-12T18:07:09.4633333+00:00

I'm developing a paper/simulation-only Python research platform on Windows using CPython 3.13.15 and Win32 APIs.

I'm investigating unexpected Windows Job Object membership and how to establish safe process termination and resource cleanup.

Environment and setup

  • Windows, 64-bit, non-administrator session.

A fresh unnamed Job Object created using CreateJobObjectW.

One-active-process limit and JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE enabled.

A single intended worker created suspended using CreateProcessW, with PROC_THREAD_ATTRIBUTE_JOB_LIST.

Handle inheritance disabled; breakaway not enabled.

The worker communicates through named-pipe IPC.

Observed results

The original worker completed successfully:

WaitForSingleObject: WAIT_OBJECT_0

GetExitCodeProcess: 0

Strict IPC EOF verified.

No outstanding IPC operation recorded.

Original process and thread handles successfully closed.

However, QueryInformationJobObject with JobObjectBasicAccountingInformation returned:

API success: TRUE

Returned length: 48/48 bytes

TotalProcesses: 2

ActiveProcesses: 1

TotalTerminatedProcesses: 0

I also tested closing the parent-owned named-pipe server handle after verified EOF but before process/thread handle release. Both orderings still returned ActiveProcesses = 1.

A subsequent JobObjectBasicProcessIdList query using the same retained Job Object returned:

API success: TRUE

NumberOfAssignedProcesses: 1

NumberOfProcessIdsInList: 1

Returned length: 16 bytes

Returned PID: 1416

The intended worker PID was 11128 and the parent PID was 16148. Neither matched the returned PID.

These queries occurred at different times, so I cannot assume they represent an atomic snapshot or that the returned PID proves the process is still running.

Questions for Windows API experts

Under what documented conditions can a terminated worker still contribute to ActiveProcesses after its original process and thread handles have been closed?

What could explain TotalProcesses = 2 and a later PID-list result containing an identifier different from the intended worker?

Can failed job associations, nested jobs, or remaining kernel references explain these observations?

What is the recommended way to establish the scope of processes potentially affected by TerminateJobObject or KILL_ON_JOB_CLOSE?

If exclusive termination authority cannot be established, what safer containment architecture should be considered?

The application currently rejects the nonzero accounting result and does not treat the worker as safely quiescent.

I'm looking for documented Windows API semantics and architectural guidance, not a way to bypass containment or weaken the safety checks.

Thank you for any guidance or relevant Microsoft documentation.I'm developing a paper/simulation-only Python research platform on Windows using CPython 3.13.15 and Win32 APIs.

I'm investigating unexpected Windows Job Object membership and how to establish safe process termination and resource cleanup.

Environment and setup

Windows, 64-bit, non-administrator session.

A fresh unnamed Job Object created using CreateJobObjectW.

One-active-process limit and JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE enabled.

A single intended worker created suspended using CreateProcessW, with PROC_THREAD_ATTRIBUTE_JOB_LIST.

Handle inheritance disabled; breakaway not enabled.

The worker communicates through named-pipe IPC.

Observed results

The original worker completed successfully:

WaitForSingleObject: WAIT_OBJECT_0

GetExitCodeProcess: 0

Strict IPC EOF verified.

No outstanding IPC operation recorded.

Original process and thread handles successfully closed.

However, QueryInformationJobObject with JobObjectBasicAccountingInformation returned:

API success: TRUE

Returned length: 48/48 bytes

TotalProcesses: 2

ActiveProcesses: 1

TotalTerminatedProcesses: 0

I also tested closing the parent-owned named-pipe server handle after verified EOF but before process/thread handle release. Both orderings still returned ActiveProcesses = 1.

A subsequent JobObjectBasicProcessIdList query using the same retained Job Object returned:

API success: TRUE

NumberOfAssignedProcesses: 1

NumberOfProcessIdsInList: 1

Returned length: 16 bytes

Returned PID: 1416

The intended worker PID was 11128 and the parent PID was 16148. Neither matched the returned PID.

These queries occurred at different times, so I cannot assume they represent an atomic snapshot or that the returned PID proves the process is still running.

Questions for Windows API experts

Under what documented conditions can a terminated worker still contribute to ActiveProcesses after its original process and thread handles have been closed?

What could explain TotalProcesses = 2 and a later PID-list result containing an identifier different from the intended worker?

Can failed job associations, nested jobs, or remaining kernel references explain these observations?

What is the recommended way to establish the scope of processes potentially affected by TerminateJobObject or KILL_ON_JOB_CLOSE?

If exclusive termination authority cannot be established, what safer containment architecture should be considered?

The application currently rejects the nonzero accounting result and does not treat the worker as safely quiescent.

I'm looking for documented Windows API semantics and architectural guidance, not a way to bypass containment or weaken the safety checks.

Thank you for any guidance or relevant Microsoft documentation.

Windows development | Windows API - Win32

1 answer

Sort by: Most helpful
  1. Percy Nguyen (WICLOUD CORPORATION) 160 Reputation points Microsoft External Staff Moderator
    2026-10-13T02:41:00.3066667+00:00

    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. 

    Was this answer helpful?


Your answer

Answers can be marked as 'Accepted' by the question author and 'Recommended' by moderators, which helps users know the answer solved the author's problem.