Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f521530eed | |||
| dd81d65958 | |||
| d1ef48560d | |||
| 49d62e20a4 | |||
| 4f8443d33f | |||
| 2fb838d1bd | |||
| b4a1ff5eab | |||
| d3e3905e7f |
30
changelog.md
30
changelog.md
@@ -1,5 +1,35 @@
|
|||||||
# 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)
|
||||||
|
replace execSync and fsync workarounds with atomic async FsHelpers operations to avoid XFS races and shell dependencies
|
||||||
|
|
||||||
|
- Removed child_process.execSync usage and shell mv/rm commands in mod_unpack and mod_compiler.
|
||||||
|
- Removed syncDirectoryTree and fsync-based workaround from the compiler module.
|
||||||
|
- Use FsHelpers.move and FsHelpers.removeDirectory (async rename/remove) for atomic filesystem operations during unpack.
|
||||||
|
- Await performUnpack directly and simplify unpack flow to improve portability and reliability on XFS and other filesystems.
|
||||||
|
|
||||||
|
## 2026-03-05 - 4.1.13 - fix(mod_unpack)
|
||||||
|
Use child_process.execSync (mv/rm) to perform unpack atomically, replacing async fs operations and logs to avoid ENOENT/partial directory listings on XFS
|
||||||
|
|
||||||
|
- Replaced async fs.promises.rename/rm and readdir/stat debugging with execSync rm -rf and mv operations for sequential, atomic moves
|
||||||
|
- Imported execSync from child_process and removed verbose console logging and extra fs checks
|
||||||
|
- Addresses race conditions observed on filesystems like XFS where libuv async operations can return partial results or ENOENT errors
|
||||||
|
|
||||||
|
## 2026-03-05 - 4.1.12 - fix(mod_compiler)
|
||||||
|
replace runtime require calls with top-level imports and use execSync/path.join for filesystem sync and traversal
|
||||||
|
|
||||||
|
- Added top-level imports: path and execSync from child_process
|
||||||
|
- Replaced require('child_process').execSync('sync') with execSync('sync') to force fs sync
|
||||||
|
- Replaced require('path').join(...) with path.join(...) when recursing directories
|
||||||
|
- Refactor is purely local/maintenance-focused (consistency and slight performance/readability improvement); no functional change expected
|
||||||
|
|
||||||
## 2026-03-05 - 4.1.11 - fix(mod_compiler)
|
## 2026-03-05 - 4.1.11 - fix(mod_compiler)
|
||||||
flush directory entries before unpack to avoid XFS delayed-log causing partial readdir results
|
flush directory entries before unpack to avoid XFS delayed-log causing partial readdir results
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@git.zone/tsbuild",
|
"name": "@git.zone/tsbuild",
|
||||||
"version": "4.1.11",
|
"version": "4.1.15",
|
||||||
"private": false,
|
"private": false,
|
||||||
"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.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@git.zone/tsbuild',
|
name: '@git.zone/tsbuild',
|
||||||
version: '4.1.11',
|
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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { CompilerOptions, Diagnostic, Program } from 'typescript';
|
import type { CompilerOptions, Diagnostic, Program } from 'typescript';
|
||||||
import typescript from 'typescript';
|
import typescript from 'typescript';
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as smartdelay from '@push.rocks/smartdelay';
|
import * as smartdelay from '@push.rocks/smartdelay';
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
@@ -368,18 +367,7 @@ export class TsCompiler {
|
|||||||
|
|
||||||
// Perform unpack if compilation succeeded
|
// Perform unpack if compilation succeeded
|
||||||
if (result.errorSummary.totalErrors === 0) {
|
if (result.errorSummary.totalErrors === 0) {
|
||||||
// Force XFS to commit all pending directory entries before unpacking.
|
await performUnpack(pattern, destDir, this.cwd);
|
||||||
// TypeScript's writeFileSync creates entries that may reside in XFS's
|
|
||||||
// delayed log. Without sync, readdir can return partial results.
|
|
||||||
require('child_process').execSync('sync');
|
|
||||||
this.syncDirectoryTree(destDir);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await performUnpack(pattern, destDir, this.cwd);
|
|
||||||
} catch (unpackErr: any) {
|
|
||||||
console.error(` ⚠️ Unpack error for ${destPath}: ${unpackErr.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
successfulOutputDirs.push(destDir);
|
successfulOutputDirs.push(destDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,27 +490,6 @@ export class TsCompiler {
|
|||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively fsync all directories in a tree.
|
|
||||||
* Forces XFS to commit pending directory entries from its log.
|
|
||||||
*/
|
|
||||||
private syncDirectoryTree(dirPath: string): void {
|
|
||||||
try {
|
|
||||||
const fd = fs.openSync(dirPath, 'r');
|
|
||||||
fs.fsyncSync(fd);
|
|
||||||
fs.closeSync(fd);
|
|
||||||
|
|
||||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
this.syncDirectoryTree(require('path').join(dirPath, entry.name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors (directory may not exist)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge multiple error summaries into one
|
* Merge multiple error summaries into one
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,11 +83,16 @@ export class TsUnpacker {
|
|||||||
/**
|
/**
|
||||||
* Perform the unpack operation - flatten nested output directories.
|
* Perform the unpack operation - flatten nested output directories.
|
||||||
*
|
*
|
||||||
* Strategy: instead of listing entries and moving them individually (which is
|
* When TypeScript compiles files that import from sibling directories,
|
||||||
* vulnerable to async readdir returning partial results under signal pressure),
|
* it creates a nested structure like dist_ts/ts/ with siblings like
|
||||||
* we rename the entire nested directory out, remove the dest dir, then rename
|
* dist_ts/ts_interfaces/. This method flattens by:
|
||||||
* the nested directory back as the dest dir. This uses only rename operations
|
* 1. Removing sibling directories (non-source folders)
|
||||||
* which are atomic at the kernel level.
|
* 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.
|
||||||
*/
|
*/
|
||||||
@@ -101,42 +106,26 @@ export class TsUnpacker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nestedPath = this.getNestedPath();
|
const nestedPath = this.getNestedPath();
|
||||||
const tempPath = this.destDir + '.__unpack_temp__';
|
|
||||||
|
|
||||||
// Log what we're about to do
|
// Step 1: Remove sibling entries (everything in dest except the source folder)
|
||||||
const nestedEntries = fs.readdirSync(nestedPath);
|
const destEntries = await fs.promises.readdir(this.destDir);
|
||||||
console.log(` 📦 Unpacking ${this.sourceFolderName}/: ${nestedEntries.length} entries in nested dir`);
|
for (const entry of destEntries) {
|
||||||
console.log(` 📦 Entries: [${nestedEntries.join(', ')}]`);
|
if (entry !== this.sourceFolderName) {
|
||||||
// Also list the dest dir to see what TypeScript created
|
await fs.promises.rm(path.join(this.destDir, entry), { recursive: true, force: true });
|
||||||
const destEntries = fs.readdirSync(this.destDir);
|
}
|
||||||
console.log(` 📦 destDir entries: [${destEntries.join(', ')}]`);
|
}
|
||||||
|
|
||||||
// Clean up any leftover temp dir from a previous failed unpack
|
// Step 2: Move all contents from nested dir up to dest dir
|
||||||
await fs.promises.rm(tempPath, { recursive: true, force: true });
|
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 1: Rename the nested source folder out to a temp location.
|
// Step 3: Remove the now-empty nested directory
|
||||||
// e.g. dist_ts/ts/ → dist_ts.__unpack_temp__/
|
await fs.promises.rmdir(nestedPath);
|
||||||
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user