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
- Keep the job handle open for the entire supervision and drain sequence.
- Disable both breakaway modes, as already done. Ordinary descendants created with
CreateProcessthen remain associated with the job by default. This lets the job represent the process tree, subject to the documented exception thatWin32_Process.Createchildren are not inherited into the job.^1^ - 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 requiresJOB_OBJECT_TERMINATE, and termination is asynchronous in the same sense as terminating each associated process.^2^ - Poll
QueryInformationJobObjectwithJobObjectBasicAccountingInformation. - 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^ - 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, andACTIVE_PROCESS_ZEROare 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
OpenProcessbefore discoverability is lost.OpenProcesscan 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 == 0for 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
- Job Objects
- TerminateJobObject function (jobapi2.h)
- JOBOBJECT_BASIC_ACCOUNTING_INFORMATION structure (winnt.h)
- Synchronization Objects
- TerminateProcess function (processthreadsapi.h)
- JOBOBJECT_ASSOCIATE_COMPLETION_PORT structure (winnt.h)
- Process Handles and Identifiers
- Handle Inheritance
- JOBOBJECT_END_OF_JOB_TIME_INFORMATION structure (winnt.h)