Skip to content

Commit bfeac07

Browse files
srettNerivecKoenkk
committed
fix: Implement systemd-notify directly (#26456)
Co-authored-by: Nerivec <[email protected]> Co-authored-by: Koen Kanters <[email protected]>
1 parent b6c0f4e commit bfeac07

File tree

8 files changed

+293
-54
lines changed

8 files changed

+293
-54
lines changed

.github/workflows/ci.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,8 @@ jobs:
188188
cache: pnpm
189189

190190
- name: Install dependencies
191-
# --ignore-scripts prevents the serialport build which often fails on Windows
192-
run: pnpm i --frozen-lockfile --ignore-scripts
191+
# --ignore-scripts prevents build on Windows (only for unix-dgram, so doesn't matter, others have pre-builds)
192+
run: pnpm i --frozen-lockfile ${{ matrix.os == 'windows-latest' && '--ignore-scripts' || '' }}
193193

194194
- name: Build
195195
run: pnpm run build

lib/controller.ts

+5-20
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type {IClientPublishOptions} from 'mqtt';
2-
import type * as SdNotify from 'sd-notify';
32

43
import type {Zigbee2MQTTAPI} from './types/api';
54

@@ -30,12 +29,11 @@ import ExtensionReceive from './extension/receive';
3029
import MQTT from './mqtt';
3130
import State from './state';
3231
import logger from './util/logger';
32+
import {initSdNotify} from './util/sd-notify';
3333
import * as settings from './util/settings';
3434
import utils from './util/utils';
3535
import Zigbee from './zigbee';
3636

37-
type SdNotifyType = typeof SdNotify;
38-
3937
const AllExtensions = [
4038
ExtensionPublish,
4139
ExtensionReceive,
@@ -73,7 +71,7 @@ export class Controller {
7371
private exitCallback: (code: number, restart: boolean) => Promise<void>;
7472
private extensions: Extension[];
7573
private extensionArgs: ExtensionArgs;
76-
private sdNotify: SdNotifyType | undefined;
74+
private sdNotify: Awaited<ReturnType<typeof initSdNotify>>;
7775

7876
constructor(restartCallback: () => Promise<void>, exitCallback: (code: number, restart: boolean) => Promise<void>) {
7977
logger.init();
@@ -128,15 +126,6 @@ export class Controller {
128126
const info = await utils.getZigbee2MQTTVersion();
129127
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
130128

131-
try {
132-
this.sdNotify = process.env.NOTIFY_SOCKET ? await import('sd-notify') : undefined;
133-
logger.debug('sd-notify loaded');
134-
/* v8 ignore start */
135-
} catch {
136-
logger.debug('sd-notify is not installed');
137-
}
138-
/* v8 ignore stop */
139-
140129
// Start zigbee
141130
try {
142131
await this.zigbee.start();
@@ -198,11 +187,7 @@ export class Controller {
198187

199188
logger.info(`Zigbee2MQTT started!`);
200189

201-
const watchdogInterval = this.sdNotify?.watchdogInterval() || 0;
202-
if (watchdogInterval > 0) {
203-
this.sdNotify?.startWatchdogMode(Math.floor(watchdogInterval / 2));
204-
}
205-
this.sdNotify?.ready();
190+
this.sdNotify = await initSdNotify();
206191
}
207192

208193
@bind async enableDisableExtension(enable: boolean, name: string): Promise<void> {
@@ -227,7 +212,7 @@ export class Controller {
227212
}
228213

229214
async stop(restart = false): Promise<void> {
230-
this.sdNotify?.stopping(process.pid);
215+
this.sdNotify?.notifyStopping();
231216

232217
// Call extensions
233218
await this.callExtensions('stop', this.extensions);
@@ -246,7 +231,7 @@ export class Controller {
246231
code = 1;
247232
}
248233

249-
this.sdNotify?.stopWatchdogMode();
234+
this.sdNotify?.stop();
250235
return await this.exit(code, restart);
251236
}
252237

lib/types/unix-dgram.d.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
declare module 'unix-dgram' {
2+
import {EventEmitter} from 'events';
3+
import {Buffer} from 'buffer';
4+
5+
export class UnixDgramSocket extends EventEmitter {
6+
send(buf: Buffer, callback?: (err?: Error) => void): void;
7+
send(buf: Buffer, offset: number, length: number, path: string, callback?: (err?: Error) => void): void;
8+
bind(path: string): void;
9+
connect(remotePath: string): void;
10+
close(): void;
11+
}
12+
13+
export function createSocket(type: 'unix_dgram', listener?: (msg: Buffer) => void): UnixDgramSocket;
14+
}

lib/util/sd-notify.ts

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type {UnixDgramSocket} from 'unix-dgram';
2+
3+
import {platform} from 'node:os';
4+
5+
import logger from './logger';
6+
7+
/**
8+
* Handle sd_notify protocol, @see https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html
9+
* No-op if running on unsupported platforms or without Type=notify
10+
* Soft-fails if improperly setup (this is not necessary for Zigbee2MQTT to function properly)
11+
*/
12+
export async function initSdNotify(): Promise<{notifyStopping: () => void; stop: () => void} | undefined> {
13+
if (!process.env.NOTIFY_SOCKET) {
14+
return;
15+
}
16+
17+
let socket: UnixDgramSocket | undefined;
18+
19+
try {
20+
const {createSocket} = await import('unix-dgram');
21+
socket = createSocket('unix_dgram');
22+
} catch (error) {
23+
if (platform() !== 'win32' || process.env.WSL_DISTRO_NAME) {
24+
// not on plain Windows
25+
logger.error(`Could not init sd_notify: ${(error as Error).message}`);
26+
logger.debug((error as Error).stack!);
27+
} else {
28+
// this should not happen
29+
logger.warning(`NOTIFY_SOCKET env is set: ${(error as Error).message}`);
30+
}
31+
32+
return;
33+
}
34+
35+
const sendToSystemd = (msg: string): void => {
36+
const buffer = Buffer.from(msg);
37+
38+
socket.send(buffer, 0, buffer.byteLength, process.env.NOTIFY_SOCKET!, (err) => {
39+
if (err) {
40+
logger.warning(`Failed to send "${msg}" to systemd: ${err.message}`);
41+
}
42+
});
43+
};
44+
const notifyStopping = (): void => sendToSystemd('STOPPING=1');
45+
46+
sendToSystemd('READY=1');
47+
48+
const wdUSec = process.env.WATCHDOG_USEC !== undefined ? Math.max(0, parseInt(process.env.WATCHDOG_USEC, 10)) : -1;
49+
50+
if (wdUSec > 0) {
51+
// Convert us to ms, send twice as frequently as the timeout
52+
const watchdogInterval = setInterval(() => sendToSystemd('WATCHDOG=1'), wdUSec / 1000 / 2);
53+
54+
return {
55+
notifyStopping,
56+
stop: (): void => clearInterval(watchdogInterval),
57+
};
58+
}
59+
60+
if (wdUSec !== -1) {
61+
logger.warning(`WATCHDOG_USEC invalid: "${process.env.WATCHDOG_USEC}", parsed to "${wdUSec}"`);
62+
}
63+
64+
return {
65+
notifyStopping,
66+
stop: (): void => {},
67+
};
68+
}

package.json

+1-3
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@
7676
"@types/node": "^22.13.5",
7777
"@types/object-assign-deep": "^0.4.3",
7878
"@types/readable-stream": "4.0.18",
79-
"@types/sd-notify": "^2.8.2",
8079
"@types/serve-static": "^1.15.7",
8180
"@types/ws": "8.5.14",
8281
"@vitest/coverage-v8": "^3.0.7",
@@ -95,14 +94,13 @@
9594
"onlyBuiltDependencies": [
9695
"@serialport/bindings-cpp",
9796
"esbuild",
98-
"sd-notify",
9997
"unix-dgram"
10098
]
10199
},
102100
"bin": {
103101
"zigbee2mqtt": "cli.js"
104102
},
105103
"optionalDependencies": {
106-
"sd-notify": "^2.8.0"
104+
"unix-dgram": "^2.0.6"
107105
}
108106
}

pnpm-lock.yaml

+3-21
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/controller.test.ts

+25-8
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {devices, mockController as mockZHController, events as mockZHEvents, ret
1414
import type {Mock, MockInstance} from 'vitest';
1515

1616
import fs from 'node:fs';
17+
import os from 'node:os';
1718
import path from 'node:path';
1819

1920
import stringify from 'json-stable-stringify-without-jsonify';
@@ -24,15 +25,12 @@ import {Controller as ZHController} from 'zigbee-herdsman';
2425
import {Controller} from '../lib/controller';
2526
import * as settings from '../lib/util/settings';
2627

27-
process.env.NOTIFY_SOCKET = 'mocked';
2828
const LOG_MQTT_NS = 'z2m:mqtt';
2929

30-
vi.mock('sd-notify', () => ({
31-
watchdogInterval: vi.fn(() => 3000),
32-
startWatchdogMode: vi.fn(),
33-
stopWatchdogMode: vi.fn(),
34-
ready: vi.fn(),
35-
stopping: vi.fn(),
30+
const mockUnixDgramSend = vi.fn();
31+
32+
vi.mock('unix-dgram', () => ({
33+
createSocket: vi.fn(() => ({send: mockUnixDgramSend})),
3634
}));
3735

3836
const mocksClear = [
@@ -49,6 +47,7 @@ const mocksClear = [
4947
mockLogger.debug,
5048
mockLogger.info,
5149
mockLogger.error,
50+
mockUnixDgramSend,
5251
];
5352

5453
describe('Controller', () => {
@@ -338,14 +337,32 @@ describe('Controller', () => {
338337
expect(mockExit).toHaveBeenCalledWith(0, true);
339338
});
340339

341-
it('Start controller and stop', async () => {
340+
it('Start controller and stop without SdNotify', async () => {
342341
mockZHController.stop.mockRejectedValueOnce('failed');
343342
await controller.start();
344343
await controller.stop();
345344
expect(mockMQTTEndAsync).toHaveBeenCalledTimes(1);
346345
expect(mockZHController.stop).toHaveBeenCalledTimes(1);
347346
expect(mockExit).toHaveBeenCalledTimes(1);
348347
expect(mockExit).toHaveBeenCalledWith(1, false);
348+
expect(mockUnixDgramSend).toHaveBeenCalledTimes(0);
349+
});
350+
351+
it('Start controller and stop with SdNotify', async () => {
352+
vi.spyOn(os, 'platform').mockImplementationOnce(() => 'linux');
353+
354+
process.env.NOTIFY_SOCKET = 'mocked'; // coverage
355+
356+
mockZHController.stop.mockRejectedValueOnce('failed');
357+
await controller.start();
358+
await controller.stop();
359+
expect(mockMQTTEndAsync).toHaveBeenCalledTimes(1);
360+
expect(mockZHController.stop).toHaveBeenCalledTimes(1);
361+
expect(mockExit).toHaveBeenCalledTimes(1);
362+
expect(mockExit).toHaveBeenCalledWith(1, false);
363+
expect(mockUnixDgramSend).toHaveBeenCalledTimes(2);
364+
365+
delete process.env.NOTIFY_SOCKET;
349366
});
350367

351368
it('Start controller adapter disconnects', async () => {

0 commit comments

Comments
 (0)