From f6d58c5402f865b7a30499004e373df5e7ae16a5 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Sep 2025 20:35:40 +0300 Subject: [PATCH 1/9] Initial commit with task details for issue #44 Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: https://github.com/link-foundation/command-stream/issues/44 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7f55d7a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Issue to solve: https://github.com/link-foundation/command-stream/issues/44 +Your prepared branch: issue-44-796ff0a8 +Your prepared working directory: /tmp/gh-issue-solver-1757439335152 + +Proceed. \ No newline at end of file From 631d6546b9d49d81b3a9a4e388b58981ed6c07d4 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Sep 2025 20:35:57 +0300 Subject: [PATCH 2/9] Remove CLAUDE.md - PR created successfully --- CLAUDE.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 7f55d7a..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -Issue to solve: https://github.com/link-foundation/command-stream/issues/44 -Your prepared branch: issue-44-796ff0a8 -Your prepared working directory: /tmp/gh-issue-solver-1757439335152 - -Proceed. \ No newline at end of file From 0db0a5af232bb4d704c6f7a77592eca9598ed7e9 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Sep 2025 20:42:02 +0300 Subject: [PATCH 3/9] Fix getcwd() failed error in subshells and directory operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change addresses issue #44 where getcwd() system call failures would cause command-stream to crash in certain scenarios, particularly when the current directory has been deleted or permissions have changed. Changes: - Added error handling around process.cwd() calls in _runSubshell method - Improved directory restoration logic with comprehensive fallback handling - Added graceful handling when original directory cannot be obtained - Enhanced error logging with better context information The fix ensures that: - Commands continue to work even when getcwd() fails - Directory restoration gracefully falls back to safe locations (HOME, /, etc.) - No unhandled exceptions are thrown due to directory access issues - CI/CD environments with temporary directories work reliably Tests added: - Comprehensive test cases for getcwd() error scenarios - Subshell-specific error handling tests - Directory restoration error handling tests - Debug examples for reproducing the issue ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- examples/debug-getcwd-error.mjs | 108 ++++++++++++++++++++++++ examples/debug-subshell-getcwd.mjs | 94 +++++++++++++++++++++ src/$.mjs | 62 +++++++++++--- tests/getcwd-error-handling.test.mjs | 118 +++++++++++++++++++++++++++ 4 files changed, 370 insertions(+), 12 deletions(-) create mode 100755 examples/debug-getcwd-error.mjs create mode 100644 examples/debug-subshell-getcwd.mjs create mode 100644 tests/getcwd-error-handling.test.mjs diff --git a/examples/debug-getcwd-error.mjs b/examples/debug-getcwd-error.mjs new file mode 100755 index 0000000..9f1aee1 --- /dev/null +++ b/examples/debug-getcwd-error.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env bun +// Test case to reproduce the getcwd() failed error +// This script demonstrates the issue that occurs when the current directory is deleted + +import { $ } from '../src/$.mjs'; +import fs from 'fs/promises'; + +const originalDir = process.cwd(); + +console.log('๐Ÿงช Testing getcwd() error scenarios'); + +async function testGetcwdError() { + const tempDir = `/tmp/test-getcwd-${Date.now()}`; + + try { + console.log(`๐Ÿ“ Creating temp directory: ${tempDir}`); + await fs.mkdir(tempDir); + + console.log(`๐Ÿ“‚ Changing to temp directory: ${tempDir}`); + process.chdir(tempDir); + + console.log(`โœ… Current directory: ${process.cwd()}`); + + // Test 1: Run command while in valid directory + console.log('\n๐Ÿ” Test 1: Running command in valid directory'); + try { + const result1 = await $`echo "test from valid dir"`; + console.log(`โœ… Command succeeded: ${result1.stdout.trim()}`); + } catch (error) { + console.log(`โŒ Command failed: ${error.message}`); + } + + // Now delete the directory while we're still in it + console.log(`\n๐Ÿ—‘๏ธ Deleting directory while we're still in it: ${tempDir}`); + process.chdir('/tmp'); // Move out first to be able to delete + await fs.rmdir(tempDir); + + // Try to go back to the deleted directory + console.log(`\n๐Ÿ“‚ Attempting to change to deleted directory: ${tempDir}`); + try { + process.chdir(tempDir); + console.log(`โš ๏ธ Unexpectedly succeeded changing to deleted directory`); + } catch (error) { + console.log(`โœ… Expected failure changing to deleted directory: ${error.message}`); + } + + // Now let's simulate being stuck in a deleted directory by using a different approach + console.log(`\n๐Ÿ” Test 2: Simulating deleted directory scenario`); + + // Create a new temp dir and change to it + const tempDir2 = `/tmp/test-getcwd-2-${Date.now()}`; + await fs.mkdir(tempDir2); + process.chdir(tempDir2); + + // Mock process.cwd to throw an error (simulating a deleted directory scenario) + const originalCwd = process.cwd; + let cwdCallCount = 0; + + // Override process.cwd to fail after first call + process.cwd = function() { + cwdCallCount++; + if (cwdCallCount > 1) { + const error = new Error('getcwd() failed: No such file or directory'); + error.errno = -2; + error.code = 'ENOENT'; + throw error; + } + return originalCwd.call(this); + }; + + try { + console.log(`๐Ÿ“ First cwd call (should succeed): ${process.cwd()}`); + console.log(`๐Ÿ“ Second cwd call (should fail): ${process.cwd()}`); + } catch (error) { + console.log(`โœ… Expected getcwd() error: ${error.message}`); + + // Now try to run a command that might trigger the error + console.log(`\n๐Ÿ” Test 3: Running command after getcwd() failure`); + try { + const result2 = await $`echo "test after getcwd failure"`; + console.log(`โœ… Command succeeded despite getcwd issue: ${result2.stdout.trim()}`); + } catch (error) { + console.log(`โŒ Command failed due to getcwd issue: ${error.message}`); + console.log(` Stack trace: ${error.stack}`); + } + } finally { + // Restore original process.cwd + process.cwd = originalCwd; + process.chdir('/tmp'); + await fs.rmdir(tempDir2).catch(() => {}); + } + + } catch (error) { + console.log(`โŒ Test setup error: ${error.message}`); + } finally { + // Always restore original directory + try { + process.chdir(originalDir); + console.log(`\n๐Ÿ  Restored to original directory: ${originalDir}`); + } catch (error) { + console.log(`โŒ Failed to restore original directory: ${error.message}`); + } + } +} + +// Run the test +await testGetcwdError(); +console.log('\nโœจ Test completed'); \ No newline at end of file diff --git a/examples/debug-subshell-getcwd.mjs b/examples/debug-subshell-getcwd.mjs new file mode 100644 index 0000000..4b92935 --- /dev/null +++ b/examples/debug-subshell-getcwd.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env bun +// Test case to reproduce the getcwd() failed error in subshells +// This targets the specific code path in _runSubshell that saves cwd + +import { $ } from '../src/$.mjs'; +import fs from 'fs/promises'; + +const originalDir = process.cwd(); + +console.log('๐Ÿงช Testing getcwd() error in subshell scenarios'); + +async function testSubshellGetcwdError() { + const tempDir = `/tmp/test-subshell-${Date.now()}`; + + try { + console.log(`๐Ÿ“ Creating temp directory: ${tempDir}`); + await fs.mkdir(tempDir); + process.chdir(tempDir); + + // Test with subshell command that should trigger _runSubshell + console.log(`\n๐Ÿ” Test 1: Running subshell command in valid directory`); + try { + const result1 = await $`(echo "subshell test")`; + console.log(`โœ… Subshell command succeeded: ${result1.stdout.trim()}`); + } catch (error) { + console.log(`โŒ Subshell command failed: ${error.message}`); + } + + // Now simulate the getcwd() failure scenario + const originalCwd = process.cwd; + + // Override process.cwd to fail when called from _runSubshell + process.cwd = function() { + const stack = new Error().stack; + if (stack.includes('_runSubshell')) { + const error = new Error('getcwd() failed: No such file or directory'); + error.errno = -2; + error.code = 'ENOENT'; + throw error; + } + return originalCwd.call(this); + }; + + console.log(`\n๐Ÿ” Test 2: Running subshell command with getcwd() failure`); + try { + const result2 = await $`(echo "subshell with getcwd failure")`; + console.log(`โœ… Subshell command succeeded despite getcwd failure: ${result2.stdout.trim()}`); + } catch (error) { + console.log(`โŒ Subshell command failed due to getcwd failure: ${error.message}`); + console.log(` Stack trace: ${error.stack}`); + } finally { + // Restore original process.cwd + process.cwd = originalCwd; + } + + // Test with command sequence that might use subshells + console.log(`\n๐Ÿ” Test 3: Running command sequence`); + try { + process.cwd = function() { + const stack = new Error().stack; + if (stack.includes('_runSubshell') || stack.includes('savedCwd')) { + const error = new Error('getcwd() failed: No such file or directory'); + error.errno = -2; + error.code = 'ENOENT'; + throw error; + } + return originalCwd.call(this); + }; + + const result3 = await $`echo "first"; echo "second"`; + console.log(`โœ… Command sequence succeeded: ${result3.stdout.trim()}`); + } catch (error) { + console.log(`โŒ Command sequence failed: ${error.message}`); + console.log(` Stack trace: ${error.stack}`); + } finally { + process.cwd = originalCwd; + } + + } catch (error) { + console.log(`โŒ Test setup error: ${error.message}`); + } finally { + // Always restore original directory + try { + process.chdir(originalDir); + console.log(`\n๐Ÿ  Restored to original directory: ${originalDir}`); + } catch (error) { + console.log(`โŒ Failed to restore original directory: ${error.message}`); + } + } +} + +// Run the test +await testSubshellGetcwdError(); +console.log('\nโœจ Test completed'); \ No newline at end of file diff --git a/src/$.mjs b/src/$.mjs index 46c7258..10be7c7 100755 --- a/src/$.mjs +++ b/src/$.mjs @@ -3702,8 +3702,16 @@ class ProcessRunner extends StreamEmitter { commandType: subshell.command.type }, null, 2)}`); - // Save current directory - const savedCwd = process.cwd(); + // Save current directory - handle getcwd() failures gracefully + let savedCwd; + try { + savedCwd = process.cwd(); + trace('ProcessRunner', () => `Saved current directory: ${savedCwd}`); + } catch (e) { + // getcwd() failed - likely in a deleted directory + trace('ProcessRunner', () => `Failed to get current directory (${e.message}), using fallback`); + savedCwd = null; // Will trigger fallback logic in finally block + } try { // Execute subshell command @@ -3720,20 +3728,50 @@ class ProcessRunner extends StreamEmitter { return result; } finally { - // Restore directory - check if it still exists first - trace('ProcessRunner', () => `Restoring cwd from ${process.cwd()} to ${savedCwd}`); - const fs = await import('fs'); - if (fs.existsSync(savedCwd)) { - process.chdir(savedCwd); - } else { - // If the saved directory was deleted, try to go to a safe location + // Restore directory - handle cases where savedCwd is null or current dir is invalid + if (savedCwd === null) { + // We couldn't get the original directory, try to restore to a safe location const fallbackDir = process.env.HOME || process.env.USERPROFILE || '/'; - trace('ProcessRunner', () => `Saved directory ${savedCwd} no longer exists, falling back to ${fallbackDir}`); + trace('ProcessRunner', () => `Cannot restore directory (original getcwd failed), falling back to ${fallbackDir}`); try { process.chdir(fallbackDir); } catch (e) { - // If even fallback fails, just stay where we are - trace('ProcessRunner', () => `Failed to restore directory: ${e.message}`); + trace('ProcessRunner', () => `Failed to restore to fallback directory: ${e.message}`); + } + } else { + // We have a savedCwd, try to restore it + let currentDir; + try { + currentDir = process.cwd(); + } catch (e) { + currentDir = ''; + } + trace('ProcessRunner', () => `Restoring cwd from ${currentDir} to ${savedCwd}`); + + const fs = await import('fs'); + if (fs.existsSync(savedCwd)) { + try { + process.chdir(savedCwd); + } catch (e) { + trace('ProcessRunner', () => `Failed to restore to saved directory: ${e.message}`); + // Try fallback + const fallbackDir = process.env.HOME || process.env.USERPROFILE || '/'; + try { + process.chdir(fallbackDir); + } catch (e2) { + trace('ProcessRunner', () => `Failed to restore to fallback directory: ${e2.message}`); + } + } + } else { + // If the saved directory was deleted, try to go to a safe location + const fallbackDir = process.env.HOME || process.env.USERPROFILE || '/'; + trace('ProcessRunner', () => `Saved directory ${savedCwd} no longer exists, falling back to ${fallbackDir}`); + try { + process.chdir(fallbackDir); + } catch (e) { + // If even fallback fails, just stay where we are + trace('ProcessRunner', () => `Failed to restore directory: ${e.message}`); + } } } } diff --git a/tests/getcwd-error-handling.test.mjs b/tests/getcwd-error-handling.test.mjs new file mode 100644 index 0000000..293a776 --- /dev/null +++ b/tests/getcwd-error-handling.test.mjs @@ -0,0 +1,118 @@ +import { expect, test, describe } from "bun:test"; +import { $ } from '../src/$.mjs'; + +describe('getcwd() error handling', () => { + test('should handle getcwd() failures in subshells gracefully', async () => { + const originalDir = process.cwd(); + const originalCwd = process.cwd; + let cwdCallCount = 0; + + try { + // Mock process.cwd to fail when called from _runSubshell + process.cwd = function() { + cwdCallCount++; + const stack = new Error().stack; + if (stack.includes('_runSubshell') && cwdCallCount > 1) { + const error = new Error('getcwd() failed: No such file or directory'); + error.errno = -2; + error.code = 'ENOENT'; + throw error; + } + return originalCwd.call(this); + }; + + // This should not throw despite the getcwd() failure + const result = await $`(echo "test subshell")`; + expect(result.stdout.toString().trim()).toBe('test subshell'); + expect(result.code).toBe(0); + + } finally { + // Restore original process.cwd + process.cwd = originalCwd; + try { + process.chdir(originalDir); + } catch (e) { + // Ignore restoration errors in test + } + } + }); + + test('should handle getcwd() failures during directory restoration', async () => { + const originalDir = process.cwd(); + const originalCwd = process.cwd; + let restorePhase = false; + + try { + // Mock process.cwd to fail during directory restoration + process.cwd = function() { + const stack = new Error().stack; + if (restorePhase && stack.includes('finally')) { + const error = new Error('getcwd() failed: No such file or directory'); + error.errno = -2; + error.code = 'ENOENT'; + throw error; + } + return originalCwd.call(this); + }; + + // Enable restore phase failure after command starts + setTimeout(() => { restorePhase = true; }, 10); + + // This should complete successfully despite restoration issues + const result = await $`(echo "test restoration")`; + expect(result.stdout.toString().trim()).toBe('test restoration'); + expect(result.code).toBe(0); + + } finally { + // Restore original process.cwd + process.cwd = originalCwd; + try { + process.chdir(originalDir); + } catch (e) { + // Ignore restoration errors in test + } + } + }); + + test('should continue working after getcwd() errors', async () => { + const originalDir = process.cwd(); + const originalCwd = process.cwd; + let failureCount = 0; + + try { + // Mock process.cwd to fail a few times then succeed + process.cwd = function() { + const stack = new Error().stack; + if (stack.includes('_runSubshell') && failureCount < 2) { + failureCount++; + const error = new Error('getcwd() failed: No such file or directory'); + error.errno = -2; + error.code = 'ENOENT'; + throw error; + } + return originalCwd.call(this); + }; + + // First command should work despite getcwd() failure + const result1 = await $`(echo "first")`; + expect(result1.stdout.toString().trim()).toBe('first'); + + // Second command should work despite getcwd() failure + const result2 = await $`(echo "second")`; + expect(result2.stdout.toString().trim()).toBe('second'); + + // Third command should work normally (no more failures) + const result3 = await $`(echo "third")`; + expect(result3.stdout.toString().trim()).toBe('third'); + + } finally { + // Restore original process.cwd + process.cwd = originalCwd; + try { + process.chdir(originalDir); + } catch (e) { + // Ignore restoration errors in test + } + } + }); +}); \ No newline at end of file From 1399a896447e829d4b6d9a18a33396a93c6d8198 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Jun 2026 14:17:06 +0000 Subject: [PATCH 4/9] Handle getcwd() failures in subshell execution (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-apply the getcwd() error handling to the modular ProcessRunner after the monorepo restructure. process.cwd() throws "getcwd() failed" when the current directory has been deleted or becomes inaccessible (common in CI/CD with temporary directories). - Add safeCwd() helper that returns null instead of throwing - _runSubshell captures the cwd via safeCwd() so it no longer crashes - restoreCwd() falls back to a safe location when the saved dir is gone/null - _runSimpleCommand falls back to the inherited cwd when getcwd() fails - Rewrite tests to genuinely reproduce the bug (fails without the fix) - Add changeset ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- js/.changeset/getcwd-error-handling.md | 11 ++ js/examples/debug-getcwd-error.mjs | 37 +++-- js/examples/debug-subshell-getcwd.mjs | 29 ++-- js/src/$.process-runner-orchestration.mjs | 62 +++++-- js/tests/getcwd-error-handling.test.mjs | 194 ++++++++++------------ 5 files changed, 189 insertions(+), 144 deletions(-) create mode 100644 js/.changeset/getcwd-error-handling.md diff --git a/js/.changeset/getcwd-error-handling.md b/js/.changeset/getcwd-error-handling.md new file mode 100644 index 0000000..e1ccdcd --- /dev/null +++ b/js/.changeset/getcwd-error-handling.md @@ -0,0 +1,11 @@ +--- +'command-stream': patch +--- + +Handle `getcwd() failed` errors gracefully during subshell execution (issue #44). + +`process.cwd()` throws `getcwd() failed: No such file or directory` when the current working directory has been deleted or becomes inaccessible (common in CI/CD with temporary directories). Subshell execution and directory restoration now degrade gracefully instead of crashing: + +- capturing the working directory before a subshell no longer throws when `getcwd()` fails +- directory restoration falls back to a safe location (`HOME`, then `/`) when the original directory is gone +- simple commands fall back to the inherited `cwd` when `getcwd()` is unavailable diff --git a/js/examples/debug-getcwd-error.mjs b/js/examples/debug-getcwd-error.mjs index 9f1aee1..1df1717 100755 --- a/js/examples/debug-getcwd-error.mjs +++ b/js/examples/debug-getcwd-error.mjs @@ -11,16 +11,16 @@ console.log('๐Ÿงช Testing getcwd() error scenarios'); async function testGetcwdError() { const tempDir = `/tmp/test-getcwd-${Date.now()}`; - + try { console.log(`๐Ÿ“ Creating temp directory: ${tempDir}`); await fs.mkdir(tempDir); - + console.log(`๐Ÿ“‚ Changing to temp directory: ${tempDir}`); process.chdir(tempDir); - + console.log(`โœ… Current directory: ${process.cwd()}`); - + // Test 1: Run command while in valid directory console.log('\n๐Ÿ” Test 1: Running command in valid directory'); try { @@ -29,35 +29,37 @@ async function testGetcwdError() { } catch (error) { console.log(`โŒ Command failed: ${error.message}`); } - + // Now delete the directory while we're still in it console.log(`\n๐Ÿ—‘๏ธ Deleting directory while we're still in it: ${tempDir}`); process.chdir('/tmp'); // Move out first to be able to delete await fs.rmdir(tempDir); - + // Try to go back to the deleted directory console.log(`\n๐Ÿ“‚ Attempting to change to deleted directory: ${tempDir}`); try { process.chdir(tempDir); console.log(`โš ๏ธ Unexpectedly succeeded changing to deleted directory`); } catch (error) { - console.log(`โœ… Expected failure changing to deleted directory: ${error.message}`); + console.log( + `โœ… Expected failure changing to deleted directory: ${error.message}` + ); } - + // Now let's simulate being stuck in a deleted directory by using a different approach console.log(`\n๐Ÿ” Test 2: Simulating deleted directory scenario`); - + // Create a new temp dir and change to it const tempDir2 = `/tmp/test-getcwd-2-${Date.now()}`; await fs.mkdir(tempDir2); process.chdir(tempDir2); - + // Mock process.cwd to throw an error (simulating a deleted directory scenario) const originalCwd = process.cwd; let cwdCallCount = 0; - + // Override process.cwd to fail after first call - process.cwd = function() { + process.cwd = function () { cwdCallCount++; if (cwdCallCount > 1) { const error = new Error('getcwd() failed: No such file or directory'); @@ -67,18 +69,20 @@ async function testGetcwdError() { } return originalCwd.call(this); }; - + try { console.log(`๐Ÿ“ First cwd call (should succeed): ${process.cwd()}`); console.log(`๐Ÿ“ Second cwd call (should fail): ${process.cwd()}`); } catch (error) { console.log(`โœ… Expected getcwd() error: ${error.message}`); - + // Now try to run a command that might trigger the error console.log(`\n๐Ÿ” Test 3: Running command after getcwd() failure`); try { const result2 = await $`echo "test after getcwd failure"`; - console.log(`โœ… Command succeeded despite getcwd issue: ${result2.stdout.trim()}`); + console.log( + `โœ… Command succeeded despite getcwd issue: ${result2.stdout.trim()}` + ); } catch (error) { console.log(`โŒ Command failed due to getcwd issue: ${error.message}`); console.log(` Stack trace: ${error.stack}`); @@ -89,7 +93,6 @@ async function testGetcwdError() { process.chdir('/tmp'); await fs.rmdir(tempDir2).catch(() => {}); } - } catch (error) { console.log(`โŒ Test setup error: ${error.message}`); } finally { @@ -105,4 +108,4 @@ async function testGetcwdError() { // Run the test await testGetcwdError(); -console.log('\nโœจ Test completed'); \ No newline at end of file +console.log('\nโœจ Test completed'); diff --git a/js/examples/debug-subshell-getcwd.mjs b/js/examples/debug-subshell-getcwd.mjs index 4b92935..b820691 100644 --- a/js/examples/debug-subshell-getcwd.mjs +++ b/js/examples/debug-subshell-getcwd.mjs @@ -11,12 +11,12 @@ console.log('๐Ÿงช Testing getcwd() error in subshell scenarios'); async function testSubshellGetcwdError() { const tempDir = `/tmp/test-subshell-${Date.now()}`; - + try { console.log(`๐Ÿ“ Creating temp directory: ${tempDir}`); await fs.mkdir(tempDir); process.chdir(tempDir); - + // Test with subshell command that should trigger _runSubshell console.log(`\n๐Ÿ” Test 1: Running subshell command in valid directory`); try { @@ -25,12 +25,12 @@ async function testSubshellGetcwdError() { } catch (error) { console.log(`โŒ Subshell command failed: ${error.message}`); } - + // Now simulate the getcwd() failure scenario const originalCwd = process.cwd; - + // Override process.cwd to fail when called from _runSubshell - process.cwd = function() { + process.cwd = function () { const stack = new Error().stack; if (stack.includes('_runSubshell')) { const error = new Error('getcwd() failed: No such file or directory'); @@ -40,23 +40,27 @@ async function testSubshellGetcwdError() { } return originalCwd.call(this); }; - + console.log(`\n๐Ÿ” Test 2: Running subshell command with getcwd() failure`); try { const result2 = await $`(echo "subshell with getcwd failure")`; - console.log(`โœ… Subshell command succeeded despite getcwd failure: ${result2.stdout.trim()}`); + console.log( + `โœ… Subshell command succeeded despite getcwd failure: ${result2.stdout.trim()}` + ); } catch (error) { - console.log(`โŒ Subshell command failed due to getcwd failure: ${error.message}`); + console.log( + `โŒ Subshell command failed due to getcwd failure: ${error.message}` + ); console.log(` Stack trace: ${error.stack}`); } finally { // Restore original process.cwd process.cwd = originalCwd; } - + // Test with command sequence that might use subshells console.log(`\n๐Ÿ” Test 3: Running command sequence`); try { - process.cwd = function() { + process.cwd = function () { const stack = new Error().stack; if (stack.includes('_runSubshell') || stack.includes('savedCwd')) { const error = new Error('getcwd() failed: No such file or directory'); @@ -66,7 +70,7 @@ async function testSubshellGetcwdError() { } return originalCwd.call(this); }; - + const result3 = await $`echo "first"; echo "second"`; console.log(`โœ… Command sequence succeeded: ${result3.stdout.trim()}`); } catch (error) { @@ -75,7 +79,6 @@ async function testSubshellGetcwdError() { } finally { process.cwd = originalCwd; } - } catch (error) { console.log(`โŒ Test setup error: ${error.message}`); } finally { @@ -91,4 +94,4 @@ async function testSubshellGetcwdError() { // Run the test await testSubshellGetcwdError(); -console.log('\nโœจ Test completed'); \ No newline at end of file +console.log('\nโœจ Test completed'); diff --git a/js/src/$.process-runner-orchestration.mjs b/js/src/$.process-runner-orchestration.mjs index 26c3d11..0cde381 100644 --- a/js/src/$.process-runner-orchestration.mjs +++ b/js/src/$.process-runner-orchestration.mjs @@ -3,6 +3,25 @@ import { trace } from './$.trace.mjs'; +/** + * Safely get the current working directory. + * + * `process.cwd()` throws "getcwd() failed" when the current directory has been + * deleted or becomes inaccessible (common in CI/CD with temporary directories). + * This helper returns null instead of throwing so callers can fall back to a + * safe location. + * + * @returns {string|null} The current working directory, or null if unavailable + */ +function safeCwd() { + try { + return process.cwd(); + } catch (e) { + trace('ProcessRunner', () => `process.cwd() failed: ${e.message}`); + return null; + } +} + /** * Execute a command based on its type * @param {object} runner - The ProcessRunner instance @@ -29,23 +48,35 @@ function executeCommand(runner, command) { async function restoreCwd(savedCwd) { trace( 'ProcessRunner', - () => `Restoring cwd from ${process.cwd()} to ${savedCwd}` + () => `Restoring cwd from ${safeCwd() ?? ''} to ${savedCwd}` ); const fs = await import('fs'); - if (fs.existsSync(savedCwd)) { - process.chdir(savedCwd); + const fallbackDir = process.env.HOME || process.env.USERPROFILE || '/'; + + // If we never captured the original directory (getcwd() failed), or the + // saved directory no longer exists, restore to a safe fallback location. + if (savedCwd && fs.existsSync(savedCwd)) { + try { + process.chdir(savedCwd); + return; + } catch (e) { + trace( + 'ProcessRunner', + () => `Failed to restore to saved directory: ${e.message}` + ); + } } else { - const fallbackDir = process.env.HOME || process.env.USERPROFILE || '/'; trace( 'ProcessRunner', () => - `Saved directory ${savedCwd} no longer exists, falling back to ${fallbackDir}` + `Saved directory ${savedCwd ?? ''} cannot be restored, falling back to ${fallbackDir}` ); - try { - process.chdir(fallbackDir); - } catch (e) { - trace('ProcessRunner', () => `Failed to restore directory: ${e.message}`); - } + } + + try { + process.chdir(fallbackDir); + } catch (e) { + trace('ProcessRunner', () => `Failed to restore directory: ${e.message}`); } } @@ -162,7 +193,11 @@ export function attachOrchestrationMethods(ProcessRunner, deps) { () => `_runSubshell ENTER | ${JSON.stringify({ commandType: subshell.command.type }, null, 2)}` ); - const savedCwd = process.cwd(); + // Capture the current directory so it can be restored after the subshell. + // Use safeCwd() because process.cwd() throws "getcwd() failed" when the + // current directory has been deleted; in that case restoreCwd() falls back + // to a safe location. + const savedCwd = safeCwd(); try { return await executeCommand(this, subshell.command); } finally { @@ -191,9 +226,12 @@ export function attachOrchestrationMethods(ProcessRunner, deps) { trace('ProcessRunner', () => `Executing real command: ${commandStr}`); const ProcessRunnerRef = this.constructor; + // Fall back to the inherited cwd (or undefined) when getcwd() fails so the + // command still runs instead of crashing with "getcwd() failed". + const currentCwd = safeCwd() ?? this.options.cwd; const runner = new ProcessRunnerRef( { mode: 'shell', command: commandStr }, - { ...this.options, cwd: process.cwd(), _bypassVirtual: true } + { ...this.options, cwd: currentCwd, _bypassVirtual: true } ); return await runner; diff --git a/js/tests/getcwd-error-handling.test.mjs b/js/tests/getcwd-error-handling.test.mjs index 293a776..6c09869 100644 --- a/js/tests/getcwd-error-handling.test.mjs +++ b/js/tests/getcwd-error-handling.test.mjs @@ -1,118 +1,108 @@ -import { expect, test, describe } from "bun:test"; +import { expect, test, describe, afterEach } from 'bun:test'; import { $ } from '../src/$.mjs'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// Regression tests for issue #44: "getcwd() failed" error. +// +// process.cwd() throws "getcwd() failed: No such file or directory" when the +// current working directory has been deleted or becomes inaccessible (common in +// CI/CD with temporary directories). Subshell execution and directory +// restoration must degrade gracefully instead of crashing. describe('getcwd() error handling', () => { - test('should handle getcwd() failures in subshells gracefully', async () => { - const originalDir = process.cwd(); - const originalCwd = process.cwd; - let cwdCallCount = 0; - + const originalCwd = process.cwd; + const startDir = process.cwd.call(process); + + afterEach(() => { + // Always restore the real process.cwd and a valid working directory so a + // failure in one test does not cascade into the others. + process.cwd = originalCwd; try { - // Mock process.cwd to fail when called from _runSubshell - process.cwd = function() { - cwdCallCount++; - const stack = new Error().stack; - if (stack.includes('_runSubshell') && cwdCallCount > 1) { - const error = new Error('getcwd() failed: No such file or directory'); - error.errno = -2; - error.code = 'ENOENT'; - throw error; - } - return originalCwd.call(this); - }; - - // This should not throw despite the getcwd() failure - const result = await $`(echo "test subshell")`; - expect(result.stdout.toString().trim()).toBe('test subshell'); - expect(result.code).toBe(0); - - } finally { - // Restore original process.cwd - process.cwd = originalCwd; - try { - process.chdir(originalDir); - } catch (e) { - // Ignore restoration errors in test - } + process.chdir(startDir); + } catch { + // ignore } }); - test('should handle getcwd() failures during directory restoration', async () => { - const originalDir = process.cwd(); - const originalCwd = process.cwd; - let restorePhase = false; - - try { - // Mock process.cwd to fail during directory restoration - process.cwd = function() { - const stack = new Error().stack; - if (restorePhase && stack.includes('finally')) { - const error = new Error('getcwd() failed: No such file or directory'); - error.errno = -2; - error.code = 'ENOENT'; - throw error; - } - return originalCwd.call(this); - }; - - // Enable restore phase failure after command starts - setTimeout(() => { restorePhase = true; }, 10); - - // This should complete successfully despite restoration issues - const result = await $`(echo "test restoration")`; - expect(result.stdout.toString().trim()).toBe('test restoration'); - expect(result.code).toBe(0); - - } finally { - // Restore original process.cwd - process.cwd = originalCwd; - try { - process.chdir(originalDir); - } catch (e) { - // Ignore restoration errors in test + test('subshell completes when process.cwd() always fails', async () => { + // Simulate getcwd() failing the way it does on a deleted directory. + process.cwd = function () { + const error = new Error('getcwd() failed: No such file or directory'); + error.errno = -2; + error.code = 'ENOENT'; + throw error; + }; + + const result = await $`(echo "test subshell")`; + + process.cwd = originalCwd; + expect(result.stdout.toString().trim()).toBe('test subshell'); + expect(result.code).toBe(0); + }); + + test('subshell completes when process.cwd() fails only inside _runSubshell', async () => { + process.cwd = function () { + const stack = new Error().stack; + if (stack.includes('_runSubshell')) { + const error = new Error('getcwd() failed: No such file or directory'); + error.errno = -2; + error.code = 'ENOENT'; + throw error; } - } + return originalCwd.call(this); + }; + + const result = await $`(echo "still works")`; + + process.cwd = originalCwd; + expect(result.stdout.toString().trim()).toBe('still works'); + expect(result.code).toBe(0); + }); + + test('multiple commands keep working after getcwd() failures', async () => { + let failures = 0; + process.cwd = function () { + const stack = new Error().stack; + if (stack.includes('_runSubshell') && failures < 2) { + failures++; + const error = new Error('getcwd() failed: No such file or directory'); + error.errno = -2; + error.code = 'ENOENT'; + throw error; + } + return originalCwd.call(this); + }; + + const r1 = await $`(echo "first")`; + const r2 = await $`(echo "second")`; + const r3 = await $`(echo "third")`; + + process.cwd = originalCwd; + expect(r1.stdout.toString().trim()).toBe('first'); + expect(r2.stdout.toString().trim()).toBe('second'); + expect(r3.stdout.toString().trim()).toBe('third'); }); - test('should continue working after getcwd() errors', async () => { - const originalDir = process.cwd(); - const originalCwd = process.cwd; - let failureCount = 0; - + test('subshell runs even when the real working directory was deleted', async () => { + // Create a temporary directory, switch into it, then delete it so the + // process is left with a working directory that no longer exists. + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'getcwd-test-')); + process.chdir(tmp); + fs.rmSync(tmp, { recursive: true, force: true }); + try { - // Mock process.cwd to fail a few times then succeed - process.cwd = function() { - const stack = new Error().stack; - if (stack.includes('_runSubshell') && failureCount < 2) { - failureCount++; - const error = new Error('getcwd() failed: No such file or directory'); - error.errno = -2; - error.code = 'ENOENT'; - throw error; - } - return originalCwd.call(this); - }; - - // First command should work despite getcwd() failure - const result1 = await $`(echo "first")`; - expect(result1.stdout.toString().trim()).toBe('first'); - - // Second command should work despite getcwd() failure - const result2 = await $`(echo "second")`; - expect(result2.stdout.toString().trim()).toBe('second'); - - // Third command should work normally (no more failures) - const result3 = await $`(echo "third")`; - expect(result3.stdout.toString().trim()).toBe('third'); - + const result = await $`(echo "deleted dir")`; + expect(result.stdout.toString().trim()).toBe('deleted dir'); + expect(result.code).toBe(0); } finally { - // Restore original process.cwd - process.cwd = originalCwd; + // Restore a valid directory for subsequent tests. try { - process.chdir(originalDir); - } catch (e) { - // Ignore restoration errors in test + process.chdir(startDir); + } catch { + // ignore } } }); -}); \ No newline at end of file +}); From 90123f8533206a91b80b10e3769d1ca68248a168 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Jun 2026 17:01:07 +0000 Subject: [PATCH 5/9] Fix spawn failure when working directory is deleted (#44) The previous getcwd() fix prevented process.cwd() from throwing, but a child process spawned with an inherited (deleted) working directory still failed at the OS level with 'posix_spawn ENOENT' on Linux/Windows. This caused the 'subshell runs even when the real working directory was deleted' regression test to fail on those platforms. - Add resolveSpawnCwd() in $.shell.mjs: when no explicit cwd is given and process.cwd() fails, spawn from a valid fallback directory instead of inheriting the broken one. Applied in both async and sync spawn paths. - Mirror the change in the Rust implementation (resolve_spawn_cwd in lib.rs, used by ProcessRunner and Pipeline) to satisfy JS/Rust parity. - Add a Rust regression test (rust/tests/getcwd_error_handling.rs). - Keep $.process-runner-execution.mjs under the 1500-line lint limit by housing the helper in $.shell.mjs. --- js/.changeset/getcwd-error-handling.md | 1 + js/src/$.process-runner-execution.mjs | 7 +++- js/src/$.shell.mjs | 41 ++++++++++++++++++++++ rust/src/lib.rs | 48 ++++++++++++++++++++++++-- rust/src/pipeline.rs | 5 +-- rust/tests/getcwd_error_handling.rs | 48 ++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 rust/tests/getcwd_error_handling.rs diff --git a/js/.changeset/getcwd-error-handling.md b/js/.changeset/getcwd-error-handling.md index e1ccdcd..b5900c1 100644 --- a/js/.changeset/getcwd-error-handling.md +++ b/js/.changeset/getcwd-error-handling.md @@ -9,3 +9,4 @@ Handle `getcwd() failed` errors gracefully during subshell execution (issue #44) - capturing the working directory before a subshell no longer throws when `getcwd()` fails - directory restoration falls back to a safe location (`HOME`, then `/`) when the original directory is gone - simple commands fall back to the inherited `cwd` when `getcwd()` is unavailable +- spawning a child process no longer fails with `posix_spawn ENOENT` when the inherited working directory has been deleted; the process is launched from a valid fallback directory (`HOME`, `USERPROFILE`, the temp dir, then `/`) instead diff --git a/js/src/$.process-runner-execution.mjs b/js/src/$.process-runner-execution.mjs index 9584668..179cff0 100644 --- a/js/src/$.process-runner-execution.mjs +++ b/js/src/$.process-runner-execution.mjs @@ -3,7 +3,7 @@ import cp from 'child_process'; import { trace } from './$.trace.mjs'; -import { findAvailableShell } from './$.shell.mjs'; +import { findAvailableShell, resolveSpawnCwd } from './$.shell.mjs'; import { StreamUtils, safeWrite, asBuffer } from './$.stream-utils.mjs'; import { pumpReadable } from './$.quote.mjs'; import { createResult } from './$.result.mjs'; @@ -186,6 +186,9 @@ function spawnWithNode(argv, config) { */ function spawnChild(argv, config) { const { stdin } = config; + // Make sure we never try to spawn from a deleted/inaccessible working + // directory, which would make the OS-level spawn fail (issue #44). + config = { ...config, cwd: resolveSpawnCwd(config.cwd) }; const needsExplicitPipe = stdin !== 'inherit' && stdin !== 'ignore'; const preferNodeForInput = isBun && needsExplicitPipe; @@ -600,6 +603,8 @@ function executeSyncNode(argv, options) { * @returns {object} Result object */ function executeSyncProcess(argv, options) { + // Guard against a deleted/inaccessible working directory (issue #44). + options = { ...options, cwd: resolveSpawnCwd(options.cwd) }; return isBun ? executeSyncBun(argv, options) : executeSyncNode(argv, options); } diff --git a/js/src/$.shell.mjs b/js/src/$.shell.mjs index d0156bf..386e867 100644 --- a/js/src/$.shell.mjs +++ b/js/src/$.shell.mjs @@ -3,11 +3,52 @@ import cp from 'child_process'; import fs from 'fs'; +import os from 'os'; import { trace } from './$.trace.mjs'; // Shell detection cache let cachedShell = null; +/** + * Resolve a working directory that is safe to spawn a child process in. + * + * When no explicit cwd is requested the child normally inherits the parent's + * working directory. But if that directory has been deleted or become + * inaccessible (the "getcwd() failed" scenario from issue #44), inheriting it + * makes the OS-level spawn fail with `ENOENT: ... posix_spawn` on Linux and a + * similar error on Windows. In that case fall back to a directory that is known + * to exist so the command still runs. + * + * Normal behavior is preserved: when an explicit cwd is given, or when the + * inherited working directory is valid, this returns the original value + * (including `undefined`, meaning "inherit"). + * + * @param {string|undefined|null} cwd - Requested working directory + * @returns {string|undefined} A spawn-safe working directory + */ +export function resolveSpawnCwd(cwd) { + // An explicit, existing directory is always honored as-is. + if (cwd) { + return cwd; + } + + // No explicit cwd: we would inherit the parent's working directory. Make sure + // that directory is actually usable before relying on inheritance. + try { + process.cwd(); + return cwd; + } catch (e) { + const fallback = + process.env.HOME || process.env.USERPROFILE || os.tmpdir() || '/'; + trace( + 'ProcessRunner', + () => + `process.cwd() failed (${e.message}); spawning in fallback directory ${fallback}` + ); + return fs.existsSync(fallback) ? fallback : os.tmpdir(); + } +} + /** * Find an available shell by checking multiple options in order * Returns the shell command and arguments to use diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 96eae26..6157d1c 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -100,6 +100,49 @@ pub use state::{ pub use stream::{AsyncIterator, IntoStream, OutputChunk, OutputStream, StreamingRunner}; pub use trace::trace; +/// Resolve a working directory that is safe to spawn a child process in. +/// +/// When no explicit cwd is requested the child normally inherits the parent's +/// working directory. But if that directory has been deleted or become +/// inaccessible (the "getcwd() failed" scenario from issue #44), inheriting it +/// makes the OS-level spawn fail. In that case fall back to a directory that is +/// known to exist so the command still runs. +/// +/// Normal behavior is preserved: when an explicit cwd is given, or when the +/// inherited working directory is valid, this returns the requested value +/// (`None` meaning "inherit"). +fn resolve_spawn_cwd(cwd: Option<&PathBuf>) -> Option { + // An explicit directory is always honored as-is. + if let Some(c) = cwd { + return Some(c.clone()); + } + + // No explicit cwd: we would inherit the parent's working directory. Make + // sure that directory is actually usable before relying on inheritance. + match std::env::current_dir() { + Ok(_) => None, + Err(e) => { + let fallback = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + trace( + "ProcessRunner", + &format!( + "current_dir() failed ({}); spawning in fallback directory {}", + e, + fallback.display() + ), + ); + if fallback.exists() { + Some(fallback) + } else { + Some(std::env::temp_dir()) + } + } + } +} + /// Error type for command-stream operations #[derive(Debug, thiserror::Error)] pub enum Error { @@ -261,8 +304,9 @@ impl ProcessRunner { cmd.stderr(Stdio::inherit()); } - // Set working directory - if let Some(ref cwd) = self.options.cwd { + // Set working directory. Fall back to a valid directory when the + // inherited working directory has been deleted (issue #44). + if let Some(cwd) = resolve_spawn_cwd(self.options.cwd.as_ref()) { cmd.current_dir(cwd); } diff --git a/rust/src/pipeline.rs b/rust/src/pipeline.rs index af5bf40..8309ef8 100644 --- a/rust/src/pipeline.rs +++ b/rust/src/pipeline.rs @@ -175,8 +175,9 @@ impl Pipeline { cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); - // Set working directory - if let Some(ref cwd) = self.cwd { + // Set working directory. Fall back to a valid directory when the + // inherited working directory has been deleted (issue #44). + if let Some(cwd) = crate::resolve_spawn_cwd(self.cwd.as_ref()) { cmd.current_dir(cwd); } diff --git a/rust/tests/getcwd_error_handling.rs b/rust/tests/getcwd_error_handling.rs new file mode 100644 index 0000000..e8dd4e5 --- /dev/null +++ b/rust/tests/getcwd_error_handling.rs @@ -0,0 +1,48 @@ +//! Regression test for issue #44: "getcwd() failed" error. +//! +//! When the current working directory has been deleted or becomes inaccessible +//! (common in CI/CD with temporary directories), inheriting it would make the +//! OS-level spawn fail. Command execution must degrade gracefully by falling +//! back to a valid directory instead of crashing. +//! +//! This test lives in its own integration-test binary so that mutating the +//! process-global working directory cannot race with other tests. + +use command_stream::commands::{disable_virtual_commands, enable_virtual_commands}; +use command_stream::run; +use std::env; +use std::fs; + +#[tokio::test] +async fn command_runs_even_when_working_directory_was_deleted() { + let start_dir = env::current_dir().expect("should have a valid start dir"); + + // Force a real OS-level spawn instead of an in-process virtual command, so + // the test exercises the code path that breaks when the working directory + // has been deleted. + disable_virtual_commands(); + + // Create a temporary directory, switch into it, then delete it so the + // process is left with a working directory that no longer exists. + let tmp = env::temp_dir().join(format!("getcwd-test-{}", std::process::id())); + fs::create_dir_all(&tmp).expect("create temp dir"); + env::set_current_dir(&tmp).expect("chdir into temp dir"); + fs::remove_dir_all(&tmp).expect("delete temp dir"); + + // Running a command must still succeed even though the inherited working + // directory is gone. + let result = run("echo deleted dir").await; + + // Restore a valid directory and the default virtual-command state before + // asserting so a panic does not leave the process stranded. + let _ = env::set_current_dir(&start_dir); + enable_virtual_commands(); + + let result = result.expect("command should run despite deleted working dir"); + assert!(result.is_success(), "exit code should be 0: {:?}", result); + assert!( + result.stdout.contains("deleted dir"), + "unexpected stdout: {:?}", + result.stdout + ); +} From 451423cd3fbbc557a7bd565dad4dbf121f2c1309 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Jun 2026 17:02:20 +0000 Subject: [PATCH 6/9] Add Rust changelog fragment for getcwd() fix (#44) --- rust/changelog.d/20260610_000000_getcwd-error-handling.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 rust/changelog.d/20260610_000000_getcwd-error-handling.md diff --git a/rust/changelog.d/20260610_000000_getcwd-error-handling.md b/rust/changelog.d/20260610_000000_getcwd-error-handling.md new file mode 100644 index 0000000..503dded --- /dev/null +++ b/rust/changelog.d/20260610_000000_getcwd-error-handling.md @@ -0,0 +1,6 @@ +--- +bump: patch +--- + +### Fixed +- Handle `getcwd()`/`current_dir()` failures during command execution (issue #44). When the inherited working directory has been deleted or becomes inaccessible, the child process is now spawned from a valid fallback directory (`HOME`, `USERPROFILE`, the temp dir, then `/`) instead of failing at the OS level. Applies to both `ProcessRunner` and `Pipeline`. From 653ddb3390186908f4eae974614db45deec03ae6 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Jun 2026 17:06:05 +0000 Subject: [PATCH 7/9] Skip getcwd Rust test when platform locks cwd (Windows) (#44) --- rust/tests/getcwd_error_handling.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/rust/tests/getcwd_error_handling.rs b/rust/tests/getcwd_error_handling.rs index e8dd4e5..f7147c9 100644 --- a/rust/tests/getcwd_error_handling.rs +++ b/rust/tests/getcwd_error_handling.rs @@ -27,7 +27,17 @@ async fn command_runs_even_when_working_directory_was_deleted() { let tmp = env::temp_dir().join(format!("getcwd-test-{}", std::process::id())); fs::create_dir_all(&tmp).expect("create temp dir"); env::set_current_dir(&tmp).expect("chdir into temp dir"); - fs::remove_dir_all(&tmp).expect("delete temp dir"); + + // Some platforms (notably Windows) lock the current working directory and + // refuse to delete it. In that case the "deleted working directory" + // scenario cannot be reproduced, so restore state and skip the test rather + // than report a spurious failure. + if fs::remove_dir_all(&tmp).is_err() { + let _ = env::set_current_dir(&start_dir); + enable_virtual_commands(); + eprintln!("skipping: platform does not allow deleting the current working directory"); + return; + } // Running a command must still succeed even though the inherited working // directory is gone. From 49591cb26ef1e7a773d9bd8a7c98a4dcd39b2130 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Jun 2026 17:14:20 +0000 Subject: [PATCH 8/9] Validate spawn cwd on disk to fix Bun stale-cwd ENOENT (#44) process.cwd() does not reliably throw when the working directory has been deleted; under Bun it returns a stale path that no longer exists. resolveSpawnCwd() now validates the directory with fs.existsSync() instead of relying on process.cwd() to throw, and falls back to a known-good directory when an explicit cwd is missing or gone. Fixes the CI failure in the deleted-working-directory regression test on Linux. --- js/src/$.shell.mjs | 82 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 12 deletions(-) diff --git a/js/src/$.shell.mjs b/js/src/$.shell.mjs index 386e867..7c27be9 100644 --- a/js/src/$.shell.mjs +++ b/js/src/$.shell.mjs @@ -9,6 +9,29 @@ import { trace } from './$.trace.mjs'; // Shell detection cache let cachedShell = null; +/** + * Pick a directory that is known to exist for spawning a child process. + * @returns {string} An existing fallback directory + */ +function spawnFallbackDir() { + const candidates = [ + process.env.HOME, + process.env.USERPROFILE, + os.tmpdir(), + '/', + ]; + for (const candidate of candidates) { + try { + if (candidate && fs.existsSync(candidate)) { + return candidate; + } + } catch { + // Ignore and try the next candidate. + } + } + return os.tmpdir(); +} + /** * Resolve a working directory that is safe to spawn a child process in. * @@ -19,34 +42,69 @@ let cachedShell = null; * similar error on Windows. In that case fall back to a directory that is known * to exist so the command still runs. * - * Normal behavior is preserved: when an explicit cwd is given, or when the - * inherited working directory is valid, this returns the original value - * (including `undefined`, meaning "inherit"). + * Note: `process.cwd()` does not reliably throw when the working directory has + * been deleted โ€” under Bun (and on some platforms) it returns a stale path that + * no longer exists on disk. So validity is checked with `fs.existsSync()` + * rather than by relying on `process.cwd()` to throw. + * + * Normal behavior is preserved: when an explicit, existing cwd is given, or + * when the inherited working directory is valid, this returns the original + * value (including `undefined`, meaning "inherit"). * * @param {string|undefined|null} cwd - Requested working directory * @returns {string|undefined} A spawn-safe working directory */ export function resolveSpawnCwd(cwd) { - // An explicit, existing directory is always honored as-is. + // An explicit directory is honored only when it actually exists on disk; + // otherwise the spawn would fail, so fall back to a known-good directory. if (cwd) { - return cwd; + try { + if (fs.existsSync(cwd)) { + return cwd; + } + } catch { + // Fall through to the fallback below. + } + const fallback = spawnFallbackDir(); + trace( + 'ProcessRunner', + () => + `Requested cwd "${cwd}" is not accessible; spawning in fallback directory ${fallback}` + ); + return fallback; } - // No explicit cwd: we would inherit the parent's working directory. Make sure - // that directory is actually usable before relying on inheritance. + // No explicit cwd: the child would inherit the parent's working directory. + // Make sure that directory actually exists before relying on inheritance. + let current; try { - process.cwd(); - return cwd; + current = process.cwd(); } catch (e) { - const fallback = - process.env.HOME || process.env.USERPROFILE || os.tmpdir() || '/'; + const fallback = spawnFallbackDir(); trace( 'ProcessRunner', () => `process.cwd() failed (${e.message}); spawning in fallback directory ${fallback}` ); - return fs.existsSync(fallback) ? fallback : os.tmpdir(); + return fallback; } + + try { + if (current && fs.existsSync(current)) { + // Inherited directory is valid: preserve "inherit" semantics. + return cwd; + } + } catch { + // Fall through to the fallback below. + } + + const fallback = spawnFallbackDir(); + trace( + 'ProcessRunner', + () => + `Inherited working directory "${current}" no longer exists; spawning in fallback directory ${fallback}` + ); + return fallback; } /** From 5141d564f3b9705add32a583e2b7441f94c4d973 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Jun 2026 17:17:24 +0000 Subject: [PATCH 9/9] Skip deleted-cwd test on Windows where cwd cannot be removed (#44) Windows locks the current working directory, so fs.rmSync on the cwd throws EBUSY and the deleted-working-directory scenario cannot occur. Skip the regression test on Windows, matching the Rust suite. --- js/tests/getcwd-error-handling.test.mjs | 41 +++++++++++++++---------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/js/tests/getcwd-error-handling.test.mjs b/js/tests/getcwd-error-handling.test.mjs index 6c09869..dfe1258 100644 --- a/js/tests/getcwd-error-handling.test.mjs +++ b/js/tests/getcwd-error-handling.test.mjs @@ -85,24 +85,33 @@ describe('getcwd() error handling', () => { expect(r3.stdout.toString().trim()).toBe('third'); }); - test('subshell runs even when the real working directory was deleted', async () => { - // Create a temporary directory, switch into it, then delete it so the - // process is left with a working directory that no longer exists. - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'getcwd-test-')); - process.chdir(tmp); - fs.rmSync(tmp, { recursive: true, force: true }); + // Windows locks the current working directory, so it cannot be deleted while + // the process is inside it (`fs.rmSync` throws EBUSY). The "deleted working + // directory" scenario this test reproduces simply cannot occur on Windows, so + // skip it there (the Rust suite skips the equivalent test for the same + // reason). + const deletedDirTest = process.platform === 'win32' ? test.skip : test; + deletedDirTest( + 'subshell runs even when the real working directory was deleted', + async () => { + // Create a temporary directory, switch into it, then delete it so the + // process is left with a working directory that no longer exists. + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'getcwd-test-')); + process.chdir(tmp); + fs.rmSync(tmp, { recursive: true, force: true }); - try { - const result = await $`(echo "deleted dir")`; - expect(result.stdout.toString().trim()).toBe('deleted dir'); - expect(result.code).toBe(0); - } finally { - // Restore a valid directory for subsequent tests. try { - process.chdir(startDir); - } catch { - // ignore + const result = await $`(echo "deleted dir")`; + expect(result.stdout.toString().trim()).toBe('deleted dir'); + expect(result.code).toBe(0); + } finally { + // Restore a valid directory for subsequent tests. + try { + process.chdir(startDir); + } catch { + // ignore + } } } - }); + ); });