holoviz / panel

Panel: The powerful data exploration & web app framework for Python
https://panel.holoviz.org
BSD 3-Clause "New" or "Revised" License
4.79k stars 518 forks source link

Hybrid C++ Pybind11/Panel integration Destructor issues. #6929

Open tstrutz opened 5 months ago

tstrutz commented 5 months ago

I am experiencing an issue with a hybrid C++ and Python integrated app using Panel. More information below.

ALL software version info

VCPKG GCC 11.4.0 (C++ 17 Requirement) Ninja CMake 3.22.1 Python 3.10 Numpy 1.24.4 Panel 1.4.2 Bokeh 3.4.1 Param 2.1.0 Pybind11 C++ (Latest) WSL Ubuntu 22.04

Please also set VCPKG_ROOT to the location of your installed VCPKG to test this. We use that in a CMakePresets file.

Description of expected behavior and the observed behavior

Application works as expected, but cleanup DOES not. I create a C++ object in C++ using python bindings generated using pybind11. This object is passed to the my Panel Dashboard, and it's state is used to populate several features of the dashboard. This dashboard is a real-time visualization tool for an algorithm written in C++.

Further testing has indicated that destructors on several of my Panel objects are not getting called. My Dashboard view contains a tab that the destructor never gets called on. The full version contains several tabs, plots, a controller, and a data model populated by the C++ object. All of this is stripped out for simplicity.

There is some cleanup such as closing output files that often occurs when the destructors get called in the full application, but this is not happening due to this bug.

Complete, minimal, self-contained example code that reproduces the issue

Basic Dashboard and C++ Application Launcher

main.py

"""This module contains an abstract class to act as a template for creating new pages
"""
import abc
import panel as pn
import param
import json
import os
import webbrowser
import holoviews as hv
import cpp_app_test
from bokeh.server.server import Server

pn.config.notifications = True
pn.config.theme = 'dark'

# Allow main page to resize adaptively
pn.extension("tabulator", "plotly", sizing_mode="stretch_width", notifications=True)
hv.extension("bokeh")
pn.extension(nthreads=8)

class BasePage_Meta(type(abc.ABC), type(param.Parameterized)):
    """Metaclass that inherits from the ABC and Parameterized metaclass.
    """

class BasePage(abc.ABC, param.Parameterized, metaclass=BasePage_Meta):
    """Abstract class for all classes pertaining to creating pages for the
    Dashboards. This requires each child class to instantiate their own version
    of the `update()` and `view()` methods.

    Parameters
    ----------

    controller : Controller
        See `_controller` in Attributes.
    parent : PanelPageBase
        Reference to this parents panel.

    Attributes
    ----------

    _controller : Controller
        Reference to the application controller.

    """
    def __init__(self, parent=None, **params) -> None:
        super().__init__(**params)
        self._parent = parent

    @abc.abstractmethod
    def view(self) -> pn.Column | pn.Row:
        """Returns a panel view containing the content to be displayed on this page.

        Returns
        ---------------

        pn.Column
            Panel column containing the content to be displayed on this page.
        """

class MainPanel(BasePage):
    """Main Panel for dashboard.

    Attributes
    ----------
    _sidebar : Sidebar
        Collapsable sidebar panel containing plotting controls.

    Parameters
    ----------
    app : App
        Reference to Python-Bound C++ app.
    """
    def __init__(self, app, **params):
        super().__init__(**params)

        self._app = app

    def __del__(self):
        print("Delete main page")

    def serve(self) -> pn.Template:
        """ Starts and makes available the panel as a web application.

        Returns
        -------
        Template
            Panel template representing main panel view.
        """
        template = pn.template.FastListTemplate(
            busy_indicator=None,
            collapsed_sidebar=False,
            header_background="#222829",
            main = self.view(),
            sidebar = None,
            sidebar_width=250,
            theme_toggle=True,
            title="Test Dashboard"
        )

        return template.servable()

    def view(self) -> pn.Tabs:
        """ Render panel layout.

        Returns
        -------
        tabs : pn.Tabs
            Panel tabs object.
        """
        tabs = pn.Tabs(dynamic=True)
        # Removed previously added tabs here.
        tabs.extend([
            ("Home", Home(self._app).view()),
        ])

        return tabs

