Lifecycle

“Lifecycle” refers to AppDaemon itself starting, running, and stopping.

Contexts

AppDaemon makes heavy use of context managers to ensure safe and efficient startup and shutdown. Each context guarantees certain things happen as it enters and exits. The logic for the AppDaemon lifecycle consists of the following contexts, all of which get added to an ExitStack.

  • Backstop logic to catch any exceptions and log them more prettily.

  • Creates a PID file for the duration of the context, if necessary/applicable.

  • Creates a new async event loop and cleans it up afterwards.

  • Attaches/removes signal handlers to catch termination signals and stop gracefully

  • Startup/shutdown text in the logs about the versions and time it took to shut down

  • Creates the AppDaemon and HTTP objects.

  • Sets/unsets the default exception handler for the event loop to prettify any unhandled exceptions. The difference with the previous exception handler is that this one has access to the AppDaemon object

Some of these are entered as part of the ADMain context, and some are entered in the ADMain.run method. All of them are exited in reverse order as the ExitStack is closed at shutdown.

Startup

The top-level entrypoint is appdaemon.__main__.main(), which uses argparse to parse the launch arguments.

The ADMain class primarily provides a context manager that allows it to be used with a with statement to smoothly control AppDaemon’s lifecycle. Internally it uses an instance of ExitStack. Several methods are wrapped with the contextmanager() decorator to create context managers that get entered as part of AppDaemon starting.

main() function
493def main() -> None:
494    """Top-level entrypoint for AppDaemon
495
496    Parses the CLI arguments, configures logging, and runs the AppDaemon.
497    """
498    args = parse_arguments()
499
500    CLI_LOG_CFG = PRE_LOGGING.copy()
501
502    if args.debug is not None:
503        CLI_LOG_CFG["root"]["level"] = args.debug
504        logger.debug("Configured logging level from command line argument")
505
506    logging.config.dictConfig(CLI_LOG_CFG)
507
508    with ADMain(args) as admain:
509        admain.run()

Running

ADMain runs AppDaemon by calling the start() method on the top-level AppDaemon object, followed by the run_forever() method of the event loop.

ADMain.run() method
411    def run(self) -> None:
412        """Start AppDaemon up after initial argument parsing.
413
414        This uses :py:meth:`~asyncio.loop.run_forever` on the event loop to run it indefinitely.
415        """
416        self._cleanup_stack.enter_context(self.startup_text())
417        self._cleanup_stack.enter_context(self.run_context(self.loop))
418        self.AD.start()
419        self.logger.debug("Running async event loop forever")
420        self.loop.run_forever()
421        self.logger.debug("Stopped running async event loop forever")

Shutdown

Shutdown is initiated by the process running AppDaemon receiving either a SIGINT or SIGTERM signal.

ADMain.handle_sig() method
339    def handle_sig(self, signum: int):
340        """Function to handle signals.
341
342        Signals:
343            SIGUSR1 will result in internal info being dumped to the DIAG log
344            SIGUSR2 will reload apps with modified code/config (useful in production_mode)
345            SIGHUP will force a reload of all apps
346            SIGINT and SIGTEM both result in AD shutting down
347        """
348        match signum:
349            case signal.SIGUSR1:
350                self.AD.thread_async.call_async_no_wait(self.AD.sched.dump_schedule)
351                self.AD.thread_async.call_async_no_wait(self.AD.callbacks.dump_callbacks)
352                self.AD.thread_async.call_async_no_wait(self.AD.threading.dump_threads)
353                self.AD.thread_async.call_async_no_wait(self.AD.app_management.dump_objects)
354                self.AD.thread_async.call_async_no_wait(self.AD.sched.dump_sun)
355            case signal.SIGUSR2:
356                self.AD.thread_async.call_async_no_wait(self.AD.app_management.check_app_updates, mode=UpdateMode.NORMAL)
357            case signal.SIGHUP:
358                self.AD.thread_async.call_async_no_wait(self.AD.app_management.check_app_updates, mode=UpdateMode.TERMINATE)
359            case (signal.SIGINT | signal.SIGTERM) as sig:
360                self.logger.info(f"Received signal: {signal.Signals(sig).name}")
361                self.stop()

The stop() method is called, which in turn calls the stop methods of the various subsystems. It doesn’t return until all the tasks that existed at the time of the call have finished. This usually happens almost instantly, but this has a 3s timeout just to be safe.

ADMain.stop() method
405    def stop(self):
406        """Stop AppDaemon and stop the event loop afterwards."""
407        self.AD.stop_time = perf_counter()
408        task = self.loop.create_task(self.AD.stop())
409        task.add_done_callback(lambda _: self.loop.stop())

This will stop the event loop when all the tasks have finished, which breaks the run_forever() call in run() and causes the ExitStack to close.

The stop() method of the AppDaemon object is responsible for stopping all the subsystems in the correct order. It first sets a global stop event that all the subsystems check at the top of their respective loops. It then calls the stop methods of each subsystem in turn, waiting for each to finish before proceeding to the next one. Finally, it cancels any remaining tasks and waits for them to finish, with a timeout of 3 seconds.

Stop Event

AppDaemon shutdown is globally indicated by the stop Event getting set in the top-level AppDaemon object. All the subsystems check the status of this event using the is_set() method at the top of their respective loops, and exit if it is set. The general pattern is like this:

import asyncio
import contextlib

async def loop(self):
    while not self.AD.stop_event.is_set():
         ... # Do stuff
         with contextlib.suppress(asyncio.TimeoutError):
             await asyncio.wait_for(self.AD.stop_event.wait(), timeout=1)

Rather than using sleep() to wait between iterations, they use wait_for() to wait for the stop event with a timeout. The timeout is suppressed with suppress() so that it doesn’t raise an exception. Whenever the event is set, wait() returns immediately, which causes wait_for() to return immediately rather than waiting for the timeout.

Reference

class appdaemon.__main__.ADMain(args: Namespace)

Bases: object

Main application class for AppDaemon, which contains the parsed CLI arguments, top-level config model, and the async event loop.

When this class is instantiated, it creates a DependencyManager from the app directory. This causes

AD: AppDaemon
add_cleanup(cleanup_func, *args, **kwargs)

Add a cleanup function to be called on exit.

args: Namespace
diag: Logger
error: Logger
handle_sig(signum: int)

Function to handle signals.

Signals:

SIGUSR1 will result in internal info being dumped to the DIAG log SIGUSR2 will reload apps with modified code/config (useful in production_mode) SIGHUP will force a reload of all apps SIGINT and SIGTEM both result in AD shutting down

logger: Logger
logging: Logging
loop: AbstractEventLoop
loop_context() Generator[AbstractEventLoop]

Context manager that creates a new async event loop and cleans it up afterwards.

Includes the logic to install uvloop if it’s enabled.

model: MainConfig

Pydantic model of the top-level object for the appdaemon.yaml file.

run() None

Start AppDaemon up after initial argument parsing.

This uses run_forever() on the event loop to run it indefinitely.

run_context(loop: AbstractEventLoop)

Context manager for the main run logic with exception handling.

setup_logging() None

Set up logging configuration and timezone.

signal_handlers(loop: AbstractEventLoop)

Context manager for signal handler registration and cleanup.

startup_text()
stop()

Stop AppDaemon and stop the event loop afterwards.