@push.rocks/smartdaemon 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,13 @@
1
1
  import type * as ffi from 'node:ffi';
2
2
  /** The first Node.js major with `node:ffi`, which this module calls libc through. */
3
3
  export declare const systemdRuntimeNodeMajor = 26;
4
+ /**
5
+ * Internal: builds the `runtime_unsupported` error of a subpath that calls libc through this
6
+ * loader (`systemd-runtime`, `file-lock`), so each subpath refuses with its own error class.
7
+ */
8
+ export type TLibcUnsupportedError = (messageArg: string, causeArg?: unknown) => Error;
9
+ /** Internal: refuses a Node.js older than `systemdRuntimeNodeMajor`; each libc subpath runs it on import. */
10
+ export declare const assertLibcNode: (subpathArg: string, unsupportedArg: TLibcUnsupportedError, versionArg?: string) => void;
4
11
  /** Internal: refuses a Node.js older than `systemdRuntimeNodeMajor`; the subpath runs it on import. */
5
12
  export declare const assertSystemdRuntimeNode: (versionArg?: string) => void;
6
13
  /**
@@ -15,6 +22,8 @@ export declare const assertSystemdRuntimeNode: (versionArg?: string) => void;
15
22
  * `CMSG_LEN(n)` is 16 + n and `CMSG_SPACE(n)` 16 + n rounded up to 8.
16
23
  * - `timespec` (16 bytes): `tv_sec` at 0, `tv_nsec` at 8.
17
24
  * - `rlimit` (16 bytes): `rlim_cur` (u64) at 0, `rlim_max` (u64) at 8; `RLIM_INFINITY` is all ones.
25
+ *
26
+ * `EWOULDBLOCK` is `EAGAIN` (11) on both.
18
27
  */
