Skip to content

pythonnet.load crashes if the Python interpreter is called from PATH in Ubuntu 20.04 #1388

Closed

Description

@tibortakacs

Environment

  • Pythonnet version: 3.0.0-dev (latest master on 18/2/2021)
  • Python version: 3.8.5
  • Operating System: Ubuntu 20.04
  • .NET Runtime: .NET 5.0.2

Details

  • Describe what you were trying to get done.
    I am trying to use the latest pythonnet in a virtual environment. If I call my simple demo script using python command in PATH, pythonnet.load() crashes. It is the same with any other PATH-based pythonX command. Using a full path works fine. See the details and the analysis below.

Preparation

# Create virtual env.
python3 -m virtualenv venv
. venv/bin/activate
# Install the latest pythonnet
git clone https://github.com/pythonnet/pythonnet.git
pip install ./pythonnet/
# Get .NET 5 runtime
wget https://download.visualstudio.microsoft.com/download/pr/824175f2-5577-46ec-a675-95d2f652575f/07dafc2398e669afa7d25f2d47398c36/dotnet-runtime-5.0.2-linux-x64.tar.gz
mkdir dotnet_5.0.2
tar xfzv dotnet-runtime-5.0.2-linux-x64.tar.gz -C dotnet_5.0.2/

Runtime config (cat runtimeconfig.json)

{
  "runtimeOptions": {
    "tfm": "net5.0",
    "framework": {
      "name": "Microsoft.NETCore.App",
      "version": "5.0.2"
    }
  }
}

Python script (cat bug.py):

import os

import clr_loader
import pythonnet

runtime_config = os.path.join(os.path.dirname(__file__), "runtimeconfig.json")
dotnet_root = os.path.join(os.path.dirname(__file__), "dotnet_5.0.2")

runtime = clr_loader.get_coreclr(runtime_config=runtime_config, dotnet_root=dotnet_root)

pythonnet.set_runtime(runtime)
pythonnet.load()

import System
from System import String

s = String("Hello World from Python.NET for .NET 5!")
System.Console.WriteLine(s)
> which python
/home/takacs/src/pythonnet/venv/bin/python

>`which python` bug.py 
Hello World from Python.NET for .NET 5!
 
> python bug.py 
Traceback (most recent call last):
  File "bug.py", line 12, in <module>
    pythonnet.load()
  File "/home/takacs/src/pythonnet/venv/lib/python3.8/site-packages/pythonnet/__init__.py", line 50, in load
    _FFI.dlopen(libpython, posix.RTLD_NODELETE | posix.RTLD_LOCAL)
  File "/home/takacs/src/pythonnet/venv/lib/python3.8/site-packages/cffi/api.py", line 150, in dlopen
    lib, function_cache = _make_ffi_library(self, name, flags)
  File "/home/takacs/src/pythonnet/venv/lib/python3.8/site-packages/cffi/api.py", line 832, in _make_ffi_library
    backendlib = _load_backend_lib(backend, libname, flags)
  File "/home/takacs/src/pythonnet/venv/lib/python3.8/site-packages/cffi/api.py", line 827, in _load_backend_lib
    raise OSError(msg)
OSError: cannot load library '/home/takacs/src/pythonnet/python': /home/takacs/src/pythonnet/python: cannot open shared object file: No such file or directory.  Additionally, ctypes.util.find_library() did not manage to locate a library called '/home/takacs/src/pythonnet/python'
  • If there was a crash, please include the traceback here.
Traceback (most recent call last):
  File "bug.py", line 12, in <module>
    pythonnet.load()
  File "/home/takacs/src/pythonnet/venv/lib/python3.8/site-packages/pythonnet/__init__.py", line 50, in load
    _FFI.dlopen(libpython, posix.RTLD_NODELETE | posix.RTLD_LOCAL)
  File "/home/takacs/src/pythonnet/venv/lib/python3.8/site-packages/cffi/api.py", line 150, in dlopen
    lib, function_cache = _make_ffi_library(self, name, flags)
  File "/home/takacs/src/pythonnet/venv/lib/python3.8/site-packages/cffi/api.py", line 832, in _make_ffi_library
    backendlib = _load_backend_lib(backend, libname, flags)
  File "/home/takacs/src/pythonnet/venv/lib/python3.8/site-packages/cffi/api.py", line 827, in _load_backend_lib
    raise OSError(msg)
OSError: cannot load library '/home/takacs/src/pythonnet/python': /home/takacs/src/pythonnet/python: cannot open shared object file: No such file or directory.  Additionally, ctypes.util.find_library() did not manage to locate a library called 

Analysis

This issue is in the pythonnet.find_libpython module. The dlinfo.dli_fname string contains the actual command that was used to start the Python interpreter. In the case of pure python command, os.path.realpath fails the properly translate this back to the real executable.

path = os.path.realpath(dlinfo.dli_fname.decode())
)

Adding a few debug lines properly demonstrate this:

def _linked_libpython_unix():
    libdl = ctypes.CDLL(ctypes.util.find_library("dl"))
    libdl.dladdr.argtypes = [ctypes.c_void_p, ctypes.POINTER(Dl_info)]
    libdl.dladdr.restype = ctypes.c_int

    dlinfo = Dl_info()
    retcode = libdl.dladdr(
        ctypes.cast(ctypes.pythonapi.Py_GetVersion, ctypes.c_void_p),
        ctypes.pointer(dlinfo))
    if retcode == 0:  # means error
        return None

    print(f"DEBUG dlinfo.dli_fname.decode(): {dlinfo.dli_fname.decode()}")
    path = os.path.realpath(dlinfo.dli_fname.decode())
    print(f"DEBUG path: {path}")
    
    if path == os.path.realpath(sys.executable):
        return None
    return path
>  ./venv/bin/python bug.py 
DEBUG dlinfo.dli_fname.decode(): ./venv/bin/python
DEBUG path: /usr/bin/python3.8
Hello World from Python.NET for .NET 5!

> ../pythonnet/venv/bin/python bug.py 
DEBUG dlinfo.dli_fname.decode(): ../pythonnet/venv/bin/python
DEBUG path: /usr/bin/python3.8
Hello World from Python.NET for .NET 5!

>python bug.py 
DEBUG dlinfo.dli_fname.decode(): python
DEBUG path: /home/takacs/src/pythonnet/python
Traceback (most recent call last):
...

The last case demonstrates that os.path.realpath just extends python with the current path which is obviously incorrect.

As a result, pythonnet.load() method's libpython variable will point to a library which cannot be loaded (

_FFI.dlopen(libpython, posix.RTLD_NODELETE | posix.RTLD_LOCAL)
).
However, if this part is disabled, the application will crash later in the .NET Python.Runtime for the same reason.

