Compare commits

...

16 Commits

Author SHA1 Message Date
8bc4f173e5 v4.1.21 2026-03-05 14:51:14 +00:00
fba2cba8e8 fix(compiler): log emitted files written outside expected destination directory for diagnostics 2026-03-05 14:51:14 +00:00
1033996cb5 v4.1.20 2026-03-05 14:48:05 +00:00
7e8b5c4467 fix(mod_compiler): add diagnostic snapshots for output directories around clear and compile steps 2026-03-05 14:48:05 +00:00
a738716d98 v4.1.19 2026-03-05 14:44:14 +00:00
d9c79ae4eb fix(mod_fs): use synchronous rm to avoid XFS metadata corruption when removing directories 2026-03-05 14:44:14 +00:00
a3255fd1fb v4.1.18 2026-03-05 14:40:05 +00:00
d6fb6e527e fix(mod_compiler): add diagnostic logging of output directory states after compilation and after import-path rewriting to aid debugging 2026-03-05 14:40:05 +00:00
96bafec720 v4.1.17 2026-03-05 14:35:05 +00:00
86f47ff743 fix(tsunpacker): use synchronous fs operations in tsunpacker to avoid readdir race conditions 2026-03-05 14:35:05 +00:00
38c134f084 v4.1.16 2026-03-05 14:30:33 +00:00
25372bf97d fix(mod_unpack): handle partial readdir results from signal-interrupted getdents64 when unpacking to ensure sibling removal and nested moves complete 2026-03-05 14:30:33 +00:00
f521530eed v4.1.15 2026-03-05 14:26:29 +00:00
dd81d65958 fix(mod_unpack): flatten nested output directory without temporary rename steps to avoid race conditions 2026-03-05 14:26:29 +00:00
d1ef48560d v4.1.14 2026-03-05 14:01:00 +00:00
49d62e20a4 fix(fs): replace execSync and fsync workarounds with atomic async FsHelpers operations to avoid XFS races and shell dependencies 2026-03-05 14:01:00 +00:00
6 changed files with 159 additions and 81 deletions

View File

@@ -1,5 +1,64 @@
# Changelog
## 2026-03-05 - 4.1.21 - fix(compiler)
log emitted files written outside expected destination directory for diagnostics
- Adds diagnostic logging for emitted files that are not under the configured destDir, listing up to 20 example paths and reporting the remaining count.
- Logging is conditional: only when not in quiet mode and not emitting JSON.
- Diagnostic runs after compilation (post-compile) and before unpacking of outputs; paths are trimmed using the process cwd for readability.
## 2026-03-05 - 4.1.20 - fix(mod_compiler)
add diagnostic snapshots for output directories around clear and compile steps
- Introduce diagSnap helper to log entry and directory counts for successful output directories when not in quiet or JSON mode
- Call diagSnap before clearing the destination directory, after clearing, and after compilation to aid debugging of missing or unexpected outputs
- Behavior for emitted files and unpacking is unchanged; this is observational/logging-only instrumentation
## 2026-03-05 - 4.1.19 - fix(mod_fs)
use synchronous rm to avoid XFS metadata corruption when removing directories
- Replaced async fs.promises.rm with synchronous fs.rmSync in removeDirectory to avoid observed XFS metadata corruption affecting sibling entries under libuv thread-pool and signal pressure
- Retains previous options: recursive, force, maxRetries, retryDelay
- Adds inline comment documenting the rationale for using a synchronous removal
## 2026-03-05 - 4.1.18 - fix(mod_compiler)
add diagnostic logging of output directory states after compilation and after import-path rewriting to aid debugging
- Imported fs to allow reading output directories for diagnostics
- Logs entries and directory counts for each successful output directory both pre- and post-import-path-rewrite
- Diagnostics are gated by !isQuiet && !isJson and are read-only (no behavior change)
- Tags used: 'diag' (post-compilation) and 'diag-post-rewrite' (after rewriting) to help identify missing or unexpected output folders
## 2026-03-05 - 4.1.17 - fix(tsunpacker)
use synchronous fs operations in tsunpacker to avoid readdir race conditions
- Replaced async fs.promises.readdir/rename/rm/rmdir loops with fs.readdirSync/renameSync/rmSync/rmdirSync
- Removed readdir retry loops that attempted to handle partial/stale readdir results
- Updated comment to document rationale: avoid race conditions under signal pressure and XFS metadata lag
- Note: function remains async but now performs blocking sync filesystem calls which may block the event loop during unpack
## 2026-03-05 - 4.1.16 - fix(mod_unpack)
handle partial readdir results from signal-interrupted getdents64 when unpacking to ensure sibling removal and nested moves complete
- Loop readdir calls for destination directory until only the source folder remains to avoid partial-listing leftovers
- Loop readdir calls for nested directory and repeatedly rename entries until the nested directory is empty
- Prevents leftover files and incomplete moves when readdir returns partial results under signals
## 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

