fix(mod_unpack): flatten nested output directory without temporary rename steps to avoid race conditions

This commit is contained in:
2026-03-05 14:26:29 +00:00
parent d1ef48560d
commit dd81d65958
4 changed files with 36 additions and 44 deletions

View File

@@ -1,5 +1,12 @@
# Changelog # 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) ## 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 replace execSync and fsync workarounds with atomic async FsHelpers operations to avoid XFS races and shell dependencies

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/tsbuild', 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.' description: 'A tool for compiling TypeScript files using the latest nightly features, offering flexible APIs and a CLI for streamlined development.'
} }

View File

@@ -134,34 +134,4 @@ export class FsHelpers {
public static async move(src: string, dest: string): Promise<void> { public static async move(src: string, dest: string): Promise<void> {
await fs.promises.rename(src, dest); await fs.promises.rename(src, dest);
} }
/**
* Remove an empty directory
*/
public static async removeEmptyDirectory(dirPath: string): Promise<void> {
// 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;
}
}
}
} }

View File

@@ -1,3 +1,4 @@
import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { TsPublishConfig } from '../mod_config/index.js'; import { TsPublishConfig } from '../mod_config/index.js';
import { FsHelpers } from '../mod_fs/index.js'; import { FsHelpers } from '../mod_fs/index.js';
@@ -82,9 +83,16 @@ export class TsUnpacker {
/** /**
* Perform the unpack operation - flatten nested output directories. * Perform the unpack operation - flatten nested output directories.
* *
* Renames the nested directory to a temp location, removes the dest dir, * When TypeScript compiles files that import from sibling directories,
* then renames the temp dir back as dest. Uses only rename operations * it creates a nested structure like dist_ts/ts/ with siblings like
* which are atomic at the kernel level. * 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. * Returns true if unpacking was performed, false if skipped.
*/ */
@@ -98,19 +106,26 @@ export class TsUnpacker {
} }
const nestedPath = this.getNestedPath(); const nestedPath = this.getNestedPath();
const tempPath = this.destDir + '.__unpack_temp__';
// Step 1: Clean up any leftover temp dir from a previous failed run // Step 1: Remove sibling entries (everything in dest except the source folder)
await FsHelpers.removeDirectory(tempPath); 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 // Step 2: Move all contents from nested dir up to dest dir
await FsHelpers.move(nestedPath, tempPath); 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) // Step 3: Remove the now-empty nested directory
await FsHelpers.removeDirectory(this.destDir); await fs.promises.rmdir(nestedPath);
// Step 4: Rename temp → dest
await FsHelpers.move(tempPath, this.destDir);
return true; return true;
} }