Skip to content

Service

Service

Bases: Resource

Base class for background services.

Lifecycle (in execution order): initialize(): before_initialize() — overridable: wait for deps, prepare on_initialize() — overridable: service-specific setup → serve task spawned after_initialize() — overridable: finalize

shutdown():
    before_shutdown()    — overridable: pre-stop signals
    → serve task cancelled
    on_shutdown()        — overridable: service-specific cleanup
    after_shutdown()     — overridable: post-cleanup

Subclasses MUST implement serve(). All six hooks are available.

Subclasses should declare restart_spec to specify their restart strategy::

class MyService(Service):
    restart_spec = RestartSpec(restart_type=RestartType.PERMANENT)

Concrete subclasses that do not declare restart_spec will emit a warning at class definition time, because silently inheriting the default profile can hide incorrect production behavior.

Source code in src/hassette/resources/service.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
class Service(Resource):
    """Base class for background services.

    Lifecycle (in execution order):
        initialize():
            before_initialize()  — overridable: wait for deps, prepare
            on_initialize()      — overridable: service-specific setup
            → serve task spawned
            after_initialize()   — overridable: finalize

        shutdown():
            before_shutdown()    — overridable: pre-stop signals
            → serve task cancelled
            on_shutdown()        — overridable: service-specific cleanup
            after_shutdown()     — overridable: post-cleanup

    Subclasses MUST implement serve(). All six hooks are available.

    Subclasses should declare ``restart_spec`` to specify their restart strategy::

        class MyService(Service):
            restart_spec = RestartSpec(restart_type=RestartType.PERMANENT)

    Concrete subclasses that do not declare ``restart_spec`` will emit a warning at
    class definition time, because silently inheriting the default profile can hide
    incorrect production behavior.
    """

    role: ClassVar[ResourceRole] = ResourceRole.SERVICE

    restart_spec: ClassVar[RestartSpec] = RestartSpec()
    """Restart strategy for this service. Declare on each concrete subclass."""

    _serve_task: asyncio.Task | None = None

    def __init_subclass__(cls, **kwargs: Any) -> None:
        super().__init_subclass__(**kwargs)
        # Only warn for concrete classes. Since FinalMeta doesn't inherit from ABCMeta,
        # __abstractmethods__ is not computed automatically. Instead, check for any
        # abstract methods declared directly on this class — if any exist, treat the
        # class as abstract/intermediate and skip the warning.
        has_abstract_methods = any(
            getattr(v, "__isabstractmethod__", False)
            for v in cls.__dict__.values()
            if callable(v) or isinstance(v, (staticmethod, classmethod, property))
        )
        if has_abstract_methods:
            return
        # Only warn if restart_spec was not declared directly on this class.
        if "restart_spec" not in cls.__dict__:
            warnings.warn(
                f"{cls.__name__} does not declare restart_spec. "
                f"Inheriting the default RestartSpec() may silently use the wrong restart strategy. "
                f"Declare restart_spec on {cls.__name__} explicitly.",
                UserWarning,
                stacklevel=2,
            )

    def _force_terminal(self) -> None:
        """Override to also cancel the serve task."""
        if self._serve_task and not self._serve_task.done():
            self._serve_task.cancel()
        super()._force_terminal()

    @abstractmethod
    async def serve(self) -> None:
        """Subclasses MUST override: run until cancelled or finished."""
        raise NotImplementedError

    @final
    async def initialize(self) -> None:
        """Initialize the Service and propagate to children.

        NOTE: Unlike Resource.initialize(), this method returns while status is
        still STARTING.  handle_running() is called by _serve_wrapper() when
        serve() actually begins.  Children MUST NOT call
        self.parent.wait_ready() during their on_initialize — this will deadlock
        because the parent's readiness depends on serve() running, which cannot
        start until child initialization completes.

        Keep flag resets and child propagation in sync with Resource.initialize().
        NOTE: _auto_wait_dependencies() runs before hooks — keep in sync with Resource.initialize().
        """
        self.shutdown_completed = False
        self.shutdown_event.clear()

        if self.initializing:
            return
        self.initializing = True
        self.logger.debug("Initializing %s: %s", self.role, self.unique_name)
        await self.handle_starting()
        try:
            try:
                await self._auto_wait_dependencies()
            except Exception as exc:
                await self.handle_failed(exc)
                raise
            if self.hassette.shutdown_event.is_set():
                self.mark_not_ready("shutdown requested during dependency wait")
                return
            await self._run_hooks([self.before_initialize, self.on_initialize])
            self._serve_task = self.task_bucket.spawn(self._serve_wrapper(), name=f"service:serve:{self.class_name}")
            await self._run_hooks([self.after_initialize])
            for child in self.children:
                if child.status not in (ResourceStatus.STARTING, ResourceStatus.RUNNING):
                    await child.initialize()
        finally:
            self.initializing = False

    @final
    async def shutdown(self) -> None:
        """NOTE: keep guards and flag resets in sync with Resource.shutdown()."""
        if self.shutdown_completed:
            return
        if self.shutting_down:
            return
        self.shutting_down = True
        if self._status not in TERMINAL_STATUSES:
            self.status = ResourceStatus.STOPPING
        self.request_shutdown(f"{self.unique_name} shutdown")
        try:
            await self._run_hooks([self.before_shutdown], continue_on_error=True)
            if self.is_running() and self._serve_task:
                self._serve_task.cancel()
                self.logger.debug("Cancelled serve() task")
                try:
                    await asyncio.wait_for(
                        self._serve_task,
                        timeout=self.hassette.config.lifecycle.resource_shutdown_timeout_seconds,
                    )
                except asyncio.CancelledError:  # noqa: ASYNC103 — we just called .cancel(); this is the expected path
                    pass  # noqa: ASYNC104
                except TimeoutError:
                    self.logger.warning(
                        "Serve task for %s did not complete within resource shutdown timeout",
                        self.unique_name,
                    )
            await self._run_hooks([self.on_shutdown, self.after_shutdown], continue_on_error=True)
        finally:
            await self._finalize_shutdown()
            self.shutting_down = False

    async def _serve_wrapper(self) -> None:
        try:
            await self.handle_running()
            await self.serve()
            # Normal return → graceful stop path
            await self.handle_stop()
        except asyncio.CancelledError:
            # Cooperative shutdown
            with suppress(Exception):
                await self.handle_stop()
            raise
        except ClosedResourceError as exc:
            if not self.hassette.shutdown_event.is_set():
                self.logger.error("Serve() task raised ClosedResourceError outside shutdown")
                with suppress(Exception):
                    await self.handle_failed(exc)
                return
            with suppress(Exception):
                await self.handle_stop()
        except FatalError as e:
            self.logger.error("Serve() task failed with fatal error: %s %s", type(e).__name__, e)
            # Crash/failure path
            await self.handle_crash(e)

        except Exception as e:
            self.logger.error("Serve() task failed: %s %s", type(e).__name__, e)
            # Crash/failure path
            await self.handle_failed(e)

    def is_running(self) -> bool:
        return self._serve_task is not None and not self._serve_task.done()