class Home(BasePage):
    """Class that contains the contents and layout for the dashboard.

    Parameters:
    -----------

    controller : `Controller`
        Application controller (Replaced with app to test issue)

    Attributes:
    -----------

    open_doc_button : pn.widgets.Button
        Button to open documentation in separate tab.
    """

    def __init__(self, app, **params) -> None:
        super().__init__(**params)
        self._app = app
        self.__create_widgets()

    def __del__(self):
        print("Delete home page")

    def __create_widgets(self):
        """ Creates Page Widgets."""
        self.open_doc_button = pn.widgets.Button(
            name="Open User Manual",
            button_type="primary",
            on_click=self.open_documentation,
            width=250
            )

    def open_documentation(self, event):
        """Function callback that activates once the 'Open Documentation' button is clicked.
            Upon click, user documentation is launched in the browser.

        Parameters
        ----------

        event
            Signals that button click has occurred prompting app to open the user documentation
            page.
        """
        file = os.path.abspath("../ReferenceDocs/doc_page.html")
        return webbrowser.open(f"file://{file}")

    def view(self) -> pn.Column:
        """View function that houses and displays the components of the dashboard
        homepage.

        Returns
        --------

        pn.Column
            Object encapsulates all the pages' components to be rendered on the server.
        """
        header = pn.pane.Markdown(
            """
            ## Home
            Documentation of what this dashboard does here.
            """
        )

        pages_paragraph = pn.pane.Markdown(
            """
            ## Pages

            * __Home__: Serves as homepage and offers quick reference for documentation.
            * __Plots__: Displays plots generated by the sidebar controls.
            """
        )

        open_doc_row = pn.Column(pn.Row(self.open_doc_button))

        return pn.Column(
            header,
            open_doc_row,
            pages_paragraph
        )

class AppLauncher:
    """This class creates an application launcher that will create the Panel
    application as well as manage running any given dashboard.

    Parameters
    ----------
    app : cpp_app_test.Application
        See `_app` in Attributes.

    Attributes
    ----------
    _app : cpp_app_test.Application
        Python-Bound C++ Application to run.
    _main_panel : MainPanel
        Main class for real-time Dashboard.
    _dashboard : pn.viewable.Viewable
        Viewable representation of the dashboard.
    _server : bokeh.server.server.Server
        Bokeh dashboard server.
    """
    def __init__(self, app: cpp_app_test.Application) -> None:
        self._app: cpp_app_test.Application = app

        self._main_panel: MainPanel = MainPanel(self._app)
        self._dashboard: pn.viewable.Viewable = pn.panel(self._main_panel.serve(), width=1920)
        self._server: Server = None

    def run(self):
        """Starts the C++ app (normally) and Python dashboard. When the C++
        application exits, the dashboard will automatically stop.

        Parameters
        ----------
        args : argparse.Namespace
            Argparse container containing command-line parsed arguments.
        """
        # Server is running in a separate thread.
        self._server = self._dashboard.show(open=False, threaded=True, port=5000, address="0.0.0.0")

        # Wait here for application to exit.
        self._app.run()

        # Stop server when C++ exits via signal handler.
        self._server.stop()

if __name__ == "__main__":
    print("Creating C++ application")

    # Pybinding object.
    app = cpp_app_test.Application()

    print("C++ application created.")

    launcher = AppLauncher(app)
    launcher.run()

    print("Exiting interpreter. Destructors should be called after this point!")

Stack traceback and/or browser JavaScript console output

Creating C++ application
Constructed application.
C++ application created.
Launching server at http://0.0.0.0:5000
^CCaught signal 2, shutting down...
Exiting interpreter. Destructors should be called after this point!

C++ Code app.hpp

#include <atomic>

class Application
{
    public:
        Application();

        virtual ~Application();

        void stop();

        void run();

    private:

        std::atomic<bool> m_running{false};
};

app.cpp

#include <app.hpp>

#include <functional>
#include <csignal>
#include <iostream>

// Put signal handling in unnamed namespace to make it local
namespace
{
    // The signal callback implementation
    std::function<void(int)> shutdownHandler;

    /**
     * @brief The signal callback to shutdown
     *
     * @param signal The signal number
     */
    void signalHandler(int signal)
    {
        // If the function was assigned at this point, then call it
        if (shutdownHandler)
        {
            shutdownHandler(signal);
        }
    }
} // namespace

Application::Application()
{
    std::cout << "Constructed application." << std::endl;
}

Application::~Application()
{
    std::cout << "Destroyed application" << std::endl;
}

void Application::stop()
{
    m_running.store(false);
}

