From dd81d659589eb1fcf32c712093ab64234d00e369 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 5 Mar 2026 14:26:29 +0000 Subject: [PATCH] fix(mod_unpack): flatten nested output directory without temporary rename steps to avoid race conditions --- changelog.md | 7 +++++ ts/00_commitinfo_data.ts | 2 +- ts/mod_fs/classes.fshelpers.ts | 30 --------------------- ts/mod_unpack/classes.tsunpacker.ts | 41 ++++++++++++++++++++--------- 4 files changed, 36 insertions(+), 44 deletions(-) diff --git a/changelog.md b/changelog.md index 9d70228..fc01361 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-03-05 - 4.1.15 - fix(mod_unpack) +flatten nested output directory without temporary rename steps to avoid race conditions + +- Replace rename-rm-rename strategy with: remove sibling entries in destination, move nested source entries up into the destination, then remove the now-empty nested folder. +- Avoid creating temporary sibling directories and avoid removing the destination directory to reduce filesystem race conditions and metadata lag issues (XFS/NFS/etc.). +- Remove removed removeEmptyDirectory helper and stop using FsHelpers.move/removeDirectory in unpack; import and use fs.promises methods (readdir, rm, rename, rmdir) directly. + ## 2026-03-05 - 4.1.14 - fix(fs) replace execSync and fsync workarounds with atomic async FsHelpers operations to avoid XFS races and shell dependencies diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 7c2b7fe..9bee49b 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.14', + version: '4.1.15', 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 4dff7ba..8ec2bce 100644 --- a/ts/mod_fs/classes.fshelpers.ts +++ b/ts/mod_fs/classes.fshelpers.ts @@ -134,34 +134,4 @@ export class FsHelpers { public static async move(src: string, dest: string): Promise { await fs.promises.rename(src, dest); } - - /** - * Remove an empty directory - */ - public static async removeEmptyDirectory(dirPath: string): Promise { - // Retry rmdir with delays to handle filesystem metadata lag (XFS, NFS, etc.) - // NEVER use recursive rm here — if rmdir fails with ENOTEMPTY, entries may - // still be valid references to renamed files/dirs that haven't fully detached - for (let attempt = 0; attempt < 5; attempt++) { - try { - await fs.promises.rmdir(dirPath); - return; - } catch (err: any) { - if (err.code === 'ENOENT') { - return; // Already gone - } - if (err.code === 'ENOTEMPTY' && attempt < 4) { - // Wait for filesystem metadata to catch up - await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1))); - continue; - } - // Final attempt failed or non-retryable error — leave directory in place - // It will be cleaned up by the next build's "clear output directory" step - if (err.code === 'ENOTEMPTY') { - return; - } - throw err; - } - } - } } diff --git a/ts/mod_unpack/classes.tsunpacker.ts b/ts/mod_unpack/classes.tsunpacker.ts index eb7eca3..3815702 100644 --- a/ts/mod_unpack/classes.tsunpacker.ts +++ b/ts/mod_unpack/classes.tsunpacker.ts @@ -1,3 +1,4 @@ +import * as fs from 'fs'; import * as path from 'path'; import { TsPublishConfig } from '../mod_config/index.js'; import { FsHelpers } from '../mod_fs/index.js'; @@ -82,9 +83,16 @@ export class TsUnpacker { /** * Perform the unpack operation - flatten nested output directories. * - * Renames the nested directory to a temp location, removes the dest dir, - * then renames the temp dir back as dest. Uses only rename operations - * which are atomic at the kernel level. + * When TypeScript compiles files that import from sibling directories, + * it creates a nested structure like dist_ts/ts/ with siblings like + * dist_ts/ts_interfaces/. This method flattens by: + * 1. Removing sibling directories (non-source folders) + * 2. Moving contents of the nested source folder up to the dest dir + * 3. Removing the now-empty nested source folder + * + * This approach never creates temporary sibling directories and never + * removes the destination directory itself, avoiding filesystem race + * conditions observed with the previous rename-rm-rename pattern. * * Returns true if unpacking was performed, false if skipped. */ @@ -98,19 +106,26 @@ export class TsUnpacker { } const nestedPath = this.getNestedPath(); - const tempPath = this.destDir + '.__unpack_temp__'; - // Step 1: Clean up any leftover temp dir from a previous failed run - await FsHelpers.removeDirectory(tempPath); + // Step 1: Remove sibling entries (everything in dest except the source folder) + const destEntries = await fs.promises.readdir(this.destDir); + for (const entry of destEntries) { + if (entry !== this.sourceFolderName) { + await fs.promises.rm(path.join(this.destDir, entry), { recursive: true, force: true }); + } + } - // Step 2: Rename nested → temp - await FsHelpers.move(nestedPath, tempPath); + // Step 2: Move all contents from nested dir up to dest dir + const nestedEntries = await fs.promises.readdir(nestedPath); + for (const entry of nestedEntries) { + await fs.promises.rename( + path.join(nestedPath, entry), + path.join(this.destDir, entry), + ); + } - // Step 3: Remove dest dir (now contains only sibling folders) - await FsHelpers.removeDirectory(this.destDir); - - // Step 4: Rename temp → dest - await FsHelpers.move(tempPath, this.destDir); + // Step 3: Remove the now-empty nested directory + await fs.promises.rmdir(nestedPath); return true; }