restart_spec: RestartSpec = RestartSpec() class-attribute

Restart strategy for this service. Declare on each concrete subclass.

serve() -> None abstractmethod async

Subclasses MUST override: run until cancelled or finished.

Source code in src/hassette/resources/service.py
79
80
81
82
@abstractmethod
async def serve(self) -> None:
    """Subclasses MUST override: run until cancelled or finished."""
    raise NotImplementedError

initialize() -> None async

Initialize the Service and propagate to children.

NOTE: Unlike Resource.initialize(), this method returns while status is still STARTING. handle_running() is called by _serve_wrapper() when serve() actually begins. Children MUST NOT call self.parent.wait_ready() during their on_initialize — this will deadlock because the parent's readiness depends on serve() running, which cannot start until child initialization completes.

Keep flag resets and child propagation in sync with Resource.initialize(). NOTE: _auto_wait_dependencies() runs before hooks — keep in sync with Resource.initialize().

Source code in src/hassette/resources/service.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
@final
async def initialize(self) -> None:
    """Initialize the Service and propagate to children.

    NOTE: Unlike Resource.initialize(), this method returns while status is
    still STARTING.  handle_running() is called by _serve_wrapper() when
    serve() actually begins.  Children MUST NOT call
    self.parent.wait_ready() during their on_initialize — this will deadlock
    because the parent's readiness depends on serve() running, which cannot
    start until child initialization completes.

    Keep flag resets and child propagation in sync with Resource.initialize().
    NOTE: _auto_wait_dependencies() runs before hooks — keep in sync with Resource.initialize().
    """
    self.shutdown_completed = False
    self.shutdown_event.clear()

    if self.initializing:
        return
    self.initializing = True
    self.logger.debug("Initializing %s: %s", self.role, self.unique_name)
    await self.handle_starting()
    try:
        try:
            await self._auto_wait_dependencies()
        except Exception as exc:
            await self.handle_failed(exc)
            raise
        if self.hassette.shutdown_event.is_set():
            self.mark_not_ready("shutdown requested during dependency wait")
            return
        await self._run_hooks([self.before_initialize, self.on_initialize])
        self._serve_task = self.task_bucket.spawn(self._serve_wrapper(), name=f"service:serve:{self.class_name}")
        await self._run_hooks([self.after_initialize])
        for child in self.children:
            if child.status not in (ResourceStatus.STARTING, ResourceStatus.RUNNING):
                await child.initialize()
    finally:
        self.initializing = False

shutdown() -> None async

NOTE: keep guards and flag resets in sync with Resource.shutdown().

Source code in src/hassette/resources/service.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
@final
async def shutdown(self) -> None:
    """NOTE: keep guards and flag resets in sync with Resource.shutdown()."""
    if self.shutdown_completed:
        return
    if self.shutting_down:
        return
    self.shutting_down = True
    if self._status not in TERMINAL_STATUSES:
        self.status = ResourceStatus.STOPPING
    self.request_shutdown(f"{self.unique_name} shutdown")
    try:
        await self._run_hooks([self.before_shutdown], continue_on_error=True)
        if self.is_running() and self._serve_task:
            self._serve_task.cancel()
            self.logger.debug("Cancelled serve() task")
            try:
                await asyncio.wait_for(
                    self._serve_task,
                    timeout=self.hassette.config.lifecycle.resource_shutdown_timeout_seconds,
                )
            except asyncio.CancelledError:  # noqa: ASYNC103 — we just called .cancel(); this is the expected path
                pass  # noqa: ASYNC104
            except TimeoutError:
                self.logger.warning(
                    "Serve task for %s did not complete within resource shutdown timeout",
                    self.unique_name,
                )
        await self._run_hooks([self.on_shutdown, self.after_shutdown], continue_on_error=True)
    finally:
        await self._finalize_shutdown()
        self.shutting_down = False