Start Debugging

Como detectar quando um arquivo termina de ser escrito no .NET

FileSystemWatcher dispara Changed antes do escritor terminar. Tres padroes confiaveis para .NET 11 para saber quando um arquivo esta totalmente escrito: abrir com FileShare.None, fazer debounce com estabilizacao de tamanho e o truque de renomeacao do lado do produtor que evita o problema completamente.

FileSystemWatcher nao avisa quando um arquivo esta “pronto”. Ele avisa que o sistema operacional observou uma mudanca. No Windows, cada chamada de WriteFile dispara um evento Changed, e Created dispara no momento em que o arquivo aparece, normalmente antes de um unico byte ser escrito. Os padroes confiaveis sao: (1) tentar abrir o arquivo com FileShare.None e tratar IOException 0x20 / 0x21 como “ainda esta sendo escrito”, repetindo com backoff; (2) fazer polling de FileInfo.Length e LastWriteTimeUtc ate que ambos estabilizem em duas amostras consecutivas; ou (3) cooperar com o produtor para que ele escreva em name.tmp e depois faca File.Move para o nome final, o que e atomico no mesmo volume. O padrao 3 e o unico correto sem condicoes de corrida. Os padroes 1 e 2 sao como sobreviver quando voce nao controla o produtor.

Este post tem como alvo o .NET 11 (preview 4) e Windows / Linux / macOS. A semantica do FileSystemWatcher descrita abaixo nao mudou desde o .NET Core 3.1 em nenhuma plataforma, e o truque da renomeacao cooperativa e o mesmo no POSIX e no NTFS.

Por que a abordagem obvia esta errada

O codigo ingenuo se parece com isso e esta em producao em lugares demais:

// .NET 11 -- BROKEN, do not ship
var watcher = new FileSystemWatcher(@"C:\inbox", "*.csv");
watcher.Created += (_, e) =>
{
    var rows = File.ReadAllLines(e.FullPath); // throws IOException
    Process(rows);
};
watcher.EnableRaisingEvents = true;

Created dispara quando o sistema operacional reporta que a entrada de diretorio existe. O processo de escrita nao necessariamente fez flush nem mesmo de um byte. No Windows o arquivo pode estar aberto com FileShare.Read (entao sua leitura retorna um arquivo parcial) ou com FileShare.None (entao sua leitura lanca IOException: The process cannot access the file because it is being used by another process, HRESULT 0x80070020, win32 error 32). No Linux voce quase sempre obtem uma leitura parcial porque nao ha bloqueio mandatorio por padrao; voce vai processar silenciosamente metade de um CSV.

Changed e pior. Dependendo de como o produtor escreve, voce pode receber um evento por chamada de WriteFile, o que significa que um arquivo de 1 MB escrito em blocos de 4 KB dispara 256 eventos. Nenhum deles avisa que o escritor terminou. Nao existe uma notificacao WriteFileLastTimeIPromise porque o kernel nao conhece a intencao do escritor.

Um terceiro problema: muitas ferramentas de copia (Explorer, robocopy, rsync) escrevem primeiro em um nome temporario oculto e depois renomeiam. Voce vera Created para o temporario, depois Renamed para o arquivo final. O evento Renamed e aquele em que voce quer reagir nesses casos, mas os padroes do FileSystemWatcher.NotifyFilter excluem LastWrite no .NET 11 e em algumas plataformas excluem FileName, entao voce precisa ativar explicitamente.

Padrao 1: Abrir com FileShare.None e aplicar backoff

Se voce nao controla o produtor, seu unico canal de observacao e “consigo abrir o arquivo de forma exclusiva”. O produtor mantem um handle aberto enquanto escreve; quando ele fecha o handle, uma abertura exclusiva tem sucesso. Isso funciona no Windows, Linux e macOS (o Linux oferece bloqueios consultivos via flock, mas a semantica de abertura sem bloqueio para um FileStream regular e suficiente porque estamos lendo apenas para confirmar que o escritor sumiu).

// .NET 11, C# 14
using System.IO;

static async Task<FileStream?> WaitForFileAsync(
    string path,
    TimeSpan timeout,
    CancellationToken ct)
{
    var deadline = DateTime.UtcNow + timeout;
    var delay = TimeSpan.FromMilliseconds(50);

    while (DateTime.UtcNow < deadline)
    {
        try
        {
            return new FileStream(
                path,
                FileMode.Open,
                FileAccess.Read,
                FileShare.None);
        }
        catch (IOException ex) when (IsSharingViolation(ex))
        {
            await Task.Delay(delay, ct);
            delay = TimeSpan.FromMilliseconds(Math.Min(delay.TotalMilliseconds * 2, 1000));
        }
        catch (UnauthorizedAccessException)
        {
            // ACL problem, not a sharing problem -- do not retry
            throw;
        }
    }
    return null;
}

static bool IsSharingViolation(IOException ex)
{
    // ERROR_SHARING_VIOLATION = 0x20, ERROR_LOCK_VIOLATION = 0x21
    var hr = ex.HResult & 0xFFFF;
    return hr is 0x20 or 0x21;
}

Tres detalhes sutis:

Esse padrao falha em um caso especifico: um produtor que abre com FileShare.Read | FileShare.Write (alguns uploaders bugados fazem isso). Sua abertura exclusiva tera sucesso no meio da escrita e voce vai ler lixo. Se voce suspeitar disso, combine o padrao 1 com o padrao 2.

Padrao 2: Debounce na estabilizacao do tamanho

Quando voce nao pode confiar nos bloqueios de arquivo (alguns produtores Linux, alguns shares SMB, alguns dumps de camera), faca polling do tamanho e de LastWriteTimeUtc. A regra pratica: se o tamanho nao mudar em duas amostragens consecutivas separadas por um intervalo razoavel, o escritor provavelmente terminou.

// .NET 11, C# 14
static async Task<bool> WaitForStableSizeAsync(
    string path,
    TimeSpan pollInterval,
    int requiredStableSamples,
    CancellationToken ct)
{
    var fi = new FileInfo(path);
    long lastSize = -1;
    DateTime lastWrite = default;
    int stable = 0;

    while (stable < requiredStableSamples)
    {
        await Task.Delay(pollInterval, ct);
        fi.Refresh(); // FileInfo caches; Refresh forces a fresh stat call
        if (!fi.Exists) return false;

        if (fi.Length == lastSize && fi.LastWriteTimeUtc == lastWrite)
        {
            stable++;
        }
        else
        {
            stable = 0;
            lastSize = fi.Length;
            lastWrite = fi.LastWriteTimeUtc;
        }
    }
    return true;
}

Escolha pollInterval baseado no que voce sabe sobre o escritor:

A pegadinha e FileInfo.Refresh(). Sem ele, FileInfo.Length retorna o valor cacheado quando o FileInfo foi construido, e seu loop gira para sempre. Nao ha aviso do compilador para isso; e um bug silencioso comum.

Combine com o padrao 1 em producao: faca polling para tamanho estavel, depois tente uma abertura exclusiva como confirmacao final. A combinacao lida tanto com produtores bem-comportados quanto mal-comportados.

Padrao 3: O produtor coopera — escreva e depois renomeie

Se voce controla o escritor, nao precisa detectar nada. Escreva em final.csv.tmp, faca fsync, feche e renomeie para final.csv. O FileSystemWatcher do consumidor observa Renamed (ou Created da extensao final) e reage. No mesmo volume NTFS ou ext4, File.Move e atomico: ou o destino existe com o conteudo completo, ou nao existe.

