From 102bd1445c2b1b20ab2ad3dd121c1f29c75018bc Mon Sep 17 00:00:00 2001 From: Simon Rettberg Date: Fri, 21 Feb 2025 10:34:42 +0100 Subject: [PATCH] feat: Implement systemd-notify directly Dependency on sd-notify removed. sd-notify is wrapping libsystemd, which requires installing libsystemd-dev for building, and loads libsystemd.so at runtime. Since we only need the sd_notify part of the lib, and the protocol is trivial to implement, do so. We also now bail out early in case a watchdog timeout is set and the notification module could not be loaded, as in this case systemd would constantly kill and restart zigbee2mqtt, resulting in unstable operation that might not be obvious at first sight. --- lib/controller.ts | 21 ++++------- lib/types/unix-dgram.d.ts | 14 +++++++ lib/util/sd-notify.ts | 60 +++++++++++++++++++++++++++++ package.json | 3 +- pnpm-lock.yaml | 24 ++---------- test/controller.test.ts | 9 ----- test/sd-notify.test.ts | 79 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 165 insertions(+), 45 deletions(-) create mode 100644 lib/types/unix-dgram.d.ts create mode 100644 lib/util/sd-notify.ts create mode 100644 test/sd-notify.test.ts diff --git a/lib/controller.ts b/lib/controller.ts index 0a86ff7bb8..6db6bf2a70 100644 --- a/lib/controller.ts +++ b/lib/controller.ts @@ -1,5 +1,4 @@ import type {IClientPublishOptions} from 'mqtt'; -import type * as SdNotify from 'sd-notify'; import type {Zigbee2MQTTAPI} from './types/api'; @@ -30,12 +29,11 @@ import ExtensionReceive from './extension/receive'; import MQTT from './mqtt'; import State from './state'; import logger from './util/logger'; +import * as sdNotify from './util/sd-notify'; import * as settings from './util/settings'; import utils from './util/utils'; import Zigbee from './zigbee'; -type SdNotifyType = typeof SdNotify; - const AllExtensions = [ ExtensionPublish, ExtensionReceive, @@ -73,7 +71,6 @@ export class Controller { private exitCallback: (code: number, restart: boolean) => Promise; private extensions: Extension[]; private extensionArgs: ExtensionArgs; - private sdNotify: SdNotifyType | undefined; constructor(restartCallback: () => Promise, exitCallback: (code: number, restart: boolean) => Promise) { logger.init(); @@ -129,11 +126,13 @@ export class Controller { logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`); try { - this.sdNotify = process.env.NOTIFY_SOCKET ? await import('sd-notify') : undefined; + await sdNotify.init(); logger.debug('sd-notify loaded'); /* v8 ignore start */ } catch { - logger.debug('sd-notify is not installed'); + logger.error('sd-notify is not available, but service was started with Type=notify'); + logger.error('Either make sure sd-notify is available, or switch service to Type=simple'); + await this.exit(1); } /* v8 ignore stop */ @@ -198,11 +197,7 @@ export class Controller { logger.info(`Zigbee2MQTT started!`); - const watchdogInterval = this.sdNotify?.watchdogInterval() || 0; - if (watchdogInterval > 0) { - this.sdNotify?.startWatchdogMode(Math.floor(watchdogInterval / 2)); - } - this.sdNotify?.ready(); + sdNotify.started(); } @bind async enableDisableExtension(enable: boolean, name: string): Promise { @@ -227,7 +222,7 @@ export class Controller { } async stop(restart = false): Promise { - this.sdNotify?.stopping(process.pid); + sdNotify.stopping(); // Call extensions await this.callExtensions('stop', this.extensions); @@ -246,7 +241,7 @@ export class Controller { code = 1; } - this.sdNotify?.stopWatchdogMode(); + sdNotify.stopped(); return await this.exit(code, restart); } diff --git a/lib/types/unix-dgram.d.ts b/lib/types/unix-dgram.d.ts new file mode 100644 index 0000000000..52b7ff5e3c --- /dev/null +++ b/lib/types/unix-dgram.d.ts @@ -0,0 +1,14 @@ +declare module 'unix-dgram' { + import {EventEmitter} from 'events'; + import {Buffer} from 'buffer'; + + export class UnixDgramSocket extends EventEmitter { + send(buf: Buffer, callback?: (err?: Error) => void): void; + send(buf: Buffer, offset: number, length: number, path: string, callback?: (err?: Error) => void): void; + bind(path: string): void; + connect(remotePath: string): void; + close(): void; + } + + export function createSocket(type: 'unix_dgram', listener?: (msg: Buffer) => void): UnixDgramSocket; +} diff --git a/lib/util/sd-notify.ts b/lib/util/sd-notify.ts new file mode 100644 index 0000000000..67fef7a57e --- /dev/null +++ b/lib/util/sd-notify.ts @@ -0,0 +1,60 @@ +import type {UnixDgramSocket} from 'unix-dgram'; + +import logger from './logger'; + +// Handle sd_notify protocol, see https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html +// All methods in here will be no-ops if run on unsupported platforms or without Type=notify + +let socket: UnixDgramSocket | undefined; +let watchdog: NodeJS.Timeout | undefined; + +function sendToSystemd(msg: string): void { + if (!socket) return; + const buffer = Buffer.from(msg); + socket.send(buffer, 0, buffer.byteLength, process.env.NOTIFY_SOCKET!, (err: Error | undefined) => { + /* v8 ignore start */ + if (err) { + logger.warning(`Failed to send "${msg}" to systemd: ${err.message}`); + } + /* v8 ignore stop */ + }); +} + +export async function init(): Promise { + if (!process.env.NOTIFY_SOCKET) return; + try { + const {createSocket} = await import('unix-dgram'); + socket = createSocket('unix_dgram'); + /* v8 ignore start */ + } catch (error) { + // Ignore error on Windows if not running on WSL, as UNIX sockets don't exist + // on Windows. Unlikely that NOTIFY_SOCKET is set anyways but better be safe. + if (process.platform === 'win32' && !process.env.WSL_DISTRO_NAME) return; + // Otherwise, pass on exception, so main process can bail out immediately + throw error; + } + /* v8 ignore start */ +} + +export function started(): void { + sendToSystemd('READY=1'); + if (!socket || !process.env.WATCHDOG_USEC || watchdog) return; + const num = Math.max(0, parseInt(process.env.WATCHDOG_USEC, 10)); + if (!num) { + logger.warning(`WATCHDOG_USEC invalid: "${process.env.WATCHDOG_USEC}", parsed to "${num}"`); + return; + } + // Convert us to ms, send twice as frequently as the timeout + const interval = num / 1000 / 2; + watchdog = setInterval(() => sendToSystemd('WATCHDOG=1'), interval); +} + +export function stopping(): void { + sendToSystemd('STOPPING=1'); +} + +export function stopped(): void { + if (!watchdog) return; + clearInterval(watchdog); + watchdog = undefined; +} diff --git a/package.json b/package.json index 233ee61cd8..7a65c87838 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,6 @@ "@types/node": "^22.13.4", "@types/object-assign-deep": "^0.4.3", "@types/readable-stream": "4.0.18", - "@types/sd-notify": "^2.8.2", "@types/serve-static": "^1.15.7", "@types/ws": "8.5.14", "@vitest/coverage-v8": "^3.0.5", @@ -98,6 +97,6 @@ "zigbee2mqtt": "cli.js" }, "optionalDependencies": { - "sd-notify": "^2.8.0" + "unix-dgram": "^2.0.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 161a49da29..b9f4a0a273 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,9 +87,9 @@ importers: specifier: 0.9.4 version: 0.9.4 optionalDependencies: - sd-notify: - specifier: ^2.8.0 - version: 2.8.0 + unix-dgram: + specifier: ^2.0.6 + version: 2.0.6 devDependencies: '@eslint/core': specifier: ^0.11.0 @@ -121,9 +121,6 @@ importers: '@types/readable-stream': specifier: 4.0.18 version: 4.0.18 - '@types/sd-notify': - specifier: ^2.8.2 - version: 2.8.2 '@types/serve-static': specifier: ^1.15.7 version: 1.15.7 @@ -622,9 +619,6 @@ packages: '@types/readable-stream@4.0.18': resolution: {integrity: sha512-21jK/1j+Wg+7jVw1xnSwy/2Q1VgVjWuFssbYGTREPUBeZ+rqVFl2udq0IkxzPC0ZhOzVceUbyIACFZKLqKEBlA==} - '@types/sd-notify@2.8.2': - resolution: {integrity: sha512-LVWtuGvzso9z3N89NISzseq8RVHkEeg2h275370yQYx8/CoNaV2NnG17TTjDavy2FrmcUBFaR6OymlPQjqfb2g==} - '@types/send@0.17.4': resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} @@ -1518,11 +1512,6 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sd-notify@2.8.0: - resolution: {integrity: sha512-e+D1v0Y6UzmqXcPlaTkHk1QMdqk36mF/jIYv5gwry/N2Tb8/UNnpfG6ktGLpeBOR6TCC5hPKgqA+0hTl9sm2tA==} - engines: {node: '>=8.0.0'} - os: [linux, darwin, win32] - semver@7.7.1: resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} engines: {node: '>=10'} @@ -2232,8 +2221,6 @@ snapshots: '@types/node': 22.13.4 safe-buffer: 5.1.2 - '@types/sd-notify@2.8.2': {} - '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 @@ -3208,11 +3195,6 @@ snapshots: safer-buffer@2.1.2: {} - sd-notify@2.8.0: - dependencies: - bindings: 1.5.0 - optional: true - semver@7.7.1: {} send@0.19.0: diff --git a/test/controller.test.ts b/test/controller.test.ts index c04ee4340e..8f6747315d 100644 --- a/test/controller.test.ts +++ b/test/controller.test.ts @@ -24,17 +24,8 @@ import {Controller as ZHController} from 'zigbee-herdsman'; import {Controller} from '../lib/controller'; import * as settings from '../lib/util/settings'; -process.env.NOTIFY_SOCKET = 'mocked'; const LOG_MQTT_NS = 'z2m:mqtt'; -vi.mock('sd-notify', () => ({ - watchdogInterval: vi.fn(() => 3000), - startWatchdogMode: vi.fn(), - stopWatchdogMode: vi.fn(), - ready: vi.fn(), - stopping: vi.fn(), -})); - const mocksClear = [ mockZHController.stop, mockMQTTEndAsync, diff --git a/test/sd-notify.test.ts b/test/sd-notify.test.ts new file mode 100644 index 0000000000..9ad680964e --- /dev/null +++ b/test/sd-notify.test.ts @@ -0,0 +1,79 @@ +import {mockLogger} from './mocks/logger'; + +function expectMessage(nth: number, message: string): void { + expect(sendMock).toHaveBeenNthCalledWith(nth, Buffer.from(message), 0, expect.any(Number), 'mocked', expect.any(Function)); +} + +const sendMock = vi.fn(); + +vi.mock('unix-dgram', () => { + const mockUnixDgramSocket = { + send: sendMock, + }; + return { + createSocket: vi.fn(() => mockUnixDgramSocket), + }; +}); + +async function runTest(): Promise { + const sd = await import('../lib/util/sd-notify'); + await sd.init(); + sd.started(); + vi.advanceTimersByTime(6000); + sd.stopping(); + sd.stopped(); +} + +const mocksClear = [mockLogger.log, mockLogger.debug, mockLogger.info, mockLogger.warning, mockLogger.error, sendMock]; + +describe('sd-notify', () => { + beforeAll(async () => { + vi.useFakeTimers(); + }); + + afterAll(async () => { + vi.useRealTimers(); + delete process.env.NOTIFY_SOCKET; + delete process.env.WATCHDOG_USEC; + }); + + beforeEach(() => { + vi.resetModules(); + mocksClear.forEach((m) => m.mockClear()); + }); + + it('No socket', async () => { + delete process.env.NOTIFY_SOCKET; + delete process.env.WATCHDOG_USEC; + await runTest(); + expect(sendMock).toHaveBeenCalledTimes(0); + }); + + it('Socket only', async () => { + process.env.NOTIFY_SOCKET = 'mocked'; + delete process.env.WATCHDOG_USEC; + await runTest(); + expect(sendMock).toHaveBeenCalledTimes(2); + expectMessage(1, 'READY=1'); + expectMessage(2, 'STOPPING=1'); + }); + + it('Socket and watchdog', async () => { + process.env.NOTIFY_SOCKET = 'mocked'; + process.env.WATCHDOG_USEC = '10000000'; + await runTest(); + expect(sendMock).toHaveBeenCalledTimes(3); + expectMessage(1, 'READY=1'); + expectMessage(2, 'WATCHDOG=1'); + expectMessage(3, 'STOPPING=1'); + }); + + it('Invalid watchdog timeout', async () => { + process.env.NOTIFY_SOCKET = 'mocked'; + process.env.WATCHDOG_USEC = 'mocked'; + await runTest(); + expect(sendMock).toHaveBeenCalledTimes(2); + expectMessage(1, 'READY=1'); + expectMessage(2, 'STOPPING=1'); + }); +});