Repository: xonsh/xonsh

Issue:
NixOS: coreutils are not captured because unthredable
_Originally posted by @amacfie in https://github.com/xonsh/xonsh/issues/4214#issuecomment-1328288341_:

On Debian/Arch:
```python
docker run --rm -it python:3.10-slim /bin/bash -c "pip install 'xonsh[full]' && xonsh"

xonsh --version  
# xonsh/0.13.3

!(echo hi) == 'hi\n'
# True
!(echo hi).out == 'hi\n'
# True
```
But on NixOS:
```bash
docker run --rm -it nixos/nix:2.3.16 /bin/sh

nix-channel --update
nix-shell -p xonsh

xonsh --version  
# xonsh/0.13.3
xonsh

!(echo hi) == 'hi\n'
# hi
# False
!(echo hi).out == 'hi\n'
# hi
# False
```
## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**
      


Discussion:
Might be that something in https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/programs/xonsh.nix (maybe in everything that gets `source`d) is screwing up our piping.
> Might be that something in https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/programs/xonsh.nix (maybe in everything that gets `source`d) is screwing up our piping.

Actually, the xonshrc defined in this module is not loaded by default without https://github.com/NixOS/nixpkgs/pull/204252, which was merged a few hours ago and is not yet in nixos-unstable (though will be landed very soon). I can reproduce the same behavior no matter whether /etc/xonshrc is loaded, so I don't think this issue is related to the NixOS module.
Thanks @SamLukeYes -- that at least rules out a bunch of things that would be very annoying to check.

The only thing left that I can think of is that we're getting some behavior from `subprocess.PIPE` that is different than what we expect.
This issue is specific to the `echo` command. On NixOS, when I run something like
```
!(python -c "print('hi')") == 'hi\n'
```
I get the expected output.

I think the cause of this issue is that xonsh thinks `echo` is not threadable, so it runs it using the `Popen` class instead of the `PopenThread` class and doesn't pipe its stdout/stderr:
https://github.com/xonsh/xonsh/blob/bcf2592b7dc628ba4f64bb3e9744199edc42cfa5/xonsh/procs/specs.py#L747-L761

I can also reproduce this issue on Fedora by running
```
!(cat /etc/passwd)
```
The `cat` command is not threadable, so its output is not captured.
Is it right that `cmds_cache.predict_threadable` returns False for nix `cat`?
What is the difference between nix `cat` and gnu `cat`?

> Is it right that `cmds_cache.predict_threadable` returns False for nix `cat`?

I'm not sure if I'm doing it right, but I observe this on my NixOS:

```xonsh
$ import xonsh

$ xonsh.commands_cache.CommandsCache($(env)).predict_threadable('cat')
True

$ xonsh --version
xonsh/0.15.1
```

> What is the difference between nix `cat` and gnu `cat`?

The default `cat` on NixOS is provided by GNU coreutils. There are also some other packages providing `cat` in nixpkgs tho, e.g. uutils, toybox, gash-utils, etc.
@SamLukeYes thank you for the answer! I'm not the NixOS user but I can help to investigate the difference because past time I spent to improving threading in xonsh. Could you please give me the list of commands that install xonsh from the main branch in nix docker i.e.
```xsh
docker run --rm -it nixos/nix:2.3.16 /bin/sh
# install python and vim
# run `pip install git+https://github.com/xonsh/xonsh`
xonsh
```
After this I can understand what is the difference.
I'm not familiar with podman/docker and have trouble accessing the internet inside the container, probably because of my proxy setting. Anyway, I managed to reproduce this issue in a conda environment on NixOS, with the latest xonsh.

```command
$ nix-shell -p conda vim --run conda-shell
$ conda-install    # may be needed before using conda for the first time
$ conda create -n test 'python<3.12'
$ conda activate test
$ pip install git+https://github.com/xonsh/xonsh
$ xonsh --version
xonsh/0.16.0.dev111