// .NET 11, C# 14 -- producer side
static async Task WriteAtomicallyAsync(
    string finalPath,
    Func<Stream, Task> writeBody,
    CancellationToken ct)
{
    var tmpPath = finalPath + ".tmp";

    await using (var fs = new FileStream(
        tmpPath,
        FileMode.Create,
        FileAccess.Write,
        FileShare.None,
        bufferSize: 81920,
        useAsync: true))
    {
        await writeBody(fs, ct);
        await fs.FlushAsync(ct);
        // FlushAsync flushes the .NET buffer; FlushToDisk forces fsync.
        // For most use cases FlushAsync + closing the handle is enough,
        // because Windows Cached Manager and the Linux page cache will
        // serialize the rename after the writes. If you must survive a
        // crash mid-write, also call:
        //   fs.Flush(flushToDisk: true);
    }

    // File.Move with overwrite=true uses MoveFileEx with MOVEFILE_REPLACE_EXISTING
    // on Windows and rename(2) on POSIX. Both are atomic on the same volume.
    File.Move(tmpPath, finalPath, overwrite: true);
}

Duas regras nao obvias:

Esse e o mesmo padrao que o Git usa para atualizar refs, o mesmo que o SQLite usa para o seu journal e o mesmo que recarregadores de configuracao atomicos (nginx, HAProxy) usam. Existe um motivo. Se voce pode mudar o produtor, faca isso e pare de ler.

Conectando corretamente ao FileSystemWatcher

O handler precisa ser barato e delegar para uma fila. FileSystemWatcher levanta eventos em uma thread do thread pool com um buffer interno pequeno (padrao 8 KB no Windows). Se voce bloqueia no handler, o buffer transborda e voce recebe eventos Error com InternalBufferOverflowException, descartando eventos silenciosamente.

// .NET 11, C# 14
using System.IO;
using System.Threading.Channels;

var channel = Channel.CreateUnbounded<string>(
    new UnboundedChannelOptions { SingleReader = true });

var watcher = new FileSystemWatcher(@"C:\inbox")
{
    Filter = "*.csv",
    NotifyFilter = NotifyFilters.FileName
                 | NotifyFilters.LastWrite
                 | NotifyFilters.Size,
    InternalBufferSize = 64 * 1024, // 64 KB, max is 64 KB on most platforms
};

watcher.Created += (_, e) => channel.Writer.TryWrite(e.FullPath);
watcher.Renamed += (_, e) => channel.Writer.TryWrite(e.FullPath);
watcher.EnableRaisingEvents = true;

// Dedicated consumer
_ = Task.Run(async () =>
{
    await foreach (var path in channel.Reader.ReadAllAsync())
    {
        if (path.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) continue;
        if (!await WaitForStableSizeAsync(path, TimeSpan.FromMilliseconds(250), 2, default))
            continue;
        await using var fs = await WaitForFileAsync(path, TimeSpan.FromSeconds(30), default);
        if (fs is null) continue;
        await ProcessAsync(fs);
    }
});

Tres coisas nesse codigo que pegam as pessoas:

Quando o arquivo esta em um share de rede

SMB e NFS adicionam seu proprio timing. FileSystemWatcher em um caminho UNC no Windows usa ReadDirectoryChangesW contra o share, mas os eventos sao coalescentes pelo redirecionador SMB. Voce pode ver um evento Changed por minuto mesmo para um arquivo de 1 GB sendo escrito continuamente. Os padroes 1 e 2 ainda funcionam, mas voce deveria definir pollInterval na ordem de 5-10 segundos; fazer polling de um FileInfo.Length remoto a cada 100ms gera um round-trip de metadados por polling e satura o link.

NFS e pior: inotify nao dispara para mudancas feitas em outros clientes, somente para mudancas no mount local feitas por processos locais. Se seu consumidor esta no host A e o produtor esta no host B escrevendo via NFS, FileSystemWatcher nao vai ver nada. A solucao e somente polling — Directory.EnumerateFiles em um timer, com os padroes 1 e 2 aplicados a cada nova entrada. Nao ha caminho de notificacao do kernel que va te salvar aqui.

Casos limite comuns

Leitura relacionada

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar