Um arquivo de configuração que funcionava cinco segundos atrás agora é um fragmento de 347 bytes de um JSON corrompido. Sua ferramenta CLI não iniciará. O usuário, que simplesmente pressionou Ctrl-C porque a atualização estava demorando mais do que o esperado, agora está encarando um rastreamento de pilha (stack trace) que não solicitou. Os dois kilobytes de configuração válida que existiam antes da escrita sumiram, substituídos pelo equivalente digital de um recibo impresso pela metade.

Isso acontece porque o writeFile não é atômico. Ele abre o caminho existente, o trunca, transmite os dados do Node para o cache de página do kernel e, eventualmente, fecha o descritor de arquivo. No momento em que o truncamento ocorre, o conteúdo antigo já se foi. Tudo entre esse truncamento e o close final é uma janela de vulnerabilidade. Um SIGINT, uma queda de energia ou a tampa de um laptop sendo fechada bruscamente durante essa janela deixa o sistema de arquivos com uma bagunça truncada. Mesmo que o Node reporte que a Promise foi resolvida, o sistema operacional ainda pode estar bufferizando as escritas na memória. Métodos de conveniência escondem essa lacuna, mas não a removem.

A solução não é escrever no local. A solução é separar o ato de escrever do ato de publicar.

Escreva em um arquivo irmão, depois substitua

O padrão confiável possui cinco etapas. Nenhuma delas é complicada, mas juntas elas movem a janela de falha de uma escrita inteira por streaming para uma única operação de metadados do sistema de arquivos.

Primeiro, serialize todo o payload na memória. Faça isso antes de criar qualquer arquivo temporário. Se o JSON.stringify lançar uma exceção porque alguém passou um objeto circular, você vai querer que essa exceção suba antes de tocar no disco.

Segundo, escreva os dados serializados em um arquivo temporário localizado no mesmo diretório do destino. Use um nome aleatório para que duas execuções simultâneas não colidam. Manter o arquivo temporário no mesmo diretório é importante porque o rename só é atômico dentro de um único sistema de arquivos. Se o seu arquivo temporário estiver em uma partição diferente, o sistema operacional recorrerá a uma sequência de cópia e exclusão, o que introduz seus próprios modos de falha e deixa de ser atômico.

Terceiro, peça ao kernel para fazer o flush desse arquivo temporário para o armazenamento físico. O fsync do Node, exposto aqui como o método sync em um filehandle, bloqueia até que os buffers sejam gravados no hardware. Isso é lento, mas as escritas de configuração ocorrem com pouca frequência, de modo que a durabilidade vale os milissegundos.

Quarto, renomeie o arquivo temporário sobre o caminho original. Tanto em sistemas POSIX quanto no Windows, este é o ponto de confirmação (commit). Leitores que abrirem o caminho original verão ou o arquivo antigo completo ou o arquivo novo completo. Não há um momento em que um leitor possa abrir o caminho e observar um buffer escrito pela metade.

Quinto, sincronize o diretório pai. Isso captura um caso de borda sutil. O rename atualiza a entrada do diretório, mas os próprios metadados do diretório podem estar no cache de página do kernel. Uma perda repentina de energia após um rename bem-sucedido pode, às vezes, deixar o sistema de arquivos em um estado onde a nova referência do inode nunca foi registrada de forma durável. Sincronizar o diretório força essa atualização de metadados para o disco e sela a transação.

Uma implementação concreta em Node.js

Aqui está como esse padrão funciona na prática usando apenas a biblioteca padrão do Node.js:

import { open, rename, rm } from "node:fs/promises";
import { dirname, basename, join } from "node:path";
import { randomUUID } from "node:crypto";

export async function writeJsonAtomic(path, value) {
  const directory = dirname(path);
  const temporary = join(directory, `.${basename(path)}.${randomUUID()}.tmp`);
  const body = `${JSON.stringify(value, null, 2)}\n`;
  let handle;

  try {
    handle = await open(temporary, "wx", 0o600);
    await handle.writeFile(body, "utf8");
    await handle.sync();
    await handle.close();
    handle = undefined;

    await rename(temporary, path);

    const directoryHandle = await open(directory, "r");
    try {
      await directoryHandle.sync();
    } finally {
      await directoryHandle.close();
    }
  } catch (error) {
    if (handle) await handle.close().catch(() => {});
    await rm(temporary, { force: true }).catch(() => {});
    throw error;
  }
}