$ xonsh
$ !(echo hi) == 'hi\n'
hi
False
```
Thank you for instructions! I tried to install conda in docker but have fail without any instructions in the net of how to do it right. Sorry.

```xsh
docker run --rm -it nixos/nix bash
nix-shell -p conda vim --run conda-shell
# building '/nix/store/253kppj13kr016xfdyg2iw0xwiriml2h-conda-shell.drv'...
# bwrap: Creating new namespace failed: Operation not permitted
```

But after this I found that `micromamba` (drop in replacement for conda) is working better.
But I faced with the question of how to install root CA certification on clean nix os. May be you can help as well?

```xsh
docker run --rm -it nixos/nix bash
nix-shell -p micromamba
export MAMBA_ROOT_PREFIX=/myenv
# export MAMBA_SSL_NO_REVOKE=true
micromamba create -n myenv python=3.11 -c conda-forge
# error    libmamba No CA certificates found on system
```
> The default cat on NixOS is provided by GNU coreutils.

It's interesting because I'm using [gnu coreutils on mac](https://formulae.brew.sh/formula/coreutils) and have no issues. We need to investigate the NixOS case. 

Side thought: is it possible to you to install coreutils from brew on NixOS and test it with xonsh? (optionally)

To be honest it's my first time of working with NixOS.
If using nix what does running the nixpklgs version do e.g. `nix run "nixpkgs#xonsh"` do?
(For those new to nix nix can just install things so it does not get installed globally so rather like using docker but not packaged off inside a VM).



Unfortunately my nix is rather deeply embedded so can't give a simple case. These are just observations.

I don't understand docker either - although I would note anything using docker is running linux even if running from a mac.

I have xonsh installed via a nix flake on a mac mini apple silicon  and I get (and also via nix run
```
🐚 xonsh --version
xonsh/0.15.1
🐚 !(echo hi).out == 'hi\n'
True
🐚 !(echo hi) == 'hi\n'
True
```

I would note that - the docker line I think is an old nix version
```
🐚 nix --version
nix (Nix) 2.19.4
```

xonsh question - Why don't I get hi printed out 
I repeated starting the shell as xonsh -i -l --no-rc and with the nix run and these gave the same. Also using nixpkgs coreutils-prefixed for echo (as echo)  I got the same results.

However as I ran xonsh (but xonsh --no-rc works) from an existing shell I get
```
zsh ❯  xonsh
[1]  + 93454 suspended (tty output)  xonsh
```

I would note that I have a possibly related issue with a third party program which behave differently if xonsh is the login shell or is run from another shell - in this case the latter is the bit that works) 

> ```shell
> $ xonsh
> $ !(echo hi) == 'hi\n'
> hi
> False
> ```

@SamLukeYes -- we've started stripping newlines from the end of single-line commands, what's the output of

```xonsh
!(echo hi) == "hi"
```

?
> what's the output of
> 
> ```python
> !(echo hi) == "hi"
> ```

```xonsh
$ !(echo hi) == 'hi'
hi
False
```

Still the same :confused:
@SamLukeYes can you help me with this - https://github.com/xonsh/xonsh/issues/5003#issuecomment-2126940398?
> I'm not familiar with podman/docker and have trouble accessing the internet inside the container, probably because of my proxy setting.

The issue was because I use a local http proxy. Passing `--net host` to `podman` solves the issue for me.

Also, I found venv works inside the container:

```command
$ nix-shell -p python3
$ python3 -m venv venv
$ source venv/bin/activate
```

And then you can use pip :)

> But I faced with the question of how to install root CA certification on clean nix os.

AFAIK, the docker image `nixos/nix` is a distroless container with nix package manager available, but not an actual NixOS with its module system. I can still reproduce the issue in the container tho.
I can reproduce this! Many thanks @SamLukeYes!
```xsh
docker run --rm -it nixos/nix bash
nix-shell -p python3
python3 -m venv venv
source venv/bin/activate
pip install git+https://github.com/xonsh/xonsh
xonsh

$XONSH_TRACE_SUBPROC=2
!(echo 1)
# Trace run_subproc({'cmds': (['echo', '1'],), 'captured': 'object'})
# 0: {'cmd': ['echo', '1'], 'cls': 'subprocess.Popen', 'bin': '/nix/store/k6h0vjh342kqlkq69sxjj8i5y50l6jfr-coreutils-9.3/bin/echo', 'thread': False, 'bg': False}
# 1
# CommandPipeline(
#   returncode=0,
#   pid=233,
#   args=['echo', '1'],
#   executed_cmd=['echo', '1'],
#   timestamps=[1716484360.5753396, 1716484360.5851793],
#   input='',
#   output=''
# )

__xonsh__.commands_cache.predict_threadable(['echo'])
# False
```

And `'thread': False`, hm...

Resolving patch:
diff --git a/xonsh/commands_cache.py b/xonsh/commands_cache.py
--- a/xonsh/commands_cache.py
+++ b/xonsh/commands_cache.py
@@ -132,6 +132,27 @@ def all_commands(self):
         self.update_cache()
         return self._cmds_cache
 
+    def resolve_symlink(self, path):
+        visited = set()
+        current_path = path
+        while os.path.islink(current_path):
+            if current_path in visited:
+                # Detected a loop while resolving symlink
+                return None
+            visited.add(current_path)
+            try:
+                current_path = os.readlink(current_path)
+            except Exception:
+                return None
+            if not os.path.isabs(current_path):
+                current_path = os.path.join(os.path.dirname(path), current_path)
+                current_path = os.path.normpath(current_path)
+
+        if current_path == path:
+            return None
+
+        return current_path
+
     def update_cache(self):
         env = self.env
         # iterate backwards so that entries at the front of PATH overwrite
@@ -383,6 +404,12 @@ def default_predictor_readbin(self, name, cmd0, timeout, failure):
             return failure
         if not os.path.isfile(fname):
             return failure
+        if (link := self.resolve_symlink(fname)) and link.endswith("coreutils"):
+            """
+            On NixOS the core tools are the symlinks to one universal ``coreutils`` binary file.
+            Detect it and use the default mode.
+            """
+            return failure
 
         try:
             fd = os.open(fname, os.O_RDONLY | os.O_NONBLOCK)


Test patch:
diff --git a/tests/test_commands_cache.py b/tests/test_commands_cache.py
--- a/tests/test_commands_cache.py
+++ b/tests/test_commands_cache.py
@@ -202,3 +202,31 @@ def test_update_cache(xession, tmp_path):
     cached = cache.update_cache()
 
     assert file2.samefile(cached[basename][0])
+
+
+@skip_if_on_windows
+def test_nixos_coreutils(tmp_path):
+    """On NixOS the core tools are the symlinks to one universal ``coreutils`` binary file."""
+    path = tmp_path / "core"
+    coreutils = path / "coreutils"
+    echo = path / "echo"
+    echo2 = path / "echo2"
+    echo3 = path / "echo3"
+    cat = path / "cat"
+
+    path.mkdir()
+    coreutils.write_bytes(b"Binary with isatty, tcgetattr, tcsetattr.")
+    echo.symlink_to(echo2)
+    echo2.symlink_to(echo3)
+    echo3.symlink_to(coreutils)
+    cat.symlink_to(coreutils)
+
+    for toolpath in [coreutils, echo, echo2, echo3, cat]:
+        # chmod a+x toolpath
+        current_permissions = toolpath.stat().st_mode
+        toolpath.chmod(current_permissions | 0o111)
+
+    cache = CommandsCache({"PATH": [path]})
+
+    assert cache.predict_threadable(["echo", "1"]) is True
+    assert cache.predict_threadable(["cat", "file"]) is False