panel icon indicating copy to clipboard operation
panel copied to clipboard

panel.serve() implicitly and unconditionally captures SIGINT under the hood

Open mcskatkat opened this issue 8 months ago • 0 comments

ALL software version info

panel 1.4.4 (currently latest). Others are irrelevant, I believe, but still:

  • python 3.9
  • bokeh 3.4.1
  • OS Windows 11
  • browser FireFox (definitely irrelevant)

Description of expected behavior and the observed behavior

Observed:

panel.serve(..., threaded=False) delegates to panel.io.server.get_server(). This in turn attempts (with silent failure!?) to install a SIGINT handler that eventually calls server.io_loop.stop(). This behavior stops the asyncio loop in its tracks without allowing any opportunity for code that shares the same event loop to perform its own orderly cleanup. Note also that the signal handler is installed unconditionally; no arguments can be passed in to prevent it. In particular, this is done even when start=False is passed.

Expected:

IMHO, being intended for programmatic server operation from user code, it is none of panel.serve()'s business to do ANY kind of OS signal handling. Also, it is not within its charter to stop the event loop it is running on. All of this should be handled by higher level code that has better awareness of the environment it is being run in, and what else may be running in it. I expect panel.serve(), especially if called with start=False, to just create a server. That's it. Not start() and definitely not stop() it for me. Even if this overreach is somehow deemed to be within the charter of panel.serve() in some cases, there should be a mechanism in place to allow the caller to prevent this when undesired.

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

In the following program, if SIGINT is received while in the try block, things will explode. This is because the event loop gets stopped by panel.serve while the coroutine is inside asyncio.wait(tasks_running, ...), and the finally clause never gets to run.

fueling_dashboard: panel.viewable.Viewable

class SpaceShip:
    async def monitor_fuel_tanks(self, how: str):
        while True:
            fueling_dashboard.update_fuel_display(self.poll_fuel_sensors(how))
            await asyncio.sleep(0.5)

    async def make_launch_preparations(self, ...):
        from asyncio import create_task, ALL_COMPLETED
        
        fueling_dashboard_server = panel.serve(fueling_dashboard, start=False, threaded=False, ...)
        panel.state.execute(self.monitor_fuel_tanks('carefully'))  # schedule background update task before starting server
        fueling_dashboard_server.start()

        tasks_running = {create_task(self.pump_in_oxygen(fill_level=0.95)), create_task(self.pump_in_hydrogen(fill_level=0.95))}
        try:
            _, tasks_running = await asyncio.wait(tasks_running, return_when=ALL_COMPLETED)
        finally:
            was_successful = not tasks_running
            if not was_successful:  # something went wrong; e.g. KeyboardInterrupt or asyncio.CancelledError because SIGINT was received
                fueling_dashboard.big_red_flashing_lamp.turn_on()
                for task in tasks_running:  # stop pumping in
                    task.cancel()
                # empty tanks to prevent explosion
                await asyncio.wait({self.pump_out_oxygen(), self.pump_out_hydrogen()}, return_when=ALL_COMPLETED)
            fueling_dashboard_server.stop()  # no longer needed
            return was_successful

Stack traceback and/or browser JavaScript console output

not applicable

Screenshots or screencasts of the bug in action

not applicable

  • [x] I may be interested in making a pull request to address this (but I'm not sure I know enough about what else could break)

mcskatkat avatar Jun 17 '24 13:06 mcskatkat