diff --git a/js/.changeset/getcwd-error-handling.md b/js/.changeset/getcwd-error-handling.md new file mode 100644 index 0000000..b5900c1 --- /dev/null +++ b/js/.changeset/getcwd-error-handling.md @@ -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 diff --git a/js/examples/debug-getcwd-error.mjs b/js/examples/debug-getcwd-error.mjs new file mode 100755 index 0000000..1df1717 --- /dev/null +++ b/js/examples/debug-getcwd-error.mjs @@ -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'); diff --git a/js/examples/debug-subshell-getcwd.mjs b/js/examples/debug-subshell-getcwd.mjs new file mode 100644 index 0000000..b820691 --- /dev/null +++ b/js/examples/debug-subshell-getcwd.mjs @@ -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'); 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/$.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/src/$.shell.mjs b/js/src/$.shell.mjs index d0156bf..7c27be9 100644 --- a/js/src/$.shell.mjs +++ b/js/src/$.shell.mjs @@ -3,11 +3,110 @@ import cp from 'child_process'; import fs from 'fs'; +import os from 'os'; 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. + * + * 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. + * + * 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 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) { + 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: the child would inherit the parent's working directory. + // Make sure that directory actually exists before relying on inheritance. + let current; + try { + current = process.cwd(); + } catch (e) { + const fallback = spawnFallbackDir(); + trace( + 'ProcessRunner', + () => + `process.cwd() failed (${e.message}); spawning in fallback directory ${fallback}` + ); + 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; +} + /** * Find an available shell by checking multiple options in order * Returns the shell command and arguments to use diff --git a/js/tests/getcwd-error-handling.test.mjs b/js/tests/getcwd-error-handling.test.mjs new file mode 100644 index 0000000..dfe1258 --- /dev/null +++ b/js/tests/getcwd-error-handling.test.mjs @@ -0,0 +1,117 @@ +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', () => { + 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 { + process.chdir(startDir); + } catch { + // ignore + } + }); + + 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'); + }); + + // 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 + } + } + } + ); +}); 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`. 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..f7147c9 --- /dev/null +++ b/rust/tests/getcwd_error_handling.rs @@ -0,0 +1,58 @@ +//! 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"); + + // 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. + 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 + ); +}