From 2531df0ebb7e21be28034c974a437228b5a04761 Mon Sep 17 00:00:00 2001 From: re2zero Date: Thu, 20 Aug 2026 02:16:45 +0800 Subject: [PATCH 1/2] fix(filesystem): use StringDecoder for UTF-8 safe headFile/tailFile headFile() and tailFile() read files in 1024-byte chunks and called .toString('utf-8') on each chunk independently. When a multi-byte UTF-8 character straddled a chunk boundary, the split decoding produced mojibake (U+FFFD replacement characters). Fix headFile by using StringDecoder across sequential reads so that incomplete trailing byte sequences are buffered and completed on the next write() call. Fix tailFile by collecting raw byte buffers from backwards reads, then decoding the concatenated buffer (in forward order) as a single UTF-8 stream with StringDecoder. Newline counting is done on raw bytes (0x0A is single-byte in UTF-8) so we stop reading at the right point. Fixes #4666 --- src/filesystem/lib.ts | 66 ++++++++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index ce4af9f38a..b45c6d5850 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -4,6 +4,7 @@ import os from 'os'; import { randomBytes } from 'crypto'; import { diffLines, createTwoFilesPatch } from 'diff'; import { minimatch } from 'minimatch'; +import { StringDecoder } from 'string_decoder'; import { normalizePath, expandHome } from './path-utils.js'; import { isPathWithinAllowedDirectories } from './path-validation.js'; @@ -281,9 +282,9 @@ export async function applyFileEdits( return formattedDiff; } -// Memory-efficient implementation to get the last N lines of a file +// Read the last N lines of a file (UTF-8 safe) export async function tailFile(filePath: string, numLines: number): Promise { - const CHUNK_SIZE = 1024; // Read 1KB at a time + const CHUNK_SIZE = 1024; const stats = await fs.stat(filePath); const fileSize = stats.size; @@ -292,48 +293,53 @@ export async function tailFile(filePath: string, numLines: number): Promise 0 && linesFound < numLines) { + // Read from the end until we have enough lines or reach start + while (position > 0 && newlineCount <= numLines) { const size = Math.min(CHUNK_SIZE, position); position -= size; const { bytesRead } = await fileHandle.read(chunk, 0, size, position); if (!bytesRead) break; - // Get the chunk as a string and prepend any remaining text from previous iteration - const readData = chunk.slice(0, bytesRead).toString('utf-8'); - const chunkText = readData + remainingText; - - // Split by newlines and count - const chunkLines = normalizeLineEndings(chunkText).split('\n'); + const data = chunk.slice(0, bytesRead); + rawBuffers.push(data); - // If this isn't the end of the file, the first line is likely incomplete - // Save it to prepend to the next chunk - if (position > 0) { - remainingText = chunkLines[0]; - chunkLines.shift(); // Remove the first (incomplete) line - } - - // Add lines to our result (up to the number we need) - for (let i = chunkLines.length - 1; i >= 0 && linesFound < numLines; i--) { - lines.unshift(chunkLines[i]); - linesFound++; + // Count newlines in raw bytes (0x0A is single-byte even in UTF-8) + for (let i = 0; i < data.length; i++) { + if (data[i] === 0x0A) newlineCount++; } } - return lines.join('\n'); + // Reverse to get forward chronological order + rawBuffers.reverse(); + + // Decode the concatenated buffer with StringDecoder (handles cross-chunk UTF-8) + const decoder = new StringDecoder('utf-8'); + const fullText = rawBuffers.map(b => decoder.write(b)).join(''); + const finalChunk = decoder.end(); + + // Split into lines and take the last numLines + const allLines = normalizeLineEndings(fullText + finalChunk).split('\n'); + + // Filter out the last empty element if the file ends with newline + const relevantLines = allLines.filter(Boolean).length > numLines + ? allLines.slice(-numLines - 1) // include empty trailing line + : allLines; + + return allLines.slice(-numLines).join('\n'); } finally { await fileHandle.close(); } +} } } -// New function to get the first N lines of a file +// Read the first N lines of a file (UTF-8 safe) export async function headFile(filePath: string, numLines: number): Promise { const fileHandle = await fs.open(filePath, 'r'); try { @@ -341,13 +347,14 @@ export async function headFile(filePath: string, numLines: number): Promise 0 && lines.length < numLines) { lines.push(buffer); } From fc760fe3f589143601029cae07c31abadc45b568 Mon Sep 17 00:00:00 2001 From: re2zero Date: Thu, 20 Aug 2026 10:58:46 +0800 Subject: [PATCH 2/2] fix(filesystem): fix syntax error - duplicate closing brace in tailFile --- src/filesystem/lib.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index b45c6d5850..ef268884ea 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -336,7 +336,6 @@ export async function tailFile(filePath: string, numLines: number): Promise