Supported barrier for inherited job processes after PID-list removal

Patrick Roebuck 0 Reputation points
2026-10-16T17:14:22.5333333+00:00

Which supported Win32 mechanism lets a non-administrator supervisor establish that every ordinary process inherited into its private job has reached the signaled process-object state after termination, including a process that is no longer returned by the job PID list?

The supervisor owns the job and the initially launched process, disables breakaway, and must support ordinary child and descendant creation. It can report an uncertain stop when its deadline expires; it does not require Windows to guarantee termination within a fixed time. The missing piece is a sound completion rule that also permits successful ordinary runs. We want to avoid launching the workload as a debugger or requiring cooperation from every child.

Our diagnostic retained a handle to one positively owned, live child before termination. After the supervisor returned, it made the following observations on that same retained process handle:

Ending Immediate process wait Wait before query Complete job PID list Wait after query
Parent exit WAIT_TIMEOUT WAIT_TIMEOUT Empty, assigned/listed 0/0, 8 bytes WAIT_TIMEOUT
Timeout WAIT_TIMEOUT WAIT_TIMEOUT Empty, assigned/listed 0/0, 8 bytes WAIT_TIMEOUT
Cancellation WAIT_TIMEOUT WAIT_TIMEOUT Empty, assigned/listed 0/0, 8 bytes WAIT_TIMEOUT

Each query was one successful QueryInformationJobObject call using JobObjectBasicProcessIdList. The process identity matched before and after. The diagnostic retained an additional noninheritable JOB_OBJECT_QUERY job handle until after the query, so these results are specific to that handle lifetime. They are not a replay with production-identical handle closure. Class-1 ActiveProcesses was not recorded. We are not claiming a Windows bug or identifying the kernel cause.

The public test source at the measured commit and failed supplemental job, including retained XML and environment details contain the observation. That run used Python 3.10.11. The separate ordinary Python 3.10 and 3.12 jobs passed; they do not remove this counterexample.

The PID-list contract describes the current population and buffer completeness. The termination contract requires a process-handle wait to establish completed termination. The job completion-port contract does not guarantee delivery of ordinary new/exit process notifications. We therefore cannot infer full historical handle coverage from those notifications alone.

Is there a documented job-level completion barrier, or a supported way to retain identity-bound handles for every inherited process before current-list discoverability is lost? If those APIs provide a different ordering guarantee that resolves this case, which contract states it? A pointer to the supported ownership/completion pattern would help us implement and test the correction.

Windows development | Windows API - Win32
0 comments No comments

1 answer

Sort by: Newest
  1. AI answer

    2026-10-16T17:15:42.83+00:00

    There is no general job-handle wait that means “all processes have exited”; for this non-breakaway job, use JOBOBJECT_BASIC_ACCOUNTING_INFORMATION.ActiveProcesses == 0 as the documented job-level drain condition, with a deadline.

    Supported completion pattern

    1. Keep the job handle open for the entire supervision and drain sequence.
    2. Disable both breakaway modes, as already done. Ordinary descendants created with CreateProcess then remain associated with the job by default. This lets the job represent the process tree, subject to the documented exception that Win32_Process.Create children are not inherited into the job.^1^
    3. For a forced stop, call TerminateJobObject. It requests termination of every process currently associated with the job; for nested jobs, it includes child jobs. The call requires JOB_OBJECT_TERMINATE, and termination is asynchronous in the same sense as terminating each associated process.^2^
    4. Poll QueryInformationJobObject with JobObjectBasicAccountingInformation.
    5. Declare the job drained only when ActiveProcesses == 0. This field is the documented count of processes currently associated with the job. It is decremented when a process exits and relevant references are released; therefore, it can conservatively lag rather than proving completion too early.^3^
    6. If the deadline expires first, return the uncertain-stop result.
    bool WaitForJobDrain(HANDLE job, DWORD timeoutMs)
    {
        const ULONGLONG end = GetTickCount64() + timeoutMs;
    
        for (;;) {
            JOBOBJECT_BASIC_ACCOUNTING_INFORMATION info{};
            if (!QueryInformationJobObject(
                    job, JobObjectBasicAccountingInformation,
                    &info, sizeof(info), nullptr))
                return false; // report GetLastError separately
    
            if (info.ActiveProcesses == 0)
                return true;
    
            if (GetTickCount64() >= end)
                return false; // uncertain stop
    
            Sleep(10);
        }
    }
    

    This avoids repeating the PID-list test: JobObjectBasicProcessIdList answers a different question—the current PID population—whereas ActiveProcesses is the documented job accounting count.

    The same rule supports successful ordinary runs: after the initial process exits normally, continue monitoring the job until ActiveProcesses reaches zero. Do not equate parent exit or an empty PID list with tree completion.

    Why the other candidate barriers do not apply

    • Waiting on the job handle: ordinary transition to zero active processes does not signal the job. The documented signaled state is tied to all processes being terminated because the configured end-of-job time limit was exceeded.^4^ Therefore, WaitForSingleObject(job, ...) is not a general normal-or-forced drain barrier.
    • Waiting only on known process handles: this is conclusive for those specific processes. A process object becomes signaled at termination, and asynchronous termination must be followed by a wait when confirmation is required.^5^ It does not establish that handles were obtained for every descendant.
    • Completion-port process messages: NEW_PROCESS, EXIT_PROCESS, and ACTIVE_PROCESS_ZERO are useful hints, but ordinary job-message delivery is explicitly not guaranteed. PID-bearing messages also do not preserve identity unless an open process handle is maintained.^6^ Associate the port while the job is inactive to reduce races, but do not use its notification history as proof of complete handle coverage.
    • Opening every descendant by PID: no documented ordering guarantee shown by these APIs ensures that user mode receives each PID early enough to call OpenProcess before discoverability is lost. OpenProcess can turn a currently valid PID into a handle, but the access check still applies.^7^

    Identity-bound handles

    Win32 directly returns a full-access process handle only to the caller of CreateProcess; that handle remains valid after termination until closed.^7^ There is no documented job API that automatically deposits a handle for every inherited descendant into the supervisor.

    Obtaining guaranteed handles for all descendants therefore requires control or cooperation at creation time—for example, a launcher/broker that creates each process and transfers or duplicates its process handle. Handle inheritance alone cannot make an uncooperative descendant send its newly created child’s handle back to the supervisor; it only passes pre-existing handles from parent to child.^8^

    Consequently:

    • Use retained process-handle waits when per-process, identity-bound proof is required.
    • Use ActiveProcesses == 0 for supported job-wide drain detection.
    • Do not claim complete historical process-handle coverage from PID lists or completion-port notifications.
    • A deliberately triggered end-of-job time limit can create a signaled job state, but it is destructive and applies specifically to that limit-expiry path, so it is not a replacement for ordinary successful completion.^9^

    References

    1. Job Objects
    2. TerminateJobObject function (jobapi2.h)
    3. JOBOBJECT_BASIC_ACCOUNTING_INFORMATION structure (winnt.h)
    4. Synchronization Objects
    5. TerminateProcess function (processthreadsapi.h)
    6. JOBOBJECT_ASSOCIATE_COMPLETION_PORT structure (winnt.h)
    7. Process Handles and Identifiers
    8. Handle Inheritance
    9. JOBOBJECT_END_OF_JOB_TIME_INFORMATION structure (winnt.h)
    AI-generated content may be incorrect. Read our transparency notes for more information.

    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.