Activity

  1. changed the title [-]New pythonnet.load crashes in virtual environment using symlink in Ubuntu 20.04[/-] [+]pythonnet.load crashes if python interpreter is called from PATH Ubuntu 20.04[/+] on Feb 18, 2021
  2. tibortakacs commented on Feb 18, 2021

    @tibortakacs
    Author

    A possible solution in Python 3:

    def _linked_libpython_unix():

    def _linked_libpython_unix():
        import shutil
        libdl = ctypes.CDLL(ctypes.util.find_library("dl"))
        libdl.dladdr.argtypes = [ctypes.c_void_p, ctypes.POINTER(Dl_info)]
        libdl.dladdr.restype = ctypes.c_int
    
        dlinfo = Dl_info()
        retcode = libdl.dladdr(
            ctypes.cast(ctypes.pythonapi.Py_GetVersion, ctypes.c_void_p),
            ctypes.pointer(dlinfo))
        if retcode == 0:  # means error
            return None
    
        path = shutil.which(dlinfo.dli_fname.decode())
        path = os.path.realpath(path)
        
        if path == os.path.realpath(sys.executable):
            return None
        return path

    The path = shutil.which(dlinfo.dli_fname.decode()) decodes the PATH-based python executable.

    If this high-level idea is supported, I am happy to move forward and create a production-ready PR.

  3. changed the title [-]pythonnet.load crashes if python interpreter is called from PATH Ubuntu 20.04[/-] [+]pythonnet.load crashes if the Python interpreter is called from PATH in Ubuntu 20.04[/+] on Feb 19, 2021
  4. filmor commented on Feb 22, 2021

    @filmor
    Member

    Thank you for your thorough analysis. I'm not quite sure about the fix, though. which is in the usual case the wrong tool, as a dynamically linked libpython is not going to be in the PATH. I'll see if I can find out how to do the check for dli_fname against sys.executable more reliably, but there seems to be little documentation on that.

  5. filmor commented on Feb 22, 2021

    @filmor
    Member

    This should be fixed by #1396, please verify and reopen if that's not the case :)

  6. tibortakacs commented on Feb 24, 2021

    @tibortakacs
    Author

    Thank you for the fast response and fix. I am afraid it does not solve the full issue though, in some sense, it made it worst. I do not have deep knowledge of this, please bear with me, but it seems to me that Py_ENABLE_SHARED does not serve our need here. It seems that although the Python application was built with --enable-shared, the executable does not always use the shared library.

    The same topic was mentioned in conda:
    conda/conda-build#2738 (comment)

    "We do build the shared library, but the Python interpreter does not use it. We build it only for projects that want to embed Python as a shared library."

    I did some experiment on my computer that demonstrates this:

    System Python in Ubuntu 20.04

    takacs@ubuntu:~$ which python3.8
    /usr/bin/python3.8
    takacs@ubuntu:~$ ldd `which python3.8`
    	linux-vdso.so.1 (0x00007fff988fe000)
    	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fbd1ef1a000)
    	libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007fbd1eef7000)
    	libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007fbd1eef1000)
    	libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007fbd1eeec000)
    	libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007fbd1ed9d000)
    	libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x00007fbd1ed6f000)
    	libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007fbd1ed51000)
    	/lib64/ld-linux-x86-64.so.2 (0x00007fbd1f120000)
    takacs@ubuntu:~$ python3.8 -c "import sysconfig; print(sysconfig.get_config_var('Py_ENABLE_SHARED'));"
    1
    

    As it is shown, the executable does not directly depend on any libpython*.so but Py_ENABLE_SHARED is 1.

    However, it works in my custom builds:

    Custom built Python3 without shared flag

    takacs@ubuntu:~/src/pythonnet/pythonenv/bin$ ls -alh
    total 29M
    drwxr-xr-x 2 takacs takacs 4.0K Feb 18 20:06 .
    drwxrwxr-x 6 takacs takacs 4.0K Feb 18 20:06 ..
    lrwxrwxrwx 1 takacs takacs    8 Feb 18 20:06 2to3 -> 2to3-3.6
    -rwxrwxr-x 1 takacs takacs  127 Feb 18 20:06 2to3-3.6
    -rwxrwxr-x 1 takacs takacs  268 Feb 18 20:06 easy_install-3.6
    lrwxrwxrwx 1 takacs takacs    7 Feb 18 20:06 idle3 -> idle3.6
    -rwxrwxr-x 1 takacs takacs  125 Feb 18 20:06 idle3.6
    -rwxrwxr-x 1 takacs takacs  250 Feb 18 20:06 pip3
    -rwxrwxr-x 1 takacs takacs  250 Feb 18 20:06 pip3.6
    lrwxrwxrwx 1 takacs takacs    8 Feb 18 20:06 pydoc3 -> pydoc3.6
    -rwxrwxr-x 1 takacs takacs  110 Feb 18 20:06 pydoc3.6
    lrwxrwxrwx 1 takacs takacs    9 Feb 18 20:06 python3 -> python3.6
    -rwxr-xr-x 2 takacs takacs  15M Feb 18 20:05 python3.6
    lrwxrwxrwx 1 takacs takacs   17 Feb 18 20:06 python3.6-config -> python3.6m-config
    -rwxr-xr-x 2 takacs takacs  15M Feb 18 20:05 python3.6m
    -rwxr-xr-x 1 takacs takacs 3.1K Feb 18 20:06 python3.6m-config
    lrwxrwxrwx 1 takacs takacs   16 Feb 18 20:06 python3-config -> python3.6-config
    lrwxrwxrwx 1 takacs takacs   10 Feb 18 20:06 pyvenv -> pyvenv-3.6
    -rwxrwxr-x 1 takacs takacs  467 Feb 18 20:06 pyvenv-3.6
    takacs@ubuntu:~/src/pythonnet/pythonenv/bin$ ldd ./python3.6 
    	linux-vdso.so.1 (0x00007fff061ed000)
    	libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f22aa4c4000)
    	libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f22aa4be000)
    	libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007f22aa4b9000)
    	libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f22aa36a000)
    	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f22aa178000)
    	/lib64/ld-linux-x86-64.so.2 (0x00007f22aa824000)
    takacs@ubuntu:~/src/pythonnet/pythonenv/bin$ ./python3.6 -c "import sysconfig; print(sysconfig.get_config_var('Py_ENABLE_SHARED'));"
    0
    takacs@ubuntu:~/src/pythonnet/pythonenv/bin$ ls -alh ../lib
    total 28M
    drwxr-xr-x  4 takacs takacs 4.0K Feb 18 20:06 .
    drwxrwxr-x  6 takacs takacs 4.0K Feb 18 20:06 ..
    -r-xr-xr-x  1 takacs takacs  28M Feb 18 20:05 libpython3.6m.a
    drwxr-xr-x  2 takacs takacs 4.0K Feb 18 20:06 pkgconfig
    drwxr-xr-x 35 takacs takacs 4.0K Feb 18 20:06 python3.6
    
    

    This demonstrates that without the --enabled-shared flag, no

    Custom build Python3 with --enable-shared flag

    takacs@ubuntu:~/src/pythonnet/pythonenv-shared/bin$ ls -alh
    total 120K
    drwxr-xr-x 2 takacs takacs 4.0K Feb 24 11:07 .
    drwxr-xr-x 6 takacs takacs 4.0K Feb 24 11:00 ..
    lrwxrwxrwx 1 takacs takacs    8 Feb 24 11:00 2to3 -> 2to3-3.6
    -rwxrwxr-x 1 takacs takacs  134 Feb 24 11:00 2to3-3.6
    -rwxrwxr-x 1 takacs takacs  275 Feb 24 11:00 easy_install-3.6
    lrwxrwxrwx 1 takacs takacs    7 Feb 24 11:00 idle3 -> idle3.6
    -rwxrwxr-x 1 takacs takacs  132 Feb 24 11:00 idle3.6
    -rwxrwxr-x 1 takacs takacs  257 Feb 24 11:00 pip3
    -rwxrwxr-x 1 takacs takacs  257 Feb 24 11:00 pip3.6
    lrwxrwxrwx 1 takacs takacs    8 Feb 24 11:00 pydoc3 -> pydoc3.6
    -rwxrwxr-x 1 takacs takacs  117 Feb 24 11:00 pydoc3.6
    lrwxrwxrwx 1 takacs takacs    9 Feb 24 11:00 python3 -> python3.6
    -rwxr-xr-x 2 takacs takacs  38K Feb 24 10:58 python3.6
    lrwxrwxrwx 1 takacs takacs   17 Feb 24 11:00 python3.6-config -> python3.6m-config
    -rwxr-xr-x 2 takacs takacs  38K Feb 24 10:58 python3.6m
    -rwxr-xr-x 1 takacs takacs 3.1K Feb 24 11:00 python3.6m-config
    lrwxrwxrwx 1 takacs takacs   16 Feb 24 11:00 python3-config -> python3.6-config
    lrwxrwxrwx 1 takacs takacs   10 Feb 24 11:00 pyvenv -> pyvenv-3.6
    -rwxrwxr-x 1 takacs takacs  474 Feb 24 11:00 pyvenv-3.6
    takacs@ubuntu:~/src/pythonnet/pythonenv-shared/bin$ ldd ./python3.6
    	linux-vdso.so.1 (0x00007fff047fc000)
    	libpython3.6m.so.1.0 => not found
    	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f784d2c0000)
    	/lib64/ld-linux-x86-64.so.2 (0x00007f784d4cb000)
    takacs@ubuntu:~/src/pythonnet/pythonenv-shared/bin$ export LD_LIBRARY_PATH=`pwd`/../lib; ./python3.6 -c "import sysconfig; print(sysconfig.get_config_var('Py_ENABLE_SHARED'));"
    1
    

    Based on this, and the conda experience, it seems that another solution is required here.

    @filmor What do you think? (I cannot reopen the issue.)

  7. reopened this on Feb 24, 2021
  8. filmor commented on Feb 27, 2021

    @filmor
    Member

    I think that I really hate this mess ;)

    I'll create a new PR that compares the basenames instead, that should do it.

  9. filmor commented on Feb 27, 2021

    @filmor
    Member

    Could you check whether #1401 finally solves this?

  10. tibortakacs commented on Mar 2, 2021

    @tibortakacs
    Author

    Unfortunately, it does not. This is the recommended change:

    path = os.path.realpath(dlinfo.dli_fname.decode())
    
    if os.path.basename(path) == os.path.basename(os.path.realpath(sys.executable)):
            return None

    If python was called using a command in the PATH, the "basename" of the path will be just "python", but sys.executable refers to the real name of the executable, in my example "python3.8".

    This is a reason why I believe a "which" type of query is needed in advance. This code works in all my cases (virtual environment, self-build Python with and without shared library):

    def _linked_libpython_unix():
        if not sysconfig.get_config_var("Py_ENABLE_SHARED"):
            return None
    
        libdl = ctypes.CDLL(ctypes.util.find_library("dl"))
        libdl.dladdr.argtypes = [ctypes.c_void_p, ctypes.POINTER(Dl_info)]
        libdl.dladdr.restype = ctypes.c_int
    
        dlinfo = Dl_info()
        retcode = libdl.dladdr(
            ctypes.cast(ctypes.pythonapi.Py_GetVersion, ctypes.c_void_p),
            ctypes.pointer(dlinfo))
        if retcode == 0:  # means error
            return None
    
        # This part is needed if the Python interpreter was started by using
        # a command on the PATH
        path = which(dlinfo.dli_fname.decode())
        if not path:
            path = dlinfo.dli_fname.decode()
    
        # Get the full, real path
        path = os.path.realpath(path)
    
        # Compare basenames only, this should always be enough except for very
        # pathological cases
        if os.path.basename(path) == os.path.basename(os.path.realpath(sys.executable)):
            return None
    
        return path

    The modification is this part:

        path = which(dlinfo.dli_fname.decode())
        if not path:
            path = dlinfo.dli_fname.decode()

    This idea is that if the dlinfo points to an executable, it either returns the real path of it (that the which command would return), or just returns the same path. If it is not an executable (e.g. it is a shared library), it just does the same that the current code does. This might resolve your concern you mentioned above.

  11. filmor commented on Mar 2, 2021

    @filmor
    Member

    Okay, I'm giving up on this for now, can you create a PR with your change? I'm still not happy with it, but dladdr is just (consistently) broken in this regard and there doesn't seem to be a way to get the real argv[0] (which is what dli_fname contains at least with glibc and the BSD libcs) in a running Python process.

  12. tibortakacs commented on Mar 2, 2021

    @tibortakacs
    Author

    Happy to file a PR. This is my first time contributing to this project, let me ask for a few clarification questions:

    • Are you happy to use whichcraft. It seems that it solves many possible cross-platform issues, and it works with Python 2 and 3.
    • What kind of tests can I add to this? For example, is it expected to add a full scenario test (create a virtual environment, install pythonnet from source, and execute a simple operation with it), or is there a better way/infrastructure for this? Could you give some hints about the testing principles in pythonnet? Thank you in advance.
  13. filmor commented on Mar 2, 2021

    @filmor
    Member

    Please don't introduce an additional dependency. In this particular case it's also absolutely not required, as shutil.which works fine on Unix (where we use it) and we have dropped Python 2 support a while ago.

    Regarding tests, I'm not sure. If you can create an additional workflow that creates a venv in this form for one Python version and checks that pythonnet.load works, that is fine. Don't add the case to the existing build matrix.

  14. tibortakacs commented on Mar 2, 2021

    @tibortakacs
    Author

    Sounds great to me. I have not realized that no more Python 2 support, then shutil is perfect.
    Regarding the test, can I ask what does "create an additional workflow" mean?

  15. filmor commented on Mar 3, 2021

    @filmor
    Member

    Looking a bit more into the messy details of ctypes, on Unix it will unconditionally refer to the main executable for lookup of Python symbols:

    https://github.com/python/cpython/blob/master/Lib/ctypes/__init__.py#L459-L464

    That means for me that we can simplify this code:

    1. Drop our vendored copy of find_libpython in favour of just vendoring the function to get the path on Windows (linked below)
    2. Pull in find_libpython as a test-time dependency to fill our environment variable correctly

    https://github.com/ktbarrett/find_libpython/blob/master/src/find_libpython/__init__.py#L151-L179

    @tibortakacs The test case would still be appreciated, but I currently can't think of any reason why we would need to do this lookup while already running anymore.

  16. tibortakacs commented on Mar 8, 2021

    @tibortakacs
    Author

    (Sorry for the delay in my response.)
    @lostmsu Thank you for pointing out these docs and pythonnet's workflow, going to read/watch them.
    @filmor Just to be sure: you are planning to create a PR following your latest proposal, or I should create a PR regarding this?

  17. ktbarrett commented on Mar 19, 2021

    @ktbarrett

    @filmor See ktbarrett/find_libpython#32 and conda/conda-build#2738. Py_ENABLED_SHARED isn't a reliable flag to test, and ctypes.pythonapi can return a handle to the current Python executable. I suggest vendoring in find_libpython@master, or just add it as a dependency. I have a CI system in place now that should prevent any breaks.

  18. filmor commented on Jun 23, 2022

    @filmor
    Member

    This should be obsolete.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions