Skip to content
Merged
12 changes: 12 additions & 0 deletions js/.changeset/getcwd-error-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'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
- 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
111 changes: 111 additions & 0 deletions js/examples/debug-getcwd-error.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/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');
97 changes: 97 additions & 0 deletions js/examples/debug-subshell-getcwd.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/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');
7 changes: 6 additions & 1 deletion js/src/$.process-runner-execution.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}

Expand Down
62 changes: 50 additions & 12 deletions js/src/$.process-runner-orchestration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() ?? '<unavailable>'} 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 ?? '<unavailable>'} 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}`);
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading