I am debugging a problem in a build system which is essentially doing the following:
- A parent spawns a child process.
- The child process creates a file and exits without closing the handle.
- The parent waits on JOB_OBJECT_MSG_EXIT_PROCESS for the child to exit.
- The parent attempts to open the file created by the child. This fails with ERROR_SHARING_VIOLATION.
Windows Performance Analyzer tracing shows that the open attempt occurs after the child process lifetime has ended yet while file cleanup is still ongoing.
In this zombie state the process handle is not yet signaled and blocking with WaitForSingleObject resolves the race.
The behavior is consistent across several Windows 10/11 systems, including with antivirus disabled. This is surprising to me and seems to limit the value of JOB_OBJECT_MSG_EXIT_PROCESS notifications.
Does Windows have a process state where processes have exited with API-visible side effects still ongoing? If so, is waiting for the process handle to be signaled required to reliably wait for this zombie state to finish?
(Waiting for JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO also appears to be sufficient yet is not useful in this application.)
/**
* Repro case demonstrating resources held after JOB_OBJECT_MSG_EXIT_PROCESS
*/
#include <stdio.h>
#include <windows.h>
// Test condition and panic out after tracing the offending line number on error
#define CHECK(cond) (check_at((cond), __LINE__))
static void check_at(_Bool cond, unsigned int line) {
if(!cond) {
fprintf(stderr, "%s:%u: failed\n", __FILE__, line);
ExitProcess(1);
}
}
// Operate as child process create the file and leave cleaning up to the OS
static void child(const WCHAR filename[]) {
HANDLE handle = CreateFileW(filename, GENERIC_WRITE, FILE_SHARE_READ, NULL,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
CHECK(handle != INVALID_HANDLE_VALUE);
static const char payload[0x10000 /* ≥5 bytes required */];
DWORD written;
CHECK(WriteFile(handle, payload, sizeof payload, &written, NULL));
CHECK(written == sizeof payload);
}
// Operate as parent executing the child and waiting for completion before
// accessing the file
static const char *parent(const WCHAR filename[], WCHAR app_name[], BOOL poll) {
// Use job object reporting status to an I/O completion port to wait for exit.
// Plain WaitForSingleObject on the child fails to reproduce the issue
HANDLE job = CreateJobObjectW(NULL, NULL);
CHECK(job);
HANDLE iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
CHECK(iocp);
JOBOBJECT_ASSOCIATE_COMPLETION_PORT assoc = { .CompletionPort = iocp };
CHECK(SetInformationJobObject(job,
JobObjectAssociateCompletionPortInformation, &assoc, sizeof assoc));
// Associate job with queue immediately from startup (Windows ≥10 required)
STARTUPINFOEXW si = { .StartupInfo.cb = sizeof si };
size_t space = 0;
while(!InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, &space))
si.lpAttributeList = _alloca(space);
DWORD proc_attrib = 0x0002000DU /* PROC_THREAD_ATTRIBUTE_JOB_LIST */;
CHECK(UpdateProcThreadAttribute(si.lpAttributeList, 0, proc_attrib, &job,
sizeof job, NULL, NULL));
// Pass a second dummy command line argument to trigger child behavior
WCHAR command_line[] = L"dummy recurse";
PROCESS_INFORMATION pi;
CHECK(CreateProcessW(app_name, command_line, NULL, NULL, TRUE,
EXTENDED_STARTUPINFO_PRESENT, NULL, NULL, &si.StartupInfo, &pi));
// Wait for successful process to exit
DWORD event;
OVERLAPPED *overlapped;
do {
ULONG_PTR key;
CHECK(GetQueuedCompletionStatus(iocp, &event, &key, &overlapped, INFINITE));
} while(event != JOB_OBJECT_MSG_EXIT_PROCESS &&
event != JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS);
CHECK((ULONG_PTR) overlapped == pi.dwProcessId);
// Poll and verify yet to be signaled or block until signaled
if(poll)
CHECK(WaitForSingleObject(pi.hProcess, 0) != WAIT_OBJECT_0);
else
CHECK(WaitForSingleObject(pi.hProcess, INFINITE) == WAIT_OBJECT_0);
// Verify successful exit
DWORD exit_code;
CHECK(GetExitCodeProcess(pi.hProcess, &exit_code));
CHECK(exit_code == 0);
// Try to read the expected output file
HANDLE handle = CreateFileW(filename, GENERIC_READ, FILE_SHARE_READ, NULL,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if(handle != INVALID_HANDLE_VALUE) {
CHECK(CloseHandle(handle));
return "PASS";
} else {
CHECK(GetLastError() == ERROR_SHARING_VIOLATION);
return "FAIL";
}
}
int wmain(int argc, WCHAR *argv[]) {
const WCHAR *filename = L"shared_file";
if(argc == 1) {
printf("polling: %s\n", parent(filename, argv[0], TRUE));
printf("blocking: %s\n", parent(filename, argv[0], FALSE));
} else {
child(filename);
}
return 0;
}
Guess #1: Maybe the file isn't closed and released until the child process is cleaned up. Even though the child process has completed, the parent process still holds an open handle. (The
PROCESS_INFORMATIONstructure filled out in the call toCreateProcesshas handles to the child process and its primary thread.) Have the parent close the child process handles before attempting to open the file.Guess #2: Anti-malware software saw the creation of a new file and opened it with a lock until it scans it.