View File

@@ -1,6 +1,6 @@
{
"name": "@git.zone/tsbuild",
"version": "4.1.13",
"version": "4.1.21",
"private": false,
"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",

View File

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

View File

@@ -1,8 +1,6 @@
import type { CompilerOptions, Diagnostic, Program } from 'typescript';
import typescript from 'typescript';
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
import * as smartdelay from '@push.rocks/smartdelay';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartpath from '@push.rocks/smartpath';
@@ -340,13 +338,30 @@ export class TsCompiler {
// Get destination directory as absolute path
const destDir = smartpath.transform.toAbsolute(destPath, this.cwd) as string;
// Diagnostic helper
const diagSnap = (label: string) => {
if (!isQuiet && !isJson) {
for (const prevDir of successfulOutputDirs) {
try {
const entries = fs.readdirSync(prevDir);
const dirs = entries.filter(e => { try { return fs.statSync(prevDir + '/' + e).isDirectory(); } catch { return false; } });
console.log(` 📋 [${label}] ${prevDir.replace(this.cwd + '/', '')}: ${entries.length} entries, ${dirs.length} dirs`);
} catch {
console.log(` 📋 [${label}] ${prevDir.replace(this.cwd + '/', '')}: MISSING!`);
}
}
}
};
// Clear the destination directory before compilation if it exists
diagSnap('pre-clear');
if (await FsHelpers.directoryExists(destDir)) {
if (!isQuiet && !isJson) {
console.log(`🧹 Clearing output directory: ${destPath}`);
}
await FsHelpers.removeDirectory(destDir);
}
diagSnap('post-clear');
// Update compiler options with the output directory
const options: CompilerOptions = {
@@ -367,22 +382,41 @@ export class TsCompiler {
const result = await this.compileFiles(absoluteFiles, options, taskInfo);
emittedFiles.push(...result.emittedFiles);
errorSummaries.push(result.errorSummary);
diagSnap('post-compile');
// Diagnostic: log emitted files that went to unexpected directories
if (!isQuiet && !isJson && result.emittedFiles.length > 0) {
const unexpectedFiles = result.emittedFiles.filter(f => !f.startsWith(destDir + '/') && !f.startsWith(destDir + '\\'));
if (unexpectedFiles.length > 0) {
console.log(` ⚠️ [diag] ${unexpectedFiles.length} files emitted OUTSIDE ${destPath}:`);
for (const f of unexpectedFiles.slice(0, 20)) {
console.log(` ${f.replace(this.cwd + '/', '')}`);
}
if (unexpectedFiles.length > 20) {
console.log(` ... and ${unexpectedFiles.length - 20} more`);
}
}
}
// Perform unpack if compilation succeeded
if (result.errorSummary.totalErrors === 0) {
// Force XFS to commit all pending directory entries before unpacking.
// TypeScript's writeFileSync creates entries that may reside in XFS's
// delayed log. Without sync, readdir can return partial results.
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);
// Diagnostic: log all output directory states after each compilation
if (!isQuiet && !isJson) {
for (const prevDir of successfulOutputDirs) {
try {
const entries = fs.readdirSync(prevDir);
const dirs = entries.filter(e => {
try { return fs.statSync(prevDir + '/' + e).isDirectory(); } catch { return false; }
});
console.log(` 📋 [diag] ${prevDir.replace(this.cwd + '/', '')}: ${entries.length} entries, ${dirs.length} dirs`);
} catch {
console.log(` 📋 [diag] ${prevDir.replace(this.cwd + '/', '')}: MISSING!`);
}
}
}
}
@@ -399,6 +433,21 @@ export class TsCompiler {
if (totalRewritten > 0 && !isQuiet && !isJson) {
console.log(` 🔄 Rewrote import paths in ${totalRewritten} file${totalRewritten !== 1 ? 's' : ''}`);
}
// Diagnostic: log output directory states after path rewriting
if (!isQuiet && !isJson) {
for (const dir of successfulOutputDirs) {
try {
const entries = fs.readdirSync(dir);
const dirs = entries.filter(e => {
try { return fs.statSync(dir + '/' + e).isDirectory(); } catch { return false; }
});
console.log(` 📋 [diag-post-rewrite] ${dir.replace(this.cwd + '/', '')}: ${entries.length} entries, ${dirs.length} dirs`);
} catch {
console.log(` 📋 [diag-post-rewrite] ${dir.replace(this.cwd + '/', '')}: MISSING!`);
}
}
}
}
// Merge all error summaries
@@ -504,27 +553,6 @@ export class TsCompiler {
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(path.join(dirPath, entry.name));
}
}
} catch {
// Ignore errors (directory may not exist)
}
}
/**
* Merge multiple error summaries into one
*/

View File

@@ -122,10 +122,13 @@ export class FsHelpers {
}
/**
* Remove a directory recursively
* Remove a directory recursively.
* Uses synchronous rm to avoid XFS metadata corruption observed with
* async fs.promises.rm affecting sibling directory entries on the
* libuv thread pool under signal pressure.
*/
public static async removeDirectory(dirPath: string): Promise<void> {
await fs.promises.rm(dirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
fs.rmSync(dirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
}
/**
@@ -134,34 +137,4 @@ export class FsHelpers {
public static async move(src: string, dest: string): Promise<void> {
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,5 +1,5 @@
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
import { TsPublishConfig } from '../mod_config/index.js';
import { FsHelpers } from '../mod_fs/index.js';
@@ -83,10 +83,16 @@ export class TsUnpacker {
/**
* Perform the unpack operation - flatten nested output directories.
*
* Uses shell commands (mv, rm) via execSync for reliability. Node.js async
* fs operations (rename, rm) can race on XFS filesystems where metadata
* commits are delayed, causing ENOENT or partial directory listings.
* Shell commands execute as direct syscalls without libuv's async layer.
* 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
*
* Uses synchronous fs operations to avoid race conditions with
* async readdir returning partial/stale results under signal pressure
* or XFS metadata lag (observed in process-group environments like gitzone).
*
* Returns true if unpacking was performed, false if skipped.
*/
@@ -100,14 +106,26 @@ export class TsUnpacker {
}
const nestedPath = this.getNestedPath();
const tempPath = this.destDir + '.__unpack_temp__';
// Use shell commands for atomic, sequential filesystem operations.
// This avoids race conditions between Node.js async fs operations on XFS.
execSync(`rm -rf "${tempPath}"`, { stdio: 'ignore' });
execSync(`mv "${nestedPath}" "${tempPath}"`, { stdio: 'ignore' });
execSync(`rm -rf "${this.destDir}"`, { stdio: 'ignore' });
execSync(`mv "${tempPath}" "${this.destDir}"`, { stdio: 'ignore' });
// Step 1: Remove sibling entries (everything in dest except the source folder)
const destEntries = fs.readdirSync(this.destDir);
for (const entry of destEntries) {
if (entry !== this.sourceFolderName) {
fs.rmSync(path.join(this.destDir, entry), { recursive: true, force: true });
}
}
// Step 2: Move all contents from nested dir up to dest dir
const nestedEntries = fs.readdirSync(nestedPath);
for (const entry of nestedEntries) {
fs.renameSync(
path.join(nestedPath, entry),
path.join(this.destDir, entry),
);
}
// Step 3: Remove the now-empty nested directory
fs.rmdirSync(nestedPath);
return true;
}