From 01b2cfe69c620b218dc9529fdbfed88633b9d140 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 5 Mar 2026 13:15:07 +0000 Subject: [PATCH] fix(unpack): use atomic renames to flatten nested output and make unpacking more reliable --- changelog.md | 9 ++++ ts/00_commitinfo_data.ts | 2 +- ts/mod_fs/classes.fshelpers.ts | 4 +- ts/mod_unpack/classes.tsunpacker.ts | 81 +++++++++++++++-------------- 4 files changed, 53 insertions(+), 43 deletions(-) diff --git a/changelog.md b/changelog.md index 3edcd81..7274a74 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-03-05 - 4.1.10 - fix(unpack) +use atomic renames to flatten nested output and make unpacking more reliable + +- Replace per-entry moves with an atomic 3-step strategy: rename nested dir to a temp location, remove the destination dir, then rename temp back to the destination to avoid partial readdir/move under signal pressure. +- FsHelpers.move switched from sync rename to fs.promises.rename to work with async flows. +- Use fs.promises.rm with retries and explicit temp-dir cleanup to handle previous failed runs. +- Add diagnostic logging and verification of intermediate states. +- Removed the older removeSiblingDirectories and moveNestedContentsUp methods in favor of the new rename-based approach. + ## 2026-03-05 - 4.1.9 - fix(fs) improve filesystem helpers: use sync rename for reliability on certain filesystems; retry rmdir with delays and avoid recursive rm; bump @push.rocks/smartfs to ^1.3.2 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index fa96d24..24dfe9d 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/tsbuild', - version: '4.1.9', + version: '4.1.10', description: 'A tool for compiling TypeScript files using the latest nightly features, offering flexible APIs and a CLI for streamlined development.' } diff --git a/ts/mod_fs/classes.fshelpers.ts b/ts/mod_fs/classes.fshelpers.ts index 4eec59a..4dff7ba 100644 --- a/ts/mod_fs/classes.fshelpers.ts +++ b/ts/mod_fs/classes.fshelpers.ts @@ -130,11 +130,9 @@ export class FsHelpers { /** * Move/rename a file or directory - * Uses renameSync for reliability on XFS/mounted filesystems where async - * rename can be interrupted by signals from process managers */ public static async move(src: string, dest: string): Promise { - fs.renameSync(src, dest); + await fs.promises.rename(src, dest); } /** diff --git a/ts/mod_unpack/classes.tsunpacker.ts b/ts/mod_unpack/classes.tsunpacker.ts index d331c5d..7dc2c5f 100644 --- a/ts/mod_unpack/classes.tsunpacker.ts +++ b/ts/mod_unpack/classes.tsunpacker.ts @@ -81,62 +81,65 @@ export class TsUnpacker { } /** - * Perform the unpack operation - flatten nested output directories - * Returns true if unpacking was performed, false if skipped + * Perform the unpack operation - flatten nested output directories. + * + * Strategy: instead of listing entries and moving them individually (which is + * vulnerable to async readdir returning partial results under signal pressure), + * we rename the entire nested directory out, remove the dest dir, then rename + * the nested directory back as the dest dir. This uses only rename operations + * which are atomic at the kernel level. + * + * Returns true if unpacking was performed, false if skipped. */ public async unpack(): Promise { - // Check if we should unpack based on config if (!(await this.shouldUnpack())) { return false; } - // Check if nested structure exists if (!(await this.detectNesting())) { return false; } const nestedPath = this.getNestedPath(); + const tempPath = this.destDir + '.__unpack_temp__'; - // Delete sibling folders (not the source folder) - await this.removeSiblingDirectories(); + // Log what we're about to do + const nestedEntries = fs.readdirSync(nestedPath); + console.log(` 📦 Unpacking ${this.sourceFolderName}/: ${nestedEntries.length} entries in nested dir`); + console.log(` 📦 Entries: [${nestedEntries.join(', ')}]`); + // Also list the dest dir to see what TypeScript created + const destEntries = fs.readdirSync(this.destDir); + console.log(` 📦 destDir entries: [${destEntries.join(', ')}]`); - // Move contents from nested folder up - await this.moveNestedContentsUp(); + // Clean up any leftover temp dir from a previous failed unpack + await fs.promises.rm(tempPath, { recursive: true, force: true }); - // Remove empty nested folder - await FsHelpers.removeEmptyDirectory(nestedPath); + // Step 1: Rename the nested source folder out to a temp location. + // e.g. dist_ts/ts/ → dist_ts.__unpack_temp__/ + await fs.promises.rename(nestedPath, tempPath); + + // Verify step 1 + const tempEntries = fs.readdirSync(tempPath); + console.log(` 📦 Step 1 (rename to temp): ${tempEntries.length} entries in temp`); + + // Step 2: Remove the dest dir (which now only contains sibling folders + // like ts_interfaces/). Use recursive rm to handle any contents. + await fs.promises.rm(this.destDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + console.log(` 📦 Step 2 (remove dest): done`); + + // Step 3: Rename the temp dir to the dest dir. + // e.g. dist_ts.__unpack_temp__/ → dist_ts/ + await fs.promises.rename(tempPath, this.destDir); + + // Verify final state + const finalEntries = fs.readdirSync(this.destDir); + const finalDirs = finalEntries.filter((e: string) => { + return fs.statSync(path.join(this.destDir, e)).isDirectory(); + }); + console.log(` 📦 Step 3 (rename to dest): ${finalEntries.length} entries (${finalDirs.length} dirs)`); return true; } - - /** - * Remove sibling directories in the destination folder - * (directories other than the source folder being unpacked) - */ - private async removeSiblingDirectories(): Promise { - const entries = await FsHelpers.listDirectory(this.destDir); - for (const entry of entries) { - if (entry.isDirectory && entry.name !== this.sourceFolderName) { - await FsHelpers.removeDirectory(path.join(this.destDir, entry.name)); - } - } - } - - /** - * Move contents from the nested folder up to the destination directory - */ - private async moveNestedContentsUp(): Promise { - const nestedPath = this.getNestedPath(); - const entries = await FsHelpers.listDirectory(nestedPath); - const dirCount = entries.filter(e => e.isDirectory).length; - const fileCount = entries.filter(e => e.isFile).length; - console.log(` 📦 Unpacking ${this.sourceFolderName}/: ${dirCount} directories, ${fileCount} files`); - for (const entry of entries) { - const src = path.join(nestedPath, entry.name); - const dest = path.join(this.destDir, entry.name); - await FsHelpers.move(src, dest); - } - } } /**