Итак, довольно простая часть программы под Windows. Есть файл, содержащий несколько записей. И их надо определенным образом отфильтровать.

Решение довольно простое — открываем файл, читаем записи одну за другой, нужные нам записываем во временный файл. Закрываем файл. Удаляем его. Переименовываем временный в оригинальный. Настолько все просто, что даже код приводить не буду. Неужели же это достаточный повод для статьи?

Пока все работает, повода об этом писать и правда нет. Но потом вдруг однажды «все падает», т.к. переименовывание не происходит из-за ошибки «Access denied». Случается это очень редко, но все же гораздо чаще, чтобы заподозрить космические лучи.

Начинаем раскопки. Первая найденная зацепка: в процессе копания насторожила вот эта вот цитата из документации Микрософта:
The DeleteFile function marks a file for deletion on close. Therefore, the file deletion does not occur until the last handle to the file is closed. Subsequent calls to CreateFile to open the file fail with ERROR_ACCESS_DENIED.
По симптоматике ну очень уж похоже, но вот только откуда же берутся эти «другие хендлеры», если кроме нас, с этим файлом никто ничего не делает и не должен делать? И у нас нет никаких других потоков с нитями, которые бы что-то делали с этим файлом?

Виновник нашелся благодаря SysInternals и их ProcessMonitor. Запускаем его, устанавливаем фильтр на наш многострадальный файл и долго и настойчиво пытаемся воспроизвести. Воспроизводим. Смотрим. И что же мы там видим?
01. 2:25:28.3162097 PM our_prog.exe 1288 CreateFile our.file SUCCESS Desired Access: Generic Read/Write
02. 2:25:28.3164513 PM our_prog.exe 1288 WriteFile our.file SUCCESS Offset: 0, Length: 898, Priority: Normal

34. 2:25:28.3173405 PM our_prog.exe 1288 WriteFile our.file SUCCESS Offset: 35,290, Length: 1,113
35. 2:25:28.3173493 PM our_prog.exe 1288 WriteFile our.file SUCCESS Offset: 36,403, Length: 1,128
36. 2:25:28.3173736 PM our_prog.exe 1288 FlushBuffersFile our.file SUCCESS
37. 2:25:28.3174212 PM our_prog.exe 1288 WriteFile our.file SUCCESS Offset: 0, Length: 40,960,
38. 2:25:28.3175927 PM Explorer.EXE 1884 QueryBasicInformationFile our.file SUCCESS
39. 2:25:28.3176144 PM Explorer.EXE 1884 CloseFile our.file SUCCESS
40. 2:25:28.3263642 PM Explorer.EXE 1884 CreateFile our.file SUCCESS Desired Access: Read Attributes,
41. 2:25:28.3294990 PM our_prog.exe 1288 CloseFile our.file SUCCESS
42. 2:25:28.3351356 PM our_prog.exe 1288 CreateFile our.file SUCCESS Desired Access: Read Attributes, Delete,
43. 2:25:28.3351856 PM our_prog.exe 1288 QueryAttributeTagFile our.file SUCCESS Attributes: A, ReparseTag: 0x0
44. 2:25:28.3352020 PM our_prog.exe 1288 SetDispositionInformationFile our.file SUCCESS Delete: True
45. 2:25:28.3352218 PM our_prog.exe 1288 CloseFile our.file SUCCESS
46. 2:25:28.3358275 PM our_prog.exe 1288 CreateFile our.file DELETE PENDING Desired Access: Generic Read/Write,
47. 2:25:28.3362207 PM our_prog.exe 1288 CreateFile our.file DELETE PENDING Desired Access: Generic Read/Write,
48. 2:25:28.3367696 PM Explorer.EXE 1884 QueryBasicInformationFile our.file SUCCESS
49. 2:25:28.4279152 PM Explorer.EXE 1884 CloseFile our.file SUCCESS
50. 2:25:28.4282859 PM Explorer.EXE 1884 CreateFile our.file NAME NOT FOUND Desired Access: Read Attributes,

83. 2:25:29.3497760 PM our_prog.exe 1288 CreateFile our.file SUCCESS Desired Access: Generic Read/Write,
А видим мы там следующее (лишние данные удалены, чтоб не загромождать). Строки с 1 по 36 — мы создаем файл, пишем в него, делаем flush. Самое интересное начинается в строках 38-40. В них откуда ни возьмись появяется explorer.exe и начинает наш файл читать.

В строке 41 мы закрываем наш файл. В строке 42 — удаляем. И поскольку explorer.exe его все еще читает, файл не удаляется. Что мы можем увидеть в строках 46 и 47, когда мы пытаемся переименовать наш временный файл в основной (статус DELETE PENDING вместо SUCCESS).

Explorer.exe заканчивает чтение только в строке 49. Только в этот момент файл физически удаляется (о чем нам косвенно говорит строчка 50, где наш настойчивый explorer.exe вновь пытается открыть файл для чтения, но у него не получается, т.к. файла уже нет).

Что из этого следует? Что благодаря Микрософт Виндоуз даже простую операцию удаления файла теперь нужно делать в стиле параноидального программирования. Вызвали функцию удалить, убедились, что она вернула ОК, и вошли в цикл ожидания «пока файл еще не удален физически». Ну и да, без знания того, что «внутре у ней неонка» уже не получается сделать практически ничего…

Но вот гложет меня сомнение, что такой подход является общепринятой практикой. Чтобы при любой работе с файлами в Виндоуз держать в голове, что ОС в любой момент решит что-нибудь с файлами поделать без вашего ведома, и писать код, устойчивый к этому. В связи с чем опрос ниже.

Комментарии (20)


  1. amarao
    06.05.2019 15:06
    +2

    Это не defensive programming.

    Все IO-операции могут приводить к сбоям и это задача программиста предоставлять инвариант с обработкой этого файла. Ровно так же, как сетевой программист должен быть готов к странным вещам по сети, программист, работающий с файлами, должен тоже быть готов к неожиданностям.


    1. olekl Автор
      06.05.2019 15:41

      В моем понимании неожиданностей вообще не должно быть (в идеале). Т.к. без понимания причин сделать корректную обработку «неожиданности» вряд ли получится.


      1. amarao
        06.05.2019 15:44
        +1

        Вы правы. Но «неожиданность» это или инвариант — зависит от программиста. Например, в любой момент файловая система может оказаться в R/O. Или вообще, начать отсутствовать, потому что пользователь флешку вынул.

        Хороший стиль программирования (хороший — приводящий к наименьшему числу ошибок) требует, чтобы ошибки и данные обрабатывались одним и тем же кодом. Использование алгебраических типов данных (Option, Result, Maybe) даёт этот эффект почти бесплатно. Использование исключений, наоборот, всё портит и делает код обработки ошибок «отдельным миром».


  1. vilgeforce
    06.05.2019 15:08

    А что у вас с шарингом при открытии файла, кстати?


    1. olekl Автор
      06.05.2019 15:42
      -1

      С сетевым? Нет его, все локально происходит.


  1. vilgeforce
    06.05.2019 15:42

    С шарингом открытия файлов. У CreateFile есть соответствующий параметр


    1. olekl Автор
      06.05.2019 15:48

      Тот, что по умолчанию в с++ в высокоуровневой работе с файлами, где этого параметра нет.


  1. oleg-m1973
    06.05.2019 15:43
    +1

    В винде много кто открывает файлы — индексатор, антивирусы и т.д. Нужно переименовывать файл перед удалением, наверное

    The DeleteFile function can be used to delete a file on close. A file cannot be deleted until all handles to it are closed. If a file cannot be deleted, its name cannot be reused. To reuse a file name immediately, rename the existing file.


    1. olekl Автор
      06.05.2019 15:47

      Да, рабочий подход. Переименование сработает даже если файл открыт :) Все же интересно, сколько людей реально эту ситуацию учитывают, когда пишут код.


      1. samhuawey
        06.05.2019 17:09

        Да собственно любой программист, который имел отношение к деплойменту на винду, про этот способ знает и учитывает. И QA тоже. В общем, открыли Америку.


      1. Videoman
        07.05.2019 01:00

        У вас этап «Удаляем его» вообще лишний. Под Windows обычно просто используют MoveFileEx с флагом MOVEFILE_REPLACE_EXISTING. Заодно и оригинальный файл не потеряете в случае сбоя и в случает с конкуренцией меньше багов. Короче, всегда старайтесь делать операции атомарными, если это возможно.


        1. oleg-m1973
          07.05.2019 05:22

          Проблема в том, что MoveFileEx точно также выдаст access denied.


          1. Videoman
            07.05.2019 11:52

            А вы выставляете флаг FILE_SHARE_DELETE при открытии?
            Если да и у вас по прежнему не отрабатывает MoveFileEx с флагом, тогда вам ничего не остается как искать проблему в своем коде. Дело в том, что вот это место:

            Решение довольно простое — открываем файл, читаем записи одну за другой, нужные нам записываем во временный файл. Закрываем файл. Удаляем его. Переименовываем временный в оригинальный. Настолько все просто, что даже код приводить не буду.
            как раз самое интересное и проблема именно там.


            1. oleg-m1973
              07.05.2019 12:01

              MoveFileEx сначала удаляет файл, потом переименовывает. Если удалить не получается, например, когда файл открыт кем-то другим, то переименовать тоже не получится. FILE_SHARE_DELETE здесь никак не поможет.


              1. Videoman
                07.05.2019 12:44

                Только если стороннее приложение, типа explorer.exe открывает без флага FILE_SHARE_DELETE. Но тогда это просто ошибка приложения, настройки системы, расширений и т.д. С таким же успехом снаружи вам могут прописать что-то в файл, создать такой же, переименовать… К этому нужно быть готовым и писать swap так, что бы операция была индемпотентна. В любом случае, для вашего приложения это невозможность произвести операцию и WinAPI тут не причем.


  1. kITerE
    06.05.2019 17:28
    +2

    Виновник нашелся благодаря SysInternals и их ProcessMonitor.

    На мой взгляд немаловажной частью проблемы понимания этой ситуации является усечение более информативных NT Status'ов нативного API до более обобщенных Win32 кодов.


    https://web.archive.org/web/20150317121919/https://support.microsoft.com/en-us/kb/113996:


    WINDOWS NT STATUS CODE                  WIN32 ERROR CODE
    ------------------------------------------------------------------
    STATUS_CANNOT_DELETE                    ERROR_ACCESS_DENIED
    STATUS_FILE_DELETED                     ERROR_ACCESS_DENIED
    STATUS_FILE_RENAMED                     ERROR_ACCESS_DENIED
    STATUS_DELETE_PENDING                   ERROR_ACCESS_DENIED


    1. olekl Автор
      06.05.2019 17:48

      И это тоже. Вот даже не могу себе представить, нафига им так делать было?


  1. kITerE
    06.05.2019 17:34
    +1

    Так же в ключе темы публикации интересно то, что (вероятно для WSL) Microsoft все же добавила новую POSIX-семантику удаления файлов (https://docs.microsoft.com/windows-hardware/drivers/ddi/content/ntddk/ns-ntddk-_file_disposition_information_ex):


    When FILE_DISPOSITION_POSIX_SEMANTICS is not set, a file marked for deletion is not actually deleted until all open handles for the file have been closed and the link count for the file is zero. When FILE_DISPOSITION_POSIX_SEMANTICS is set, the link is removed from the visible namespace as soon as the POSIX delete handle has been closed, but the file’s data streams remain accessible by other existing handles until the last handle has been closed. That is, applications that already had the file open can still use their handle to read/write even though the name they used to open it is gone and the file's link count may have reached zero.
    If the file is being deleted at user request, using POSIX semantics allows the system to delete the file as requested, but also allows any process with an open handle to continue to access the file's data as long as the handle is open.

    Если я правильно понимаю POSIX-семантику, то создание нового файла поверх удаленного должно быть возможным сразу, а не по закрытию последнего описателя на него.
    Проверил: нет, FILE_DISPOSITION_POSIX_SEMANTICS не позволяет создавать файлы поверх удаляемого.


  1. tandzan
    06.05.2019 19:38

    Встречалась похожая ситуация, когда моя консольная утилитка не могла удалить папку с ошибкой «папка не пустая». Один раз лично наблюдал эту ошибку, при попытке удалить папку Far'ом, повторная попытка увенчалась успехом. Грешил на антивирус, но т.к. натыкался на нее раз в пару месяцев, так и не смог поймать злоумышленника.


  1. Cerberuser
    07.05.2019 05:24

    Видимо, именно поэтому git checkout при работающем докере регулярно обваливается с permission denied и не может создать файлы из новой ветки?