From 3d552b5bc0b0da19616656e41cafaa67132200de Mon Sep 17 00:00:00 2001 From: Gustavo Date: Fri, 31 Jul 2026 22:13:49 -0300 Subject: [PATCH] Fix export excel writer crashing the process on a stream write error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exportExcel's CSV/XLS write streams had no 'error' listener, so a write failure (disk full, export directory becomes inaccessible mid-run — both realistic for a large CNPJ export) surfaced as an uncaught exception that crashed the whole export-job.mjs child process, bypassing every await/try-catch in the caller. The exports row was then never marked 'error' and stayed stuck, and a half-written file could sit at the final path (no temp+rename, unlike the same pattern already used in rfb-extractor.mjs/rfb-downloader.mjs). Streams now track their error via a listener instead of throwing, writes and stream-end are rejected through the normal promise chain so export-job.mjs's existing catch/finally handles it, and both files are written to .tmp paths and only renamed to their final names once both streams close successfully. Found via a full-project audit. --- src/export/excel.mjs | 95 +++++++++++++++++++++++++++++++------------ tests/export.test.mjs | 21 ++++++++++ 2 files changed, 91 insertions(+), 25 deletions(-) diff --git a/src/export/excel.mjs b/src/export/excel.mjs index 8911213..b3df36b 100644 --- a/src/export/excel.mjs +++ b/src/export/excel.mjs @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import fsp from 'node:fs/promises'; import path from 'node:path'; import { csvEscape, excelTextCell, neutralizeSpreadsheetFormula, BOM } from '../util/csv.mjs'; import { iterateLeads } from '../treatment/queries.mjs'; @@ -54,50 +55,94 @@ function escapeHtml(value) { .replace(/>/g, '>'); } -async function writeStream(stream, text) { +// Rastreia o evento 'error' do stream sem lancar (evita a excecao nao +// capturada que derrubaria o processo inteiro): writeStream/endStream +// verificam esse estado e transformam a falha numa promise rejeitada, que o +// try/catch normal deste modulo (e do export-job.mjs) consegue tratar. +function trackStreamError(stream) { + let error = null; + stream.on('error', (streamError) => { error = streamError; }); + return () => error; +} + +async function writeStream(stream, text, getError) { + if (getError()) throw getError(); if (!stream.write(text)) { - await new Promise((resolve) => stream.once('drain', resolve)); + await new Promise((resolve, reject) => { + stream.once('drain', resolve); + stream.once('error', reject); + }); } } +async function endStream(stream, getError) { + if (getError()) throw getError(); + await new Promise((resolve, reject) => { + stream.once('error', reject); + stream.end((error) => (error ? reject(error) : resolve())); + }); +} + export async function exportExcel({ db, filtro, dir, onProgress = () => {}, collectIds }) { fs.mkdirSync(dir, { recursive: true }); const baseName = 'leads-excel'; const csvPath = path.join(dir, `${baseName}.csv`); const xlsPath = path.join(dir, `${baseName}.xls`); + // Grava em .tmp e so promove (rename) pro nome final se as duas escritas + // terminarem com sucesso — nunca deixa um arquivo pela metade no lugar do + // arquivo final, mesmo caminho ja usado em rfb-extractor.mjs/rfb-downloader.mjs. + const csvTmpPath = `${csvPath}.tmp`; + const xlsTmpPath = `${xlsPath}.tmp`; - const csv = fs.createWriteStream(csvPath, { encoding: 'utf8' }); - const xls = fs.createWriteStream(xlsPath, { encoding: 'utf8' }); - - await writeStream(csv, BOM + HEADERS.join(';') + '\n'); - await writeStream(xls, `${BOM}${ - HEADERS.map((h) => ``).join('') - }\n`); + const csv = fs.createWriteStream(csvTmpPath, { encoding: 'utf8' }); + const xls = fs.createWriteStream(xlsTmpPath, { encoding: 'utf8' }); + const csvError = trackStreamError(csv); + const xlsError = trackStreamError(xls); let total = 0; - for (const lead of iterateLeads(db, filtro)) { - const row = leadToExportRow(lead); + try { + await writeStream(csv, BOM + HEADERS.join(';') + '\n', csvError); + await writeStream(xls, `${BOM}
${escapeHtml(h)}
${ + HEADERS.map((h) => ``).join('') + }\n`, xlsError); - const csvCells = HEADERS.map((h) => (TEXT_COLUMNS.has(h) && row[h] !== '' - ? excelTextCell(row[h]) - : csvEscape(neutralizeSpreadsheetFormula(row[h])))); - await writeStream(csv, csvCells.join(';') + '\n'); + for (const lead of iterateLeads(db, filtro)) { + const row = leadToExportRow(lead); - const xlsCells = HEADERS.map((h) => - `${escapeHtml(TEXT_COLUMNS.has(h) ? row[h] : neutralizeSpreadsheetFormula(row[h]))}` - ); - await writeStream(xls, `${xlsCells.join('')}\n`); + const csvCells = HEADERS.map((h) => (TEXT_COLUMNS.has(h) && row[h] !== '' + ? excelTextCell(row[h]) + : csvEscape(neutralizeSpreadsheetFormula(row[h])))); + await writeStream(csv, csvCells.join(';') + '\n', csvError); - if (collectIds) collectIds.push(lead.id); - total += 1; - if (total % 10000 === 0) onProgress({ message: `${total} leads gravados...` }); + const xlsCells = HEADERS.map((h) => + `${escapeHtml(TEXT_COLUMNS.has(h) ? row[h] : neutralizeSpreadsheetFormula(row[h]))}` + ); + await writeStream(xls, `${xlsCells.join('')}\n`, xlsError); + + if (collectIds) collectIds.push(lead.id); + total += 1; + if (total % 10000 === 0) onProgress({ message: `${total} leads gravados...` }); + } + + await writeStream(xls, '
${escapeHtml(h)}
\n', xlsError); + await Promise.all([ + endStream(csv, csvError), + endStream(xls, xlsError) + ]); + } catch (error) { + csv.destroy(); + xls.destroy(); + await Promise.all([ + fsp.rm(csvTmpPath, { force: true }), + fsp.rm(xlsTmpPath, { force: true }) + ]); + throw error; } - await writeStream(xls, '\n'); await Promise.all([ - new Promise((resolve, reject) => csv.end((e) => (e ? reject(e) : resolve()))), - new Promise((resolve, reject) => xls.end((e) => (e ? reject(e) : resolve()))) + fsp.rename(csvTmpPath, csvPath), + fsp.rename(xlsTmpPath, xlsPath) ]); return { diff --git a/tests/export.test.mjs b/tests/export.test.mjs index 1842261..18f49f3 100644 --- a/tests/export.test.mjs +++ b/tests/export.test.mjs @@ -45,6 +45,27 @@ test('exportExcel usa nome interno e neutraliza formulas no CSV/XLS', async () = } }); +// Antes, um erro no stream de escrita (disco cheio, pasta inacessivel) virava +// excecao nao capturada e derrubava o processo do job inteiro, sem nunca +// chegar ao catch de export-job.mjs; e o arquivo final podia ficar pela +// metade no lugar do arquivo definitivo. Forca EISDIR criando um diretorio +// no caminho .tmp esperado do CSV, no lugar de um disco cheio de verdade. +test('exportExcel rejeita (nao derruba o processo) quando o stream falha, e nao deixa arquivo pela metade', async () => { + const db = openDb(':memory:'); + seedLead(db); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'pessoal-export-falha-')); + try { + await fs.mkdir(path.join(dir, 'leads-excel.csv.tmp')); + await assert.rejects(() => exportExcel({ db, filtro: {}, dir })); + assert.equal(await fs.stat(path.join(dir, 'leads-excel.csv')).then(() => true, () => false), false); + assert.equal(await fs.stat(path.join(dir, 'leads-excel.xls')).then(() => true, () => false), false); + assert.equal(await fs.stat(path.join(dir, 'leads-excel.xls.tmp')).then(() => true, () => false), false); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + db.close(); + } +}); + test('rota de exportacao rejeita tipos corporativos removidos', async () => { const db = openDb(':memory:'); seedLead(db);