void Application::run()
{
    // Setup signal handler for interrupt.

    // Set signal handling callback to capture Application object using this.
    shutdownHandler = [this](int signal)
    {
        std::cout << "Caught signal " << signal << ", shutting down..." << std::endl;
        try
        {
            stop();
        }
        catch(std::runtime_error & e)
        {
            std::cout << "Exception thrown in signal handler " << e.what() << std::endl;
        }
    };
    // Setup signal handling only once application is initialized. Otherwise, application will stall.
    std::signal(SIGINT, signalHandler); // For CTRL + C
    std::signal(SIGTERM, signalHandler);
    std::signal(SIGKILL, signalHandler);

    // Start an infinite loop. Will be interrupted when user interrupts the application and exit the app and the
    // dashboard server on python side.
    m_running.store(true);

    while (m_running.load())
    {

    }
}

Python binding code app_pybind.cpp

#include <iostream>
#include <csignal>
#include <functional>
#include <atomic>

#include <pybind11/pybind11.h>
#include <pybind11/stl.h>

#include <app.hpp>

namespace py = pybind11;

PYBIND11_MODULE(cpp_app_test, m)
{
    // Same binding code
    py::class_<Application> app(m, "Application");
    app.def(py::init<>());
    app.def("stop", &Application::stop);
    app.def("run", &Application::run, py::call_guard<py::gil_scoped_release>());
}

Build Stuff

CMakePresets.json

{
    "version": 3,
    "configurePresets": [
        {
            "name": "default",
            "inherits": "online",
            "generator": "Ninja Multi-Config"
        },
        {
            "name": "online",
            "binaryDir": "${sourceDir}/build",
            "toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake"
        }
    ],
    "buildPresets": [
        {
            "name": "Release",
            "jobs": 32,
            "configurePreset": "default",
            "configuration": "Release",
            "targets": ["install"]
        },
        {
            "name": "Debug",
            "configurePreset": "default",
            "configuration": "Debug",
            "targets": ["install"]
        },
        {
            "name": "RelWithDebInfo",
            "configurePreset": "default",
            "configuration": "RelWithDebInfo",
            "targets": ["install"]
        }
    ]
}

vcpkg.json

{
    "name": "panel_test",
    "version": "1.0.0",
    "description": "Test for Panel Bug",
    "homepage": "",
    "dependencies": [
      "nlohmann-json",
      "pybind11",
      {
        "name": "vcpkg-cmake",
        "host": true
      },
      {
        "name": "vcpkg-cmake-config",
        "host": true
      },
      {
        "name": "vcpkg-tool-ninja"
      }
    ]
  }

CMakeLists.txt

cmake_minimum_required(VERSION 3.22)

project(panel_test VERSION 0.0.0)

set(CXX_STANDARD_REQUIRED 17)

find_package(Python REQUIRED COMPONENTS Interpreter Development)
find_package(pybind11 CONFIG REQUIRED)

# Python method:
pybind11_add_module(cpp_app_test SHARED THIN_LTO OPT_SIZE app_pybind.cpp app.cpp)

target_include_directories(cpp_app_test PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

# Just put binaries in the same folder as the python module.
set (CMAKE_INSTALL_PREFIX ${CMAKE_SOURCE_DIR})

install(TARGETS cpp_app_test
        RUNTIME DESTINATION .
        ARCHIVE DESTINATION .
        LIBRARY DESTINATION .)

How to build the minimum viable code

Create and place all files within the same folder. I can upload a zip if that makes it easier.

cmake --preset default
cmake --build --preset Release

To run

Run the following in the same folder the files are in.

python main.py 

Notice, how python exits and the application does NOT call the C++ destructor.

Screenshots or screencasts of the bug in action

tstrutz commented 4 months ago

I may have figured out what was causing this. It looks like Panel is holding onto Panel resources even after the Python interpreter exits. Maybe a leak of some kind?

Anyways, I found that running pn.state.kill_all_servers() as the last line in main.py eliminates all of the problems here. I also moved all of the variables to a def main function so that there are zero global variables other than the app object. The weird thing with this is I am stopping the server. It shouldn't be running, unless the state is still left behind?

Why doesn't panel automatically do this clean up operation when Python exits? It's probably a good idea.

def main(app):
    # Pybinding object.
    launcher = AppLauncher(app)
    launcher.run()

if __name__ == "__main__":
    print("Creating C++ application")

    app = cpp_app_test.Application()

    print("C++ application created.")

    main(app)

    print("Exiting interpreter. Destructors should be called after this point!")

    pn.state.kill_all_servers()

produces the output:

Creating C++ application
Constructed application.
C++ application created.
Launching server at http://0.0.0.0:5000
^CCaught signal 2, shutting down...
Delete main page
Exiting interpreter. Destructors should be called after this point!
Delete home page
Destroyed application

I've noticed that the full application which contains a Sidebar and a Controller still doesn't work though. I may just reduce the number of references to self_app to fix this.