Different behaviour about dwMilliseconds parameter in WaitForSingleObject

iamoon-6427 85 Reputation points
2026-09-27T16:10:58.8966667+00:00

Hi, I want to detect the changes in a directory asynchronously (with ReadDirectoryChangesExW). However by using WaitForSingleObject to receive its signal state, there is a different in the number of received events:

  • If WaitForSingleObject(ol.hEvent, INFINITE), the number of events is equal to the result of synchronous ReadDirectoryChangesExW
  • If WaitForSingleObject(ol.hEvent, 20), the number of events is less than the synchronous mode.

Code

Here is the code(watching dir "D:\test")

const char *ActionToStr(DWORD action) {
    switch (action) {
    case FILE_ACTION_ADDED:
        return "FILE_ACTION_ADDED";
        break;
    case FILE_ACTION_REMOVED:
        return "FILE_ACTION_REMOVED";
        break;
    case FILE_ACTION_MODIFIED:
        return "FILE_ACTION_MODIFIED";
        break;
    case FILE_ACTION_RENAMED_OLD_NAME:
        return "FILE_ACTION_RENAMED_OLD_NAME";
        break;
    case FILE_ACTION_RENAMED_NEW_NAME:
        return "FILE_ACTION_RENAMED_NEW_NAME";
        break;
    default:
        return "<UNKNOWN_ACTION>";
        break;
    }
}

int wmain() {
    constexpr DWORD SIZE_BUF = 32 * 1024;

    // Create handle of watching directory
    const TCHAR *watchPath = _T("D:\\test");
    HANDLE hDir = ::CreateFile(watchPath,
                               FILE_LIST_DIRECTORY,
                               FILE_SHARE_DELETE | FILE_SHARE_READ | FILE_SHARE_WRITE,
                               NULL,
                               OPEN_EXISTING,
                               FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED,
                               NULL);
    if (hDir == INVALID_HANDLE_VALUE) {
        printf("(%lu)CreateFile failed\n", GetLastError());
        return 1;
    }

    // Prepare things for [ReadDirectoryChangesExW]
    OVERLAPPED ol{};
    ol.hEvent = ::CreateEvent(NULL, FALSE, FALSE, _T("WatchEvent"));
    if (ol.hEvent == NULL) {
        printf("(%lu)CreateEvent failed\n", GetLastError());
        return 1;
    }
    alignas(DWORD) char buffer[SIZE_BUF]; // align it as documented
    DWORD dwNotifyFilter = FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_ATTRIBUTES | FILE_NOTIFY_CHANGE_SECURITY | FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_CREATION | FILE_NOTIFY_CHANGE_SIZE | FILE_NOTIFY_CHANGE_LAST_ACCESS;

    while (true) {
        // Watch directory asynchronously
        if (FALSE == ::ReadDirectoryChangesExW(
                         hDir,
                         buffer,
                         SIZE_BUF,
                         FALSE,
                         dwNotifyFilter,
                         NULL, /* If in async mode, it's undefined */
                         &ol,
                         NULL,
                         ReadDirectoryNotifyExtendedInformation)) {
            printf("(%lu)ReadDirectoryChangesExW failed\n", GetLastError());
            return 1;
        }

        // ↓ [ISSUE HERE]
        DWORD res = ::WaitForSingleObject(ol.hEvent, INFINITE);
        // DWORD res = ::WaitForSingleObject(ol.hEvent, 20);
        // ↑ [ISSUE HERE]

        if (res == WAIT_FAILED) {
            printf("(%lu)WaitForSingleObject failed\n", GetLastError());
            return 1;

        } else if (res == WAIT_TIMEOUT) {
            // do nothing if timeout

        } else if (res == WAIT_OBJECT_0) {
            // event triggered, means directory changed
            printf("\n======= NEXT WHILE LOOP ===========\n");
            DWORD szTransferred = 0;
            // if (FALSE == ::GetOverlappedResultEx(hDir, &ol, &szTransferred, 0, FALSE)) {
            if (FALSE == ::GetOverlappedResult(hDir, &ol, &szTransferred, TRUE)) {
                printf("(%lu)GetOverlappedResult failed\n", GetLastError());
                return 1;
            }
            if (szTransferred <= 0) {
                printf("GetOverlappedResult receive 0 bytes\n");
                return 1;
            }

            printf("GetOverlappedResultEx receive %lu bytes\n", szTransferred);

            // iterate all [FILE_NOTIFY_EXTENDED_INFORMATION] in the buffer
            // and print its information
            auto next = (FILE_NOTIFY_EXTENDED_INFORMATION *)buffer;
            while (next != nullptr) {
                std::wstring fileName(next->FileName, next->FileNameLength / sizeof(wchar_t));
                printf("    => [FSNOTIFY_DIR] %ls (size = %lld), Action = %hs\n",
                             fileName.c_str(), next->FileSize.QuadPart, ActionToStr(next->Action));

                if (next->NextEntryOffset != 0)
                    next = (FILE_NOTIFY_EXTENDED_INFORMATION *)((char *)next + next->NextEntryOffset);
                else
                    next = nullptr;
            }

        } else {
            // unknown result from WaitForSingleObject
            printf("Unknown result from WaitForSingleObject: %lu\n", res);
            return 1;
        }
    }
    return 0;
}

Reproduce

Use neovim(when saving the file, neovim can produce more events) to edit D:\test\b.txt, here is the result:

  • With INFINITE as dwMilliseconds (receive 8 events)

======= NEXT WHILE LOOP ===========
GetOverlappedResultEx receive 92 bytes
    => [FSNOTIFY_DIR] 4913 (size = 0), Action = FILE_ACTION_ADDED

======= NEXT WHILE LOOP ===========
GetOverlappedResultEx receive 92 bytes
    => [FSNOTIFY_DIR] 4913 (size = 0), Action = FILE_ACTION_REMOVED

======= NEXT WHILE LOOP ===========
GetOverlappedResultEx receive 192 bytes
    => [FSNOTIFY_DIR] b.txt (size = 9), Action = FILE_ACTION_RENAMED_OLD_NAME
    => [FSNOTIFY_DIR] b.txt~ (size = 9), Action = FILE_ACTION_RENAMED_NEW_NAME

======= NEXT WHILE LOOP ===========
GetOverlappedResultEx receive 96 bytes
    => [FSNOTIFY_DIR] b.txt~ (size = 9), Action = FILE_ACTION_MODIFIED

======= NEXT WHILE LOOP ===========
GetOverlappedResultEx receive 94 bytes
    => [FSNOTIFY_DIR] b.txt (size = 0), Action = FILE_ACTION_ADDED

======= NEXT WHILE LOOP ===========
GetOverlappedResultEx receive 94 bytes
    => [FSNOTIFY_DIR] b.txt (size = 9), Action = FILE_ACTION_MODIFIED

======= NEXT WHILE LOOP ===========
GetOverlappedResultEx receive 96 bytes
    => [FSNOTIFY_DIR] b.txt~ (size = 9), Action = FILE_ACTION_REMOVED
  • With 20 as dwMilliseconds (receive 5 events)

======= NEXT WHILE LOOP ===========
GetOverlappedResultEx receive 92 bytes
    => [FSNOTIFY_DIR] 4913 (size = 0), Action = FILE_ACTION_REMOVED

======= NEXT WHILE LOOP ===========
GetOverlappedResultEx receive 192 bytes
    => [FSNOTIFY_DIR] b.txt~ (size = 9), Action = FILE_ACTION_MODIFIED

======= NEXT WHILE LOOP ===========
GetOverlappedResultEx receive 94 bytes
    => [FSNOTIFY_DIR] b.txt (size = 0), Action = FILE_ACTION_ADDED

======= NEXT WHILE LOOP ===========
GetOverlappedResultEx receive 94 bytes
    => [FSNOTIFY_DIR] b.txt (size = 9), Action = FILE_ACTION_MODIFIED

======= NEXT WHILE LOOP ===========
GetOverlappedResultEx receive 96 bytes
    => [FSNOTIFY_DIR] b.txt~ (size = 9), Action = FILE_ACTION_REMOVED

Others

From Microsoft Docs on ReadDirectoryChangesExW, they haven't recommend using WaitForSingleObject nor WaitForMultipleObjects for asynchronous completion. But from Microsoft Example, they do use WaitForMultipleObjects and GetOverlappedResult simultanously. Also tested replacing WaitForSingleObject with WaitForMultipleObjects, the result is the same: works well if INFINITE, missing some events when set dwMilliseconds to a number.

Thanks a lot if someone can help!

Windows development | Windows API - Win32
0 comments No comments

2 answers

Sort by: Most helpful
  1. Taki Ly (WICLOUD CORPORATION) 5,380 Reputation points Microsoft External Staff Moderator
    2026-09-28T06:36:40.9933333+00:00

    Hello @iamoon-6427 ,

    The difference is not caused by dwMilliseconds. It is caused by where ReadDirectoryChangesExW sits in your loop. It is called at the top of while (true), so it runs again after every WAIT_TIMEOUT, while the previous request is still pending, using the same handle, the same buffer and the same OVERLAPPED.

    With a 20 ms timeout the loop issues a new request every 20 ms while the directory is idle. When neovim saves the file, the kernel completes several of those queued requests, each writing a batch of notifications into the same buffer, so later batches overwrite earlier ones. The auto-reset event is signaled once, you call GetOverlappedResult once and parse only what is left in the buffer. That is why the early events (4913 ADDED, RENAMED_OLD_NAME, RENAMED_NEW_NAME) are gone.

    With INFINITE the wait only returns after the request completes, so there is always exactly one outstanding request and nothing is overwritten.

    The documentation calls this out directly:

    A common mistake is to reuse an OVERLAPPED structure before the previous asynchronous operation has been completed. You should use a separate structure for each request. OVERLAPPED structure, Remarks

    I reproduced this with your code by counting issued vs. harvested requests and generating the same sequence neovim produces on :w after the watcher had been idle for 3 seconds. With INFINITE, 0 requests were pending when the change happened and all events were received. With 20, about 90 requests were pending, only 1 completion was harvested and only 1 event (b.txt~ REMOVED) was received. With the corrected loop below and the same 20 ms timeout, all events were received. Two runs, identical results.

    Fix: issue one request, keep it outstanding across timeouts, and issue the next one only after the previous result has been consumed.

    auto post = [&]() {
        return ::ReadDirectoryChangesExW(hDir, buffer, SIZE_BUF, FALSE, dwNotifyFilter,
                                         NULL, &ol, NULL, ReadDirectoryNotifyExtendedInformation);
    };
    
    if (!post()) { /* handle error */ }
    
    while (true) {
        DWORD res = ::WaitForSingleObject(ol.hEvent, 20);
    
        if (res == WAIT_TIMEOUT) {
            // do other work, then keep waiting on the SAME request
            continue;
        }
        if (res != WAIT_OBJECT_0) { /* handle error */ }
    
        DWORD szTransferred = 0;
        if (!::GetOverlappedResult(hDir, &ol, &szTransferred, FALSE)) { /* handle error */ }
        if (szTransferred == 0) { /* internal buffer overflowed, re-enumerate the directory */ }
    
        // parse FILE_NOTIFY_EXTENDED_INFORMATION entries
    
        if (!post()) { /* handle error */ }   // only now issue the next request
    }
    

    A few related points:

    • Use a manual-reset event for OVERLAPPED.hEvent. Wait functions and GetOverlappedResult both reset auto-reset events, which can make GetOverlappedResult(..., TRUE) hang after a successful wait (OVERLAPPED, hEvent). Also drop the "WatchEvent" name, a named event is system-wide and can collide with another process.
    • To stop while a request is pending, call CancelIoEx(hDir, &ol) and then GetOverlappedResult once more before closing the handle.
    • 0 bytes from GetOverlappedResult, or ERROR_NOTIFY_ENUM_DIR, means the internal buffer overflowed and you must rescan the directory (ReadDirectoryChangesExW, Remarks).
    • WaitForSingleObject/WaitForMultipleObjects + GetOverlappedResult is a supported pattern and is what the wsdapi sample does. The sample works because it keeps one outstanding request per handle.

    Hope these information help! If you found my response helpful or informative, I would greatly appreciate it if you could follow this guide for your confirmation.

    Thank you.

    Was this answer helpful?

    0 comments No comments

  2. AI answer

    2026-09-27T16:11:24.29+00:00

    dwMilliseconds is not what changes the set of file notifications. The difference comes from how the overlapped operation is being consumed.

    For ReadDirectoryChangesExW opened with FILE_FLAG_OVERLAPPED, the documented async patterns are:

    1. GetOverlappedResult with a unique OVERLAPPED.hEvent
    2. GetQueuedCompletionStatus with an I/O completion port
    3. A completion routine, which requires the thread to be in an alertable wait state

    In the first pattern, the event is only the completion signal. The actual result must be obtained from the same overlapped operation.

    The key issue in the shown loop is this sequence:

    • start ReadDirectoryChangesExW
    • call WaitForSingleObject(ol.hEvent, 20)
    • if timeout, immediately loop and issue another ReadDirectoryChangesExW again on the same handle with the same OVERLAPPED

    That means a previous async directory read can still be pending when the next iteration starts. The code only calls GetOverlappedResult in the WAIT_OBJECT_0 branch, so timed-out iterations do not complete or harvest the pending operation before reissuing another read. With INFINITE, the code always waits until the operation completes before continuing, so notifications are not skipped by the loop logic.

    Also, ReadDirectoryChangesExW keeps a buffer associated with the directory handle. Changes between calls are added to that buffer and returned on the next call. If the system cannot record all changes, or if the buffer is too small for all detailed changes, the documented recovery is to enumerate the directory or subtree. So event grouping can differ between calls, and missing detailed notifications must be handled by rescan logic.

    Recommended fixes:

    1. Do not issue a new ReadDirectoryChangesExW until the previous overlapped request has completed and its result has been collected.
    2. If using event-based completion, keep the pattern as:
      • issue one async ReadDirectoryChangesExW
      • wait for ol.hEvent
      • call GetOverlappedResult
      • parse all returned entries
      • issue the next async ReadDirectoryChangesExW
    3. If a timeout is needed, use it only to let the thread do other work, not to start another directory read while one is still pending.
    4. If using completion routines, use an alertable wait such as WaitForSingleObjectEx, because WaitForSingleObject does not enter an alertable wait state.
    5. Use a manual-reset event in the OVERLAPPED structure when retrieving overlapped results with GetOverlappedResultEx; the documentation warns about auto-reset events causing indefinite blocking in some wait/result sequences.

    For the current code, the simplest correction is:

    • keep one outstanding ReadDirectoryChangesExW
    • on WAIT_TIMEOUT, do other work and then wait again on the same outstanding request
    • only after WAIT_OBJECT_0 and successful GetOverlappedResult should the next ReadDirectoryChangesExW be posted

    That explains why INFINITE appears to “work” and 20 appears to “lose” events: the timeout path is allowing the loop to outrun the pending overlapped request, not changing the semantics of directory notifications.


    References:

    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.