19
28
  export declare const libcConstants: {
20
29
  readonly AF_UNIX: 1;
@@ -28,7 +37,11 @@ export declare const libcConstants: {
28
37
  readonly FD_CLOEXEC: 1;
29
38
  readonly CLOCK_MONOTONIC: 1;
30
39
  readonly EINTR: 4;
40
+ readonly EWOULDBLOCK: 11;
31
41
  readonly RLIMIT_NOFILE: 7;
42
+ readonly LOCK_EX: 2;
43
+ readonly LOCK_NB: 4;
44
+ readonly LOCK_UN: 8;
32
45
  };
33
46
  /** `sun_path` is 108 bytes; an address of up to 107 bytes keeps a path's terminating NUL. */
34
47
  export declare const socketAddressMaximumBytes = 107;
@@ -59,6 +72,10 @@ declare const libcFunctions: {
59
72
  readonly arguments: readonly ["int32", "pointer"];
60
73
  readonly return: 'int32';
61
74
  };
75
+ readonly flock: {
76
+ readonly arguments: readonly ["int32", "int32"];
77
+ readonly return: 'int32';
78
+ };
62
79
  readonly __errno_location: {
63
80
  readonly arguments: readonly [];
64
81
  readonly return: 'pointer';
@@ -74,10 +91,12 @@ export interface ISystemdLibc {
74
91
  errno(): number;
75
92
  }
76
93
  /**
77
- * glibc, opened through `node:ffi` on first use and kept for the process lifetime. Loading
78
- * `node:ffi` emits Node's ExperimentalWarning once.
94
+ * glibc, opened through `node:ffi` on first use and kept for the process lifetime: the one libc
95
+ * loader of the package, shared by the `systemd-runtime` and `file-lock` subpaths. A refusal is
96
+ * built by `unsupportedArg`, the calling subpath's `runtime_unsupported` error. Loading `node:ffi`
97
+ * emits Node's ExperimentalWarning once.
79
98
  */
80
- export declare const systemdLibc: () => ISystemdLibc;
99
+ export declare const systemdLibc: (unsupportedArg?: TLibcUnsupportedError) => ISystemdLibc;
81
100
  /** The `msg_namelen` of an `AF_UNIX` address: a path keeps its NUL; an abstract name has none. */
82
101
  export declare const socketAddressLength: (addressArg: string) => number;
83
102
  /** Writes a `sockaddr_un` at `offsetArg`; an abstract name's leading `@` becomes its NUL. */
@@ -1,13 +1,16 @@
1
1
  import { SystemdRuntimeError } from './classes.systemdruntimeerror.js';
2
2
  /** The first Node.js major with `node:ffi`, which this module calls libc through. */
3
3
  export const systemdRuntimeNodeMajor = 26;
4
- /** Internal: refuses a Node.js older than `systemdRuntimeNodeMajor`; the subpath runs it on import. */
5
- export const assertSystemdRuntimeNode = (versionArg = process.versions.node) => {
4
+ const systemdRuntimeUnsupported = (messageArg, causeArg) => new SystemdRuntimeError('runtime_unsupported', messageArg, causeArg === undefined ? {} : { cause: causeArg });
5
+ /** Internal: refuses a Node.js older than `systemdRuntimeNodeMajor`; each libc subpath runs it on import. */
6
+ export const assertLibcNode = (subpathArg, unsupportedArg, versionArg = process.versions.node) => {
6
7
  const major = Number(/^(\d+)\./.exec(versionArg)?.[1]);
7
8
  if (!(major >= systemdRuntimeNodeMajor)) {
8
- throw new SystemdRuntimeError('runtime_unsupported', `@push.rocks/smartdaemon/systemd-runtime requires Node.js ${systemdRuntimeNodeMajor} or later; this is Node.js ${versionArg}.`);
9
+ throw unsupportedArg(`@push.rocks/smartdaemon/${subpathArg} requires Node.js ${systemdRuntimeNodeMajor} or later; this is Node.js ${versionArg}.`);
9
10
  }
10
11
  };
12
+ /** Internal: refuses a Node.js older than `systemdRuntimeNodeMajor`; the subpath runs it on import. */
13
+ export const assertSystemdRuntimeNode = (versionArg = process.versions.node) => assertLibcNode('systemd-runtime', systemdRuntimeUnsupported, versionArg);
11
14
  /**
12
15
  * The generic Linux values both qualified targets (x86_64 and aarch64, glibc) use. The structure
13
16
  * layouts below are the LP64 glibc ones, identical on both:
@@ -20,6 +23,8 @@ export const assertSystemdRuntimeNode = (versionArg = process.versions.node) =>
20
23
  * `CMSG_LEN(n)` is 16 + n and `CMSG_SPACE(n)` 16 + n rounded up to 8.
21
24
  * - `timespec` (16 bytes): `tv_sec` at 0, `tv_nsec` at 8.
22
25
  * - `rlimit` (16 bytes): `rlim_cur` (u64) at 0, `rlim_max` (u64) at 8; `RLIM_INFINITY` is all ones.
26
+ *
27
+ * `EWOULDBLOCK` is `EAGAIN` (11) on both.
23
28
  */
24
29
  export const libcConstants = {
25
30
  AF_UNIX: 1,
@@ -33,7 +38,11 @@ export const libcConstants = {
33
38
  FD_CLOEXEC: 1,
34
39
  CLOCK_MONOTONIC: 1,
35
40
  EINTR: 4,
41
+ EWOULDBLOCK: 11,
36
42
  RLIMIT_NOFILE: 7,
43
+ LOCK_EX: 2,
44
+ LOCK_NB: 4,
45
+ LOCK_UN: 8,
37
46
  };
38
47
  /** `sun_path` is 108 bytes; an address of up to 107 bytes keeps a path's terminating NUL. */
39
48
  export const socketAddressMaximumBytes = 107;
@@ -47,29 +56,32 @@ const libcFunctions = {
47
56
  fcntl: { arguments: ['int32', 'int32', 'int32'], return: 'int32' },
48
57
  clock_gettime: { arguments: ['int32', 'pointer'], return: 'int32' },
49
58
  getrlimit: { arguments: ['int32', 'pointer'], return: 'int32' },
59
+ flock: { arguments: ['int32', 'int32'], return: 'int32' },
50
60
  __errno_location: { arguments: [], return: 'pointer' },
51
61
  };
52
62
  let loaded = null;
53
63
  /**
54
- * glibc, opened through `node:ffi` on first use and kept for the process lifetime. Loading
55
- * `node:ffi` emits Node's ExperimentalWarning once.
64
+ * glibc, opened through `node:ffi` on first use and kept for the process lifetime: the one libc
65
+ * loader of the package, shared by the `systemd-runtime` and `file-lock` subpaths. A refusal is
66
+ * built by `unsupportedArg`, the calling subpath's `runtime_unsupported` error. Loading `node:ffi`
67
+ * emits Node's ExperimentalWarning once.
56
68
  */
57
- export const systemdLibc = () => {
69
+ export const systemdLibc = (unsupportedArg = systemdRuntimeUnsupported) => {
58
70
  if (loaded !== null)
59
71
  return loaded;
60
72
  if (process.platform !== 'linux' || (process.arch !== 'x64' && process.arch !== 'arm64')) {
61
- throw new SystemdRuntimeError('runtime_unsupported', `The systemd runtime supports Linux on x64 and arm64; this is ${process.platform} on ${process.arch}.`);
73
+ throw unsupportedArg(`glibc calls through node:ffi support Linux on x64 and arm64; this is ${process.platform} on ${process.arch}.`);
62
74
  }
63
75
  const ffiModule = process.getBuiltinModule('node:ffi');
64
76
  if (ffiModule === undefined) {
65
- throw new SystemdRuntimeError('runtime_unsupported', 'node:ffi is not available (disabled by --no-experimental-ffi?).');
77
+ throw unsupportedArg('node:ffi is not available (disabled by --no-experimental-ffi?).');
66
78
  }
67
79
  let functions;
68
80
  try {
69
81
  functions = ffiModule.dlopen('libc.so.6', libcFunctions).functions;
70
82
  }
71
83
  catch (error) {
72
- throw new SystemdRuntimeError('runtime_unsupported', 'glibc (libc.so.6) could not be opened through node:ffi; under the permission model pass --allow-ffi.', { cause: error });
84
+ throw unsupportedArg('glibc (libc.so.6) could not be opened through node:ffi; under the permission model pass --allow-ffi.', error);
73
85
  }
74
86
  loaded = Object.freeze({
75
87
  functions,
@@ -89,4 +101,4 @@ export const writeSocketAddress = (bufferArg, offsetArg, addressArg) => {
89
101
  };
90
102
  /** `CMSG_SPACE` for `countArg` descriptors. */
91
103
  export const controlSpace = (countArg) => 16 + ((4 * countArg + 7) & ~7);
92
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3lzdGVtZHJ1bnRpbWUubGliYy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzX3N5c3RlbWRydW50aW1lL3N5c3RlbWRydW50aW1lLmxpYmMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQ0EsT0FBTyxFQUFFLG1CQUFtQixFQUFFLE1BQU0sa0NBQWtDLENBQUM7QUFFdkUscUZBQXFGO0FBQ3JGLE1BQU0sQ0FBQyxNQUFNLHVCQUF1QixHQUFHLEVBQUUsQ0FBQztBQUUxQyx1R0FBdUc7QUFDdkcsTUFBTSxDQUFDLE1BQU0sd0JBQXdCLEdBQUcsQ0FBQyxVQUFVLEdBQVcsT0FBTyxDQUFDLFFBQVEsQ0FBQyxJQUFJLEVBQVEsRUFBRTtJQUMzRixNQUFNLEtBQUssR0FBRyxNQUFNLENBQUMsVUFBVSxDQUFDLElBQUksQ0FBQyxVQUFVLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUM7SUFDdkQsSUFBSSxDQUFDLENBQUMsS0FBSyxJQUFJLHVCQUF1QixDQUFDLEVBQUUsQ0FBQztRQUN4QyxNQUFNLElBQUksbUJBQW1CLENBQzNCLHFCQUFxQixFQUNyQiw0REFBNEQsdUJBQXVCLDhCQUE4QixVQUFVLEdBQUcsQ0FDL0gsQ0FBQztJQUNKLENBQUM7QUFDSCxDQUFDLENBQUM7QUFFRjs7Ozs7Ozs7Ozs7O0dBWUc7QUFDSCxNQUFNLENBQUMsTUFBTSxhQUFhLEdBQUc7SUFDM0IsT0FBTyxFQUFFLENBQUM7SUFDVixVQUFVLEVBQUUsQ0FBQztJQUNiLFlBQVksRUFBRSxTQUFTO0lBQ3ZCLFVBQVUsRUFBRSxDQUFDO0lBQ2IsVUFBVSxFQUFFLENBQUM7SUFDYixZQUFZLEVBQUUsTUFBTTtJQUNwQixPQUFPLEVBQUUsQ0FBQztJQUNWLE9BQU8sRUFBRSxDQUFDO0lBQ1YsVUFBVSxFQUFFLENBQUM7SUFDYixlQUFlLEVBQUUsQ0FBQztJQUNsQixLQUFLLEVBQUUsQ0FBQztJQUNSLGFBQWEsRUFBRSxDQUFDO0NBQ1IsQ0FBQztBQUVY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
104
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3lzdGVtZHJ1bnRpbWUubGliYy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzX3N5c3RlbWRydW50aW1lL3N5c3RlbWRydW50aW1lLmxpYmMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQ0EsT0FBTyxFQUFFLG1CQUFtQixFQUFFLE1BQU0sa0NBQWtDLENBQUM7QUFFdkUscUZBQXFGO0FBQ3JGLE1BQU0sQ0FBQyxNQUFNLHVCQUF1QixHQUFHLEVBQUUsQ0FBQztBQVExQyxNQUFNLHlCQUF5QixHQUEwQixDQUFDLFVBQVUsRUFBRSxRQUFRLEVBQUUsRUFBRSxDQUNoRixJQUFJLG1CQUFtQixDQUFDLHFCQUFxQixFQUFFLFVBQVUsRUFBRSxRQUFRLEtBQUssU0FBUyxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUUsS0FBSyxFQUFFLFFBQVEsRUFBRSxDQUFDLENBQUM7QUFFaEgsNkdBQTZHO0FBQzdHLE1BQU0sQ0FBQyxNQUFNLGNBQWMsR0FBRyxDQUM1QixVQUFrQixFQUNsQixjQUFxQyxFQUNyQyxVQUFVLEdBQVcsT0FBTyxDQUFDLFFBQVEsQ0FBQyxJQUFJLEVBQ3BDLEVBQUU7SUFDUixNQUFNLEtBQUssR0FBRyxNQUFNLENBQUMsVUFBVSxDQUFDLElBQUksQ0FBQyxVQUFVLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUM7SUFDdkQsSUFBSSxDQUFDLENBQUMsS0FBSyxJQUFJLHVCQUF1QixDQUFDLEVBQUUsQ0FBQztRQUN4QyxNQUFNLGNBQWMsQ0FDbEIsMkJBQTJCLFVBQVUscUJBQXFCLHVCQUF1Qiw4QkFBOEIsVUFBVSxHQUFHLENBQzdILENBQUM7SUFDSixDQUFDO0FBQ0gsQ0FBQyxDQUFDO0FBRUYsdUdBQXVHO0FBQ3ZHLE1BQU0sQ0FBQyxNQUFNLHdCQUF3QixHQUFHLENBQUMsVUFBVSxHQUFXLE9BQU8sQ0FBQyxRQUFRLENBQUMsSUFBSSxFQUFRLEVBQUUsQ0FDM0YsY0FBYyxDQUFD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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@push.rocks/smartdaemon",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "private": false,
5
5
  "description": "Start scripts as long running daemons and manage them.",
6
6
  "main": "dist_ts/index.js",
@@ -14,6 +14,10 @@
14
14
  "types": "./dist_ts_systemdruntime/index.d.ts",
15
15
  "default": "./dist_ts_systemdruntime/index.js"
16
16
  },
17
+ "./file-lock": {
18
+ "types": "./dist_ts_filelock/index.d.ts",
19
+ "default": "./dist_ts_filelock/index.js"
20
+ },
17
21
  "./package.json": "./package.json"
18
22
  },
19
23
  "type": "module",
@@ -37,10 +41,8 @@
37
41
  },
38
42
  "homepage": "https://code.foss.global/push.rocks/smartdaemon",
39
43
  "devDependencies": {
40
- "@git.zone/tsbuild": "^6.0.2",
41
- "@git.zone/tsrun": "^3.0.3",
42
- "@git.zone/tstest": "^8.0.0",
43
- "@types/node": "^26.6.4"
44
+ "@git.zone/tsrepo": "^2.1.0",
45
+ "@types/node": "^26.6.6"
44
46
  },
45
47
  "dependencies": {
46
48
  "@push.rocks/lik": "^6.4.4",
@@ -52,6 +54,7 @@
52
54
  "files": [
53
55
  "ts/**/*",
54
56
  "ts_systemdruntime/**/*",
57
+ "ts_filelock/**/*",
55
58
  "ts_web/**/*",
56
59
  "dist/**/*",
57
60
  "dist_*/**/*",
@@ -68,9 +71,9 @@
68
71
  "last 1 chrome versions"
69
72
  ],
70
73
  "scripts": {
71
- "test": "tstest test/ --verbose --timeout 60",
72
- "build": "tsbuild tsfolders --web",
74
+ "test": "tsrepo test test/ --verbose --timeout 60",
75
+ "build": "tsrepo build tsfolders --web",
73
76
  "format": "gitzone format",
74
- "buildDocs": "tsdoc"
77
+ "buildDocs": "tsrepo docs"
75
78
  }
76
79
  }
package/readme.md CHANGED
@@ -293,6 +293,49 @@ store needs systemd 254 or later: the call reads the manager's version first and
293
293
  manager with `SystemdUnitManagerTooOldError` (`manager_too_old`), an unreadable version with
294
294
  `version_unknown` and an unanswered read with `command_failed`, each without running the clean.
295
295
 
296
+ `kill(signal?, options?)` ends a unit's processes without a stop job: it sends `SIGKILL` (the
297
+ default) or `SIGTERM` to every process of the unit at once, its main and control process and every
298
+ other process in its control group (`systemctl kill --signal=<signal> --kill-whom=all`), then reads
299
+ the unit every 100 ms until it is at rest or about to restart, for at most `timeoutMs` (integer
300
+ milliseconds from 1 to 600000, default 30000). Use it where a `stop()` would wait out the unit's
301
+ `TimeoutStopSec=`, such as a unit whose stop already failed or overran; it does not run `ExecStop=`,
302
+ does not cancel or await a pending job, and never starts, enables, unmasks or resets the unit. It
303
+ resolves a frozen `{ outcome, state }`, where `state` is the snapshot that decided the outcome (for
304
+ `timeout`, the last one read):
305
+
306
+ | `outcome` | Meaning |
307
+ | --- | --- |
308
+ | `'stopped'`, `'failed'` | `systemdUnitQuiescence()`'s `rest`: no main or control process and no control group. A unit whose main process is gone while its control group still holds processes resolves only once the group is gone. A unit already at rest is signalled all the same (nothing receives it) and resolves at the first read |
309
+ | `'auto-restart'` | SubState `auto-restart`, or `auto-restart-queued` (systemd 254), without a main or control process: the unit waits for its `Restart=` timer or its queued restart. Its control group is not read here: with `RestartMode=direct` the manager keeps the emptied group |
310
+ | `'restarted'` | the manager began a new invocation (`InvocationID`) before a read saw the unit at rest: a restart delay shorter than the poll interval, or another client's start |
311
+ | `'timeout'` | none of these within `timeoutMs`; a read begun before the deadline is still awaited, each bounded by `commandTimeoutMs` |
312
+
313
+ A unit whose `LoadState` is not `loaded` in the read before the signal (`not-found`, `masked`,
314
+ `bad-setting`, `error`, ...) is refused with `command_failed` before any signal: that read makes the
315
+ manager load the unit object, and the manager may signal an improperly loaded unit, so such a unit
316
+ would otherwise resolve `stopped`. A refused signal and a `systemctl kill` that exceeded
317
+ `commandTimeoutMs` reject with `command_failed` too; after a timed-out call the signal may have been
318
+ sent, so inspect the unit. Once the manager accepted the signal, a failed read rejects with
319
+ `signalled_uninspected` and that read's error as `cause`. `signalled_uninspected` is a new member of
320
+ `SystemdUnitError['code']`: a consumer with an exhaustive `never` switch over the code must add a
321
+ case for it. `kill()` needs systemd 252 or later, the first whose `systemctl` takes
322
+ `--kill-whom=`: it reads the manager's version first and refuses an older manager with
323
+ `SystemdUnitManagerTooOldError`, an unreadable version with `version_unknown` and an unanswered read
324
+ with `command_failed`, each before any signal. A signal other than `'SIGKILL'` or `'SIGTERM'` and an
325
+ option other than `timeoutMs` reject with `invalid_options` before any command runs.
326
+
327
+ ```typescript
328
+ const killed = await unit.kill('SIGKILL', { timeoutMs: 30_000 });
329
+ if (killed.outcome === 'timeout') {
330
+ console.log('still running', killed.state.activeState, killed.state.controlGroup);
331
+ } else if (killed.outcome !== 'restarted') {
332
+ await unit.start();
333
+ }
334
+ ```
335
+
336
+ After an `auto-restart` outcome the start job stays queued until the unit's `RestartSec=` elapses,
337
+ so `start()` waits for it; a long `RestartSec=` can exceed `commandTimeoutMs`.
338
+
296
339
  `inspectConfiguration()` reports the selected `fragmentPath`, opaque `dropInPaths`
297
340
  list and `needsReload` flag. `reloadConfiguration()` explicitly reloads the manager's
298
341
  unit definitions globally and returns a fresh configuration snapshot. Neither
@@ -1112,6 +1155,70 @@ verifies the flag on every passed descriptor, as `sd_listen_fds(3)` does.
1112
1155
  caller's user manager (`systemd-run --user`), and skips with the reason without Node.js 26 or a
1113
1156
  user manager.
1114
1157
 
1158
+ ### An exclusive file lock on an open descriptor (`/file-lock`, Node.js 26)
1159
+
1160
+ Node.js has no file lock. The `@push.rocks/smartdaemon/file-lock` subpath takes the kernel's
1161
+ `flock(2)` lock on a descriptor the caller opened, through the same glibc loader as
1162
+ `/systemd-runtime`, and nothing more: no path, no open, no class, no wait, no shared lock.
1163
+
1164
+ ```typescript
1165
+ import * as fs from 'node:fs';
1166
+ import { FileLockError, tryLockFile, unlockFile } from '@push.rocks/smartdaemon/file-lock';
1167
+
1168
+ // The caller opens (and checks) the file itself; the lock belongs to this open file description.
1169
+ const descriptor = fs.openSync('/var/lib/example/.owner.lock', 'r+');
1170
+ if (!tryLockFile(descriptor)) throw new Error('another process owns /var/lib/example');
1171
+ try {
1172
+ // ... work only the owner may do ...
1173
+ } finally {
1174
+ unlockFile(descriptor); // or close the descriptor, or exit
1175
+ fs.closeSync(descriptor);
1176
+ }
1177
+ ```
1178
+
1179
+ | Call | Does |
1180
+ | --- | --- |
1181
+ | `tryLockFile(fd)` | `flock(fd, LOCK_EX \| LOCK_NB)`: true when granted (also when this description already holds it), false only when another holds a conflicting lock (`EWOULDBLOCK`) |
1182
+ | `unlockFile(fd)` | `flock(fd, LOCK_UN)`; releasing a description that holds no lock succeeds |
1183
+
1184
+ Both are synchronous and either complete or throw `FileLockError` (`code`, and `errno` when the
1185
+ libc call failed): `lock_failed` and `unlock_failed` carry the errno (`EBADF` for a descriptor that
1186
+ is not open), and a number that cannot be a descriptor (not an integer from 0 to `INT_MAX`) is
1187
+ `descriptor_invalid` before any call.
1188
+
1189
+ What the lock is:
1190
+
1191
+ - **Advisory and exclusive.** It excludes only other `flock(2)` locks; a process that does not ask
1192
+ is not stopped from reading or writing the file. `/proc/locks` shows it as `FLOCK ADVISORY WRITE`.
1193
+ - **Held by the open file description**, not by the process or the descriptor number. A `dup` of
1194
+ the descriptor shares it, and so does a descriptor a child inherits (through `stdio`, for
1195
+ example): the child keeps the lock after the parent closed its copy or died. A second `open` of
1196
+ the same file, even in the same process, is a new description and conflicts.
1197
+ - **Released** by `unlockFile`, or when the last descriptor of the description is closed, which a
1198
+ process exit (`SIGKILL` included) does.
1199
+ - **Not an `fcntl` lock.** On a local Linux filesystem `fcntl(2)` record locks (`F_SETLK`,
1200
+ `F_OFD_SETLK`) and `flock(2)` locks do not conflict with each other, so a peer that uses `fcntl` is
1201
+ not excluded.
1202
+ - Any other program taking `flock(2)` on the same file, util-linux `flock(1)` for example, excludes
1203
+ and is excluded by it.
1204
+
1205
+ It requires **Node.js 26 or later** on Linux x64 or arm64 with glibc, like `/systemd-runtime`:
1206
+ importing it on an older Node.js throws `FileLockError` `runtime_unsupported`, and the first call
1207
+ throws the same code on another platform or with `node:ffi` unavailable or denied (pass
1208
+ `--allow-ffi` under the permission model).
1209
+
1210
+ There is **no blocking lock**, on purpose. `node:ffi` has no off-thread call mode, so a blocking
1211
+ `flock(fd, LOCK_EX)` on the main thread stops the event loop for the whole wait: signal handlers,
1212
+ timers and `sd_notify` messages all stall. Moving it into a worker does not help: a worker blocked
1213
+ in `flock` cannot be terminated, and `process.exit()` waits until the lock is granted, which is
1214
+ never while another process holds it. A caller that must wait polls `tryLockFile` on a timer:
1215
+
1216
+ ```typescript
1217
+ const lockWhenFree = async (fd: number, intervalMs = 100): Promise<void> => {
1218
+ while (!tryLockFile(fd)) await new Promise((resolve) => setTimeout(resolve, intervalMs));
1219
+ };
1220
+ ```
1221
+
1115
1222
  ### Starting a program in its own transient scope
1116
1223
 
1117
1224
  A service that launches a long-lived program keeps that program in its own
@@ -1368,8 +1475,8 @@ console.log(template); // View generated systemd unit file
1368
1475
  - **systemd 240 or later** for installing a definition with an `append` output (`standardOutput` or
1369
1476
  `standardError`), refused the same way
1370
1477
  - **Node.js**: Version 14.x or higher
1371
- - **Node.js 26 or later** for the `@push.rocks/smartdaemon/systemd-runtime` subpath (`node:ffi`), on Linux
1372
- x64 or arm64 with glibc
1478
+ - **Node.js 26 or later** for the `@push.rocks/smartdaemon/systemd-runtime` and
1479
+ `@push.rocks/smartdaemon/file-lock` subpaths (`node:ffi`), on Linux x64 or arm64 with glibc
1373
1480
  - **Permissions**: Either root access or properly configured sudo
1374
1481
 
1375
1482
  ## ⚙️ Generated Systemd Service Files
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartdaemon',
6
- version: '3.0.0',
6
+ version: '3.2.0',
7
7
  description: 'Start scripts as long running daemons and manage them.'
8
8
  }
@@ -12,6 +12,7 @@ import {
12
12
  type ISystemdManagerVersion,
13
13
  fileDescriptorStoreCleanMajor,
14
14
  fileDescriptorStorePreserveMajor,
15
+ killWhomMajor,
15
16
  managerVersionArguments,
16
17
  parseManagerVersion,
17
18
  } from './systemd.managerversion.js';
@@ -127,6 +128,49 @@ export interface ISystemdUnitJobOptions {
127
128
  wait?: boolean;
128
129
  }
129
130
 
131
+ /** The signals `SystemdUnit.kill()` sends. */
132
+ export type TSystemdUnitKillSignal = 'SIGKILL' | 'SIGTERM';
133
+
134
+ /** `SystemdUnit.kill()` options. */
135
+ export interface ISystemdUnitKillOptions {
136
+ /**
137
+ * How long to wait, after the manager accepted the signal, for the unit to come to rest: integer
138
+ * milliseconds from 1 to 600000. Defaults to 30000. A read begun before the deadline is still
139
+ * awaited; each read is bounded by `commandTimeoutMs`.
140
+ */
141
+ timeoutMs?: number;
142
+ }
143
+
144
+ /**
145
+ * How the unit stood when `SystemdUnit.kill()` stopped waiting. `stopped` and `failed` are
146
+ * `systemdUnitQuiescence()`'s `rest`: no main or control process and no control group.
147
+ * `auto-restart`: SubState `auto-restart` or `auto-restart-queued` (systemd 254) without a main or
148
+ * control process, waiting for its `Restart=` timer or its queued restart job. `restarted`: the
149
+ * manager started a new invocation of the unit (a restart delay shorter than the poll interval, or
150
+ * another client's start) before a read saw it at rest. `timeout`: none of these within `timeoutMs`.
151
+ */
152
+ export type TSystemdUnitKillOutcome = 'stopped' | 'failed' | 'auto-restart' | 'restarted' | 'timeout';
153
+
154
+ export interface ISystemdUnitKillResult {
155
+ readonly outcome: TSystemdUnitKillOutcome;
156
+ /** The snapshot that decided `outcome`; for `timeout`, the last one read before the deadline. */
157
+ readonly state: ISystemdUnitState;
158
+ }
159
+
160
+ /** Interval between the snapshots `kill()` reads while it waits. */
161
+ const killPollIntervalMs = 100;
162
+
163
+ /** Reads the outcome a kill has reached from one snapshot, or null while it has reached none. */
164
+ const killOutcome = (state: ISystemdUnitState, newInvocation: boolean): TSystemdUnitKillOutcome | null => {
165
+ const { rest } = systemdUnitQuiescence(state);
166
+ if (rest !== null) return rest;
167
+ // In auto-restart the manager may keep the unit's (empty) control group: with RestartMode=direct
168
+ // the unit never passes the inactive state that releases it, so ControlGroup is not read here.
169
+ if (state.mainPid === 0 && state.controlPid === 0 &&
170
+ (state.subState === 'auto-restart' || state.subState === 'auto-restart-queued')) return 'auto-restart';
171
+ return newInvocation ? 'restarted' : null;
172
+ };
173
+
130
174
  export interface ISystemdUnitConfigurationState {
131
175
  readonly unitName: string;
132
176
  readonly fragmentPath: string;
@@ -232,12 +276,15 @@ export class SystemdUnitError extends Error {
232
276
  * `version_unknown`: the manager answered, but its `Version` property is not one this package
233
277
  * reads, so a call that needs a minimum manager version refuses.
234
278
  * `manager_too_old`: always a `SystemdUnitManagerTooOldError`, naming both versions.
279
+ * `signalled_uninspected`: only `kill()`. The manager accepted the signal, and a snapshot read
280
+ * while waiting failed; `cause` is that inspection's error.
235
281
  */
236
282
  public readonly code:
237
283
  | 'invalid_options'
238
284
  | 'command_failed'
239
285
  | 'invalid_state'
240
286
  | 'released_uninspected'
287
+ | 'signalled_uninspected'
241
288
  | 'version_unknown'
242
289
  | 'manager_too_old',
243
290
  options?: ErrorOptions,
@@ -480,10 +527,18 @@ export class SystemdUnit {
480
527
 
481
528
  /** Snapshot only. Callers own application-specific admission and restart policy. */
482
529
  public async inspect(): Promise<ISystemdUnitState> {
530
+ return (await this.readState(properties)).state;
531
+ }
532
+
533
+ /**
534
+ * Reads one snapshot with `keys`: the snapshot properties, optionally followed by `InvocationID`,
535
+ * which is empty for a unit never started and otherwise 32 lowercase hex digits.
536
+ */
537
+ private async readState(keys: readonly string[]): Promise<{ state: ISystemdUnitState; invocationId: string }> {
483
538
  const stdout = await this.command([
484
539
  'show',
485
540
  this.unitName,
486
- `--property=${properties.join(',')}`,
541
+ `--property=${keys.join(',')}`,
487
542
  '--all',
488
543
  ]);
489
544
  const fields = new Map<string, string>();
@@ -491,13 +546,14 @@ export class SystemdUnit {
491
546
  for (const line of (stdout.endsWith('\n') ? stdout.slice(0, -1) : stdout).split('\n')) {
492
547
  const separator = line.indexOf('=');
493
548
  const key = line.slice(0, separator);
494
- if (separator < 1 || !(properties as readonly string[]).includes(key) || fields.has(key)) {
549
+ if (separator < 1 || !keys.includes(key) || fields.has(key)) {
495
550
  throw new SystemdUnitError('invalid_state');
496
551
  }
497
552
  fields.set(key, line.slice(separator + 1));
498
553
  }
499
554
  if (
500
- fields.size !== properties.length || fields.get('Id') !== this.unitName ||
555
+ fields.size !== keys.length || fields.get('Id') !== this.unitName ||
556
+ (keys.includes('InvocationID') && !/^(?:[0-9a-f]{32})?$/.test(fields.get('InvocationID') ?? '')) ||
501
557
  ['LoadState', 'ActiveState', 'SubState'].some((key) => !fields.get(key)) ||
502
558
  !/^[a-z][a-z-]{0,31}$/.test(fields.get('Result')!) ||
503
559
  ['MainPID', 'ControlPID', 'NFileDescriptorStore'].some((key) =>
@@ -506,7 +562,7 @@ export class SystemdUnit {
506
562
  // systemd prints the store's unsigned 32-bit count in decimal.
507
563
  Number(fields.get('NFileDescriptorStore')) > 4294967295
508
564
  ) throw new SystemdUnitError('invalid_state');
509
- return Object.freeze({
565
+ const state: ISystemdUnitState = Object.freeze({
510
566
  unitName: this.unitName,
511
567
  loadState: fields.get('LoadState')!,
512
568
  activeState: fields.get('ActiveState')!,
@@ -521,6 +577,7 @@ export class SystemdUnit {
521
577
  result: fields.get('Result')!,
522
578
  fileDescriptorStoreCount: Number(fields.get('NFileDescriptorStore')),
523
579
  });
580
+ return { state, invocationId: fields.get('InvocationID') ?? '' };
524
581
  }
525
582
 
526
583
  /**
@@ -585,6 +642,65 @@ export class SystemdUnit {
585
642
  return this.inspect();
586
643
  }
587
644
 
645
+ /**
646
+ * Sends `signal` (default `SIGKILL`) to every process of the unit at once, its main and control
647
+ * process and every other process in its control group (`systemctl kill --signal=<signal>
648
+ * --kill-whom=all`), then waits, bounded by `timeoutMs`, until the unit is at rest or about to
649
+ * restart, and resolves with the outcome and the snapshot that decided it. It does not queue a
650
+ * stop job and does not wait out `TimeoutStopSec=`; a pending job is neither cancelled nor
651
+ * awaited. It never starts, enables, unmasks or resets the unit.
652
+ *
653
+ * A unit already at rest is signalled all the same (nothing receives it) and resolves at the first
654
+ * read. A unit whose main process is gone while its control group still holds processes resolves
655
+ * only once the group is gone. A unit whose `LoadState` in the read before the signal is not
656
+ * `loaded` (`not-found`, `masked`, `bad-setting`, `error`, ...) is refused with `command_failed`
657
+ * before any signal, as are a refused signal and a `systemctl` call that exceeded
658
+ * `commandTimeoutMs`; after a timed-out call the signal may have been sent, so inspect the unit. Once the manager accepted the signal, a failed read
659
+ * rejects with `signalled_uninspected` and that read's error as `cause`.
660
+ *
661
+ * Needs systemd 252 or later, the first whose `systemctl` takes `--kill-whom=`: the manager's
662
+ * version is read first, and an older manager is refused with `SystemdUnitManagerTooOldError`, an
663
+ * unreadable version with `version_unknown` and an unanswered read with `command_failed`, each
664
+ * before any signal. A malformed signal or option rejects with `invalid_options` before any command.
665
+ */
666
+ public async kill(
667
+ signalArg: TSystemdUnitKillSignal = 'SIGKILL',
668
+ optionsArg?: ISystemdUnitKillOptions,
669
+ ): Promise<ISystemdUnitKillResult> {
670
+ if (
671
+ (signalArg !== 'SIGKILL' && signalArg !== 'SIGTERM') ||
672
+ (optionsArg !== undefined && (
673
+ typeof optionsArg !== 'object' || optionsArg === null ||
674
+ Object.keys(optionsArg).some((key) => key !== 'timeoutMs') ||
675
+ (optionsArg.timeoutMs !== undefined && (!Number.isSafeInteger(optionsArg.timeoutMs) ||
676
+ optionsArg.timeoutMs < 1 || optionsArg.timeoutMs > 600000))
677
+ ))
678
+ ) throw new SystemdUnitError('invalid_options');
679
+ const timeoutMs = optionsArg?.timeoutMs ?? 30000;
680
+ await this.requireManagerVersion(killWhomMajor);
681
+ const killKeys = [...properties, 'InvocationID'];
682
+ // The invocation before the signal, so a restart the wait could not observe at rest is reported.
683
+ const { state: before, invocationId } = await this.readState(killKeys);
684
+ // That read makes the manager load the unit object, and the manager may signal a unit it could
685
+ // not load properly; such a unit has no processes of its own to end, so it is refused here.
686
+ if (before.loadState !== 'loaded') throw new SystemdUnitError('command_failed');
687
+ await this.command(['kill', `--signal=${signalArg}`, '--kill-whom=all', this.unitName]);
688
+ const deadline = performance.now() + timeoutMs;
689
+ for (;;) {
690
+ let read: { state: ISystemdUnitState; invocationId: string };
691
+ try {
692
+ read = await this.readState(killKeys);
693
+ } catch (error) {
694
+ throw new SystemdUnitError('signalled_uninspected', { cause: error });
695
+ }
696
+ const outcome = killOutcome(read.state, read.invocationId !== invocationId);
697
+ if (outcome !== null) return Object.freeze({ outcome, state: read.state });
698
+ const remaining = deadline - performance.now();
699
+ if (remaining <= 0) return Object.freeze({ outcome: 'timeout', state: read.state });
700
+ await plugins.timers.setTimeout(Math.min(killPollIntervalMs, remaining));
701
+ }
702
+ }
703
+
588
704
  /** Reports the manager's selected definition without reading or changing its contents. */
589
705
  public async inspectConfiguration(): Promise<ISystemdUnitConfigurationState> {
590
706
  const keys = ['Id', 'FragmentPath', 'DropInPaths', 'NeedDaemonReload'];
@@ -6,9 +6,10 @@ import { constants as fsConstants } from 'node:fs';
6
6
  import * as crypto from 'node:crypto';
7
7
  import * as util from 'node:util';
8
8
  import * as os from 'node:os';
9
+ import * as timers from 'node:timers/promises';
9
10
  export type { Stats } from 'node:fs';
10
11
 
11
- export { path, childProcess, fs, fsConstants, crypto, util, os };
12
+ export { path, childProcess, fs, fsConstants, crypto, util, os, timers };
12
13
 
13
14
  // @pushrocks scope
14
15
  import * as lik from '@push.rocks/lik';
@@ -19,6 +19,9 @@ export const outputAppendMajor = 240;
19
19
  /** Internal: the systemd major whose `systemctl clean` first releases a file descriptor store (`--what=fdstore`). */
20
20
  export const fileDescriptorStoreCleanMajor = 254;
21
21
 
22
+ /** Internal: the systemd major whose `systemctl kill` first takes `--kill-whom=` (formerly `--kill-who=`). */
23
+ export const killWhomMajor = 252;
24
+
22
25
  /**
23
26
  * Internal: the systemd major whose `systemd-run` first takes `--expand-environment` and reports a
24
27
  * started scope as `runInTransientScope()` reads it.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Why a file lock operation refused or failed:
3
+ *
4
+ * - `runtime_unsupported`: the lock cannot be called: Node.js older than 26, a platform other than
5
+ * Linux on x64 or arm64, a C library other than glibc (`libc.so.6`), `node:ffi` disabled
6
+ * (`--no-experimental-ffi`) or denied by the permission model (no `--allow-ffi`).
7
+ * - `descriptor_invalid`: a descriptor number that cannot exist (not an integer from 0 to
8
+ * `INT_MAX`); refused before any call.
9
+ * - `lock_failed`: `flock(LOCK_EX | LOCK_NB)` failed for a reason other than a conflicting lock
10
+ * (`errno` names why: `EBADF` for a descriptor that is not open, for example).
11
+ * - `unlock_failed`: `flock(LOCK_UN)` failed (`errno` names why).
12
+ */
13
+ export type TFileLockErrorCode = 'runtime_unsupported' | 'descriptor_invalid' | 'lock_failed' | 'unlock_failed';
14
+
15
+ export interface IFileLockErrorOptions {
16
+ /** The C library's `errno` after the failed call, when a call failed. */
17
+ readonly errno?: number;
18
+ readonly cause?: unknown;
19
+ }
20
+
21
+ export class FileLockError extends Error {
22
+ /** The C library's `errno` after the failed call, or null when no call failed. */
23
+ public readonly errno: number | null;
24
+
25
+ constructor(
26
+ public readonly code: TFileLockErrorCode,
27
+ messageArg: string,
28
+ optionsArg: IFileLockErrorOptions = {},
29
+ ) {
30
+ super(
31
+ `${messageArg}${optionsArg.errno === undefined ? '' : ` (errno ${optionsArg.errno})`}`,
32
+ optionsArg.cause === undefined ? undefined : { cause: optionsArg.cause },
33
+ );
34
+ this.name = 'FileLockError';
35
+ this.errno = optionsArg.errno ?? null;
36
+ }
37
+ }