Alguns detalhes neste código merecem atenção.

A flag wx significa "escrever, mas falhar se o arquivo já existir". Isso protege contra uma colisão de UUID ou um arquivo temporário abandonado de um processo anterior que travou. Se alguém tiver colocado um arquivo malicioso onde seu arquivo temporário deveria estar, você saberá imediatamente, em vez de sobrescrever o que quer que esteja lá.

A máscara de permissão 0o600 cria o arquivo temporário apenas com permissão de leitura e escrita para o proprietário. Arquivos de configuração frequentemente contêm segredos, tokens de API ou URLs de repositórios privados. Não há razão para permitir que outros usuários no sistema espiem o arquivo temporário enquanto ele está sendo preparado.

Observe as chamadas sync separadas no arquivo e depois no diretório. Muitos desenvolvedores pulam a sincronização do diretório porque parece redundante. Não é. Ext4, APFS e NTFS lidam com atualizações de diretório de formas diferentes, mas todos compartilham o hábito comum de agrupar escritas de metadados para melhorar o desempenho. Se você se preocupa em sobreviver a uma queda de energia, a sincronização do diretório é o selo final.

A limpeza no bloco catch é intencionalmente defensiva. Se algo for lançado após o handle do arquivo ser aberto, o código tenta fechar o handle e remover o arquivo temporário, suprimindo quaisquer erros secundários para que a exceção original se propague de forma limpa. Você não quer que um erro de permissão durante a limpeza mascare o bug real que causou a falha.

Onde este padrão deixa de ajudar

A substituição atômica de arquivos evita escritas incompletas (torn writes). Ela não evita atualizações perdidas. Se duas instâncias da sua CLI lerem a mesma configuração simultaneamente, ambas realizarem suas edições em memória, ambas escreverem novos arquivos temporários e ambas executarem seus renomeios, o segundo renomeio vence. O primeiro processo não observou as alterações do segundo processo. Dependendo da sua aplicação, isso pode significar que um usuário adiciona uma configuração em um terminal e um usuário diferente a remove em outro, com o arquivo final refletindo apenas o último escritor.

Se a sua ferramenta precisar suportar mutadores concorrentes, você precisará de um mecanismo de coordenação sobre a escrita atômica. Um arquivo de trava consultiva (advisory lock file) funciona para casos simples. Vetores de versão ou um número de revisão monotônico dentro da própria configuração podem ajudar a detectar colisões para que o segundo escritor possa tentar novamente. Isso adiciona complexidade, e a complexidade é onde os bugs se escondem.

É por isso que o limite importa. Um único blob JSON que um processo atualiza ocasionalmente é um bom candidato para uma escrita de arquivo atômica. Assim que você se vir gerenciando múltiplos registros, aplicando esquemas ou se preocupando com mutações concorrentes, você terá superado o sistema de arquivos. O SQLite existe exatamente por este motivo. Ele oferece transações atômicas, journals de rollback e tratamento adequado de leitores e escritores concorrentes, tudo dentro de um único arquivo local no host. Um protocolo de arquivo inteligente não é um banco de dados, e você não deve gastar orçamento de manutenção fingindo que é.

A verdadeira lição

Na próxima vez que você recorrer ao writeFile dentro de uma ferramenta de CLI, faça uma pausa. A serialização não é a parte difícil. A durabilidade é. Arquivos de configuração são pequenos demais para fazer streaming e importantes demais para serem truncados. Escreva todo o payload em um arquivo irmão oculto, faça o flush, confirme com um rename e informe o diretório sobre isso. Seus usuários podem pressionar Ctrl-C, puxar o cabo de força ou fechar a tampa do laptop. Quando a máquina voltar, o arquivo conterá ou o mundo antigo ou o novo. Nada entre os dois.