Skip to content

cd never actually chdir(2)s the shell process — /proc/<pid>/cwd stays frozen at spawn dir forever #1303

Description

@Blfrg

Summary

cd updates the shell's internal notion of the working directory ($PWD, pwd builtin, prompt integrations, and the cwd applied to newly spawned children) but never calls chdir(2) on the shell process itself. The process's real, kernel-tracked working directory (/proc/<pid>/cwd) stays frozen at whatever directory the shell was originally spawned in, for the lifetime of the process.

This makes brush's working directory invisible/wrong to every external tool that reads /proc/<pid>/cwd to determine "where is this shell" — terminal multiplexers (tmux #{pane_current_path}, wezterm/kitty pane-cwd inheritance for new splits), session managers, ps/lsof-based tooling, editors' terminal-cwd detection, debuggers, etc. — even though the shell's own interactive behavior (builtins, prompt, and everything it execs) is entirely self-consistent and correct.

Environment

  • brush 0.4.0 (git 96a26d0), Linux x86_64
  • Reproduced both as an interactive PTY child (os.pty.fork() + execvp("brush", ["brush", "-i"])) and inside herdr (a terminal-workspace-manager that spawns brush as default_shell and persists pane cwd for session restore by reading /proc/<pid>/cwd)
  • TERM=xterm-256color, prompt = starship

Reproduction

# fresh interactive brush, PID known ahead of time (e.g. 9598), spawned in /home/user
cd /tmp && echo "virtual=$PWD kernel_shell=$(readlink /proc/9598/cwd) kernel_child=$(readlink /proc/self/cwd)"

Output:

virtual=/tmp kernel_shell=/home/user kernel_child=/tmp
  • virtual ($PWD, read by the shell itself): correct, /tmp
  • kernel_child (/proc/self/cwd of the readlink process, a genuine forked+exec'd child of the shell): correct, /tmp — proves children really are born chdir'd into the new directory
  • kernel_shell (/proc/<shell-pid>/cwd, read live from outside the process): still /home/user, the original spawn directory — never updates, checked repeatedly over 60+ seconds

Additional checks performed

  1. Not thread-local (unshare(CLONE_FS)) either. Enumerated every LWP under /proc/<pid>/task/*/cwd (13 threads) — all report the stale spawn directory, none show the new one.
  2. cd does validate paths for realcd /does/not/exist correctly errors (error: cd: i/o error: No such file or directory (os error 2)) and does not update $PWD. This isn't blind trust of arbitrary input; the target is genuinely stat'd/validated before being "accepted" as the new logical cwd.
  3. External commands run correctly in the new directory. ls (a real forked+exec'd child, not an alias/builtin) after cding into a different directory lists that directory's actual contents, confirmed against an independent listing of the same path.
  4. Ruled out seccomp (present identically in both a working standalone repro and the non-working case), PID/mount/user namespace differences (identical across processes compared), and ptrace/strace artifacts (tried to attach with strace -p, got EPERM, consistent with normal YAMA ptrace_scope restriction rather than anything shell-specific).

Expected behavior

After a successful cd, the shell process's own chdir(2)-visible working directory (/proc/<pid>/cwd) should reflect the new directory, matching what pwd, the prompt, and child processes already correctly show. (cd -P/-L physical-vs-logical semantics are a separate, secondary concern — see #202 — this issue is about the raw process cwd never syncing at all, in either mode.)

Suggested fix

In the cd builtin's implementation (wherever it currently does the equivalent of updating an internal PathBuf/$PWD after validating the target), also best-effort call std::env::set_current_dir(&new_path) and log-and-ignore failures, so the process's real cwd tracks the shell's logical cwd. If brush is designed to be embedded as a library hosting multiple independent shell instances in one process (where a shared process-global cwd would be unsafe to mutate), this could be gated behind a flag that defaults on for the standalone/interactive binary and off for library embedding.

Rationale: the process cwd isn't purely internal shell state — it's a documented, load-bearing interface that the rest of the Unix tooling ecosystem reads via /proc/<pid>/cwd. A shell that never sets it is effectively invisible to every multiplexer, session manager, and debugger on Linux, even though it works perfectly for its own commands.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions