@mearl/daemon-core 2.20.6 → 2.22.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.
package/README.md CHANGED
@@ -12,9 +12,12 @@ Mearl 各 Node 包共享的守护进程生命周期原语:进程存活探测
12
12
 
13
13
  ```typescript
14
14
  isAlive(pid: number | undefined | null): boolean
15
+ isProcessGroupAlive(group: number): boolean
15
16
  ```
16
17
 
17
- 用 signal-0 探测进程是否存活(`EPERM` 视为存活——进程存在但属于他人)。
18
+ 用 signal-0 探测进程是否存活(`EPERM` 视为存活——进程存在但属于他人)。Linux 同时读取 `/proc/<pid>/stat`,将已退出、尚未回收的 zombie 和 dead 状态视为已停止;主线程退出但仍有其他线程时,以及无法读取状态时,保守视为存活。
19
+
20
+ POSIX 进程组检查包含根进程退出后仍存活的后代。Linux 只有在两次检查得到相同的已退出成员及创建身份时,才确认组内执行已停止;活跃后代、成员变化和权限或读取失败都会保留清理未确认状态。Windows 不使用 POSIX 进程组检查。
18
21
 
19
22
  ### 路径派生(多实例)
20
23
 
@@ -0,0 +1,38 @@
1
+ import fs from 'node:fs';
2
+ export interface SpawnCommand {
3
+ command: string;
4
+ args: string[];
5
+ }
6
+ export interface CommandResult {
7
+ code: number;
8
+ stdout: string;
9
+ stderr: string;
10
+ }
11
+ export interface RunCommandOptions {
12
+ capture?: boolean;
13
+ /** 显示并捕获输出,同时继承 stdin 以保留安装器交互。 */
14
+ tee?: boolean;
15
+ cwd?: string;
16
+ env?: NodeJS.ProcessEnv;
17
+ timeoutMs?: number;
18
+ }
19
+ export type CommandRunner = (command: string, args: string[], options?: RunCommandOptions) => Promise<CommandResult>;
20
+ export declare function isExecutable(filePath: string, platform?: NodeJS.Platform): boolean;
21
+ export declare function executableNames(name: string, platform?: NodeJS.Platform): string[];
22
+ export interface VersionManagerExecutableCandidateOptions {
23
+ env?: NodeJS.ProcessEnv;
24
+ platform?: NodeJS.Platform;
25
+ readdirSync?: typeof fs.readdirSync;
26
+ }
27
+ export declare function versionManagerExecutableCandidates(home: string, names: string[], options?: VersionManagerExecutableCandidateOptions): string[];
28
+ export declare function resolveSpawnCommand(executable: string, args: string[], platform?: NodeJS.Platform, commandInterpreter?: string | undefined): SpawnCommand;
29
+ export declare function prependCommandPath(command: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
30
+ export declare function prependNodePath(env?: NodeJS.ProcessEnv, nodeExecutable?: string): NodeJS.ProcessEnv;
31
+ export declare function prependPathDirectory(directory: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
32
+ export declare function findExecutable(name: string, options?: {
33
+ env?: NodeJS.ProcessEnv;
34
+ existsSync?: (filePath: string) => boolean;
35
+ platform?: NodeJS.Platform;
36
+ extraDirectories?: string[];
37
+ }): string | null;
38
+ export declare const runCommand: CommandRunner;
@@ -0,0 +1,139 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ const MAX_CAPTURE_LENGTH = 1_000_000;
5
+ export function isExecutable(filePath, platform = process.platform) {
6
+ try {
7
+ fs.accessSync(filePath, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ export function executableNames(name, platform = process.platform) {
15
+ return platform === 'win32' ? [`${name}.cmd`, `${name}.exe`, name] : [name];
16
+ }
17
+ export function versionManagerExecutableCandidates(home, names, options = {}) {
18
+ const env = options.env ?? process.env;
19
+ const platform = options.platform ?? process.platform;
20
+ const readdirSync = options.readdirSync ?? fs.readdirSync;
21
+ const fnmDefaultRoot = env.XDG_DATA_HOME
22
+ ? path.join(env.XDG_DATA_HOME, 'fnm')
23
+ : platform === 'darwin'
24
+ ? path.join(home, 'Library', 'Application Support', 'fnm')
25
+ : platform === 'win32' && env.APPDATA
26
+ ? path.join(env.APPDATA, 'fnm')
27
+ : path.join(home, '.local', 'share', 'fnm');
28
+ const roots = [
29
+ path.join(env.NVM_DIR || path.join(home, '.nvm'), 'versions', 'node'),
30
+ path.join(env.FNM_DIR || fnmDefaultRoot, 'node-versions'),
31
+ path.join(home, '.fnm', 'node-versions'),
32
+ ].filter((root, index, all) => all.indexOf(root) === index);
33
+ return roots.flatMap(root => {
34
+ try {
35
+ return readdirSync(root, { withFileTypes: true })
36
+ .filter(entry => entry.isDirectory())
37
+ .sort((left, right) => right.name.localeCompare(left.name, undefined, { numeric: true }))
38
+ .flatMap(entry => names.flatMap(name => [
39
+ path.join(root, entry.name, 'bin', name),
40
+ path.join(root, entry.name, 'installation', 'bin', name),
41
+ ]));
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ });
47
+ }
48
+ export function resolveSpawnCommand(executable, args, platform = process.platform, commandInterpreter = process.env.ComSpec) {
49
+ if (platform === 'win32' && /\.(?:cmd|bat)$/i.test(executable)) {
50
+ return {
51
+ command: commandInterpreter || 'cmd.exe',
52
+ args: ['/d', '/s', '/c', executable, ...args],
53
+ };
54
+ }
55
+ return { command: executable, args };
56
+ }
57
+ export function prependCommandPath(command, env = process.env) {
58
+ if (!path.isAbsolute(command))
59
+ return env;
60
+ return prependPathDirectory(path.dirname(command), env);
61
+ }
62
+ export function prependNodePath(env = process.env, nodeExecutable = process.execPath) {
63
+ return prependPathDirectory(path.dirname(nodeExecutable), env);
64
+ }
65
+ export function prependPathDirectory(directory, env = process.env) {
66
+ const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path') || 'PATH';
67
+ const inherited = env[pathKey] || '';
68
+ const entries = inherited.split(path.delimiter).filter(Boolean);
69
+ if (entries.includes(directory))
70
+ return env;
71
+ return { ...env, [pathKey]: [directory, ...entries].join(path.delimiter) };
72
+ }
73
+ export function findExecutable(name, options = {}) {
74
+ const env = options.env ?? process.env;
75
+ const platform = options.platform ?? process.platform;
76
+ const existsSync = options.existsSync ?? (candidate => isExecutable(candidate, platform));
77
+ const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path') || 'PATH';
78
+ const directories = [
79
+ ...(options.extraDirectories ?? []),
80
+ ...(env[pathKey] ?? '').split(path.delimiter).filter(Boolean),
81
+ ];
82
+ for (const directory of directories) {
83
+ for (const executableName of executableNames(name, platform)) {
84
+ const candidate = path.join(directory, executableName);
85
+ if (existsSync(candidate))
86
+ return candidate;
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+ export const runCommand = (command, args, options = {}) => new Promise((resolve, reject) => {
92
+ const capture = options.capture === true || options.tee === true;
93
+ const launch = resolveSpawnCommand(command, args);
94
+ const child = spawn(launch.command, launch.args, {
95
+ cwd: options.cwd,
96
+ env: options.env,
97
+ stdio: capture ? [options.tee ? 'inherit' : 'ignore', 'pipe', 'pipe'] : 'inherit',
98
+ windowsHide: true,
99
+ });
100
+ let stdout = '';
101
+ let stderr = '';
102
+ let settled = false;
103
+ const append = (current, chunk) => options.tee
104
+ ? (current + chunk).slice(-MAX_CAPTURE_LENGTH)
105
+ : current.length >= MAX_CAPTURE_LENGTH
106
+ ? current
107
+ : (current + chunk).slice(0, MAX_CAPTURE_LENGTH);
108
+ const finish = (error, code = 1) => {
109
+ if (settled)
110
+ return;
111
+ settled = true;
112
+ if (timer)
113
+ clearTimeout(timer);
114
+ if (error)
115
+ reject(error);
116
+ else
117
+ resolve({ code, stdout, stderr });
118
+ };
119
+ const timer = options.timeoutMs === undefined
120
+ ? undefined
121
+ : setTimeout(() => {
122
+ child.kill('SIGTERM');
123
+ finish(new Error(`Command timed out after ${options.timeoutMs}ms`));
124
+ }, options.timeoutMs);
125
+ if (capture) {
126
+ child.stdout?.setEncoding('utf8').on('data', (chunk) => {
127
+ stdout = append(stdout, chunk);
128
+ if (options.tee)
129
+ process.stdout.write(chunk);
130
+ });
131
+ child.stderr?.setEncoding('utf8').on('data', (chunk) => {
132
+ stderr = append(stderr, chunk);
133
+ if (options.tee)
134
+ process.stderr.write(chunk);
135
+ });
136
+ }
137
+ child.once('error', error => finish(error));
138
+ child.once('close', code => finish(undefined, code ?? 1));
139
+ });
package/dist/index.d.ts CHANGED
@@ -3,3 +3,4 @@ export * from './paths.js';
3
3
  export * from './registry.js';
4
4
  export * from './log.js';
5
5
  export * from './daemon.js';
6
+ export * from './command.js';
package/dist/index.js CHANGED
@@ -3,3 +3,4 @@ export * from './paths.js';
3
3
  export * from './registry.js';
4
4
  export * from './log.js';
5
5
  export * from './daemon.js';
6
+ export * from './command.js';
package/dist/log.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ /** Keep recent diagnostics and every log with a corresponding daemon record. */
2
+ export declare function pruneOrphanLogs(directory: string, retentionMs?: number): void;
1
3
  /**
2
4
  * Rotate `<log>` to `<log>.1` if it has grown past the limit. Meant to be called
3
5
  * before each (re)start, since a detached child appends to an inherited fd and
package/dist/log.js CHANGED
@@ -1,6 +1,28 @@
1
- import { existsSync, statSync, renameSync, readFileSync } from 'node:fs';
1
+ import { existsSync, statSync, renameSync, readFileSync, readdirSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
2
3
  /** Rotate the log when it grows past this size (keeps one `.1` backup). */
3
4
  const DEFAULT_MAX_LOG_BYTES = 5 * 1024 * 1024;
5
+ /** Keep recent diagnostics and every log with a corresponding daemon record. */
6
+ export function pruneOrphanLogs(directory, retentionMs = 7 * 86400_000) {
7
+ try {
8
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
9
+ const match = /^([a-f0-9]{16})\.log(?:\.1)?$/.exec(entry.name);
10
+ if (!entry.isFile() || !match || existsSync(join(directory, `${match[1]}.json`)))
11
+ continue;
12
+ const file = join(directory, entry.name);
13
+ try {
14
+ if (Date.now() - statSync(file).mtimeMs >= retentionMs)
15
+ rmSync(file, { force: true });
16
+ }
17
+ catch {
18
+ // Another host may have reclaimed the same orphan.
19
+ }
20
+ }
21
+ }
22
+ catch {
23
+ // Diagnostic cleanup must not prevent starting a browser.
24
+ }
25
+ }
4
26
  /**
5
27
  * Rotate `<log>` to `<log>.1` if it has grown past the limit. Meant to be called
6
28
  * before each (re)start, since a detached child appends to an inherited fd and
package/dist/process.d.ts CHANGED
@@ -1,8 +1,16 @@
1
+ /** OS creation identity distinguishes an owner from an unrelated process reusing its pid. */
2
+ export declare function processIdentity(pid: number): string | undefined;
3
+ export declare function isProcessOwnerAlive(owner: {
4
+ pid: number;
5
+ processIdentity?: unknown;
6
+ }): boolean;
1
7
  /**
2
8
  * Whether a process with the given pid is currently alive.
3
9
  *
4
- * Uses the signal-0 probe, which never actually delivers a signal — it only
5
- * performs the kernel's permission/existence checks. EPERM means the process
6
- * exists but is owned by another user, so it still counts as alive.
10
+ * Signal 0 checks existence without delivering a signal. Linux zombies retain
11
+ * their pid until reaped, so procfs distinguishes them from executable owners.
12
+ * Unavailable inspection and EPERM remain conservatively alive.
7
13
  */
8
14
  export declare function isAlive(pid: number | undefined | null): boolean;
15
+ /** POSIX group liveness includes live descendants after their original owner exits. */
16
+ export declare function isProcessGroupAlive(group: number): boolean;
package/dist/process.js CHANGED
@@ -1,18 +1,140 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ function linuxProcessStat(pid) {
4
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
5
+ const end = stat.lastIndexOf(')');
6
+ const fields = stat
7
+ .slice(end + 2)
8
+ .trim()
9
+ .split(/\s+/);
10
+ const group = Number(fields[2]);
11
+ const threads = Number(fields[17]);
12
+ if (end < 0 ||
13
+ !/^[A-Za-z]$/.test(fields[0]) ||
14
+ !Number.isSafeInteger(group) ||
15
+ group < 0 ||
16
+ !Number.isSafeInteger(threads) ||
17
+ threads < 0 ||
18
+ !/^\d+$/.test(fields[19] ?? ''))
19
+ throw new Error('Invalid Linux process stat');
20
+ return { state: fields[0], group, threads, identity: fields[19] };
21
+ }
22
+ function linuxProcessExited({ state, threads }) {
23
+ // A terminated thread-group leader may still have executable sibling threads.
24
+ return threads <= 1 && (state === 'Z' || state === 'X' || state === 'x');
25
+ }
26
+ /** OS creation identity distinguishes an owner from an unrelated process reusing its pid. */
27
+ export function processIdentity(pid) {
28
+ if (!Number.isSafeInteger(pid) || pid < 1)
29
+ return undefined;
30
+ try {
31
+ if (process.platform === 'linux') {
32
+ return linuxProcessStat(pid).identity;
33
+ }
34
+ const result = process.platform === 'win32'
35
+ ? spawnSync('powershell.exe', [
36
+ '-NoProfile',
37
+ '-NonInteractive',
38
+ '-Command',
39
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate.ToUniversalTime().Ticks`,
40
+ ], { encoding: 'utf8', timeout: 2000, windowsHide: true })
41
+ : spawnSync('ps', ['-p', String(pid), '-o', 'lstart='], {
42
+ encoding: 'utf8',
43
+ timeout: 2000,
44
+ });
45
+ return result.status === 0 ? result.stdout.trim() || undefined : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ export function isProcessOwnerAlive(owner) {
52
+ if (!isAlive(owner.pid))
53
+ return false;
54
+ if (typeof owner.processIdentity !== 'string')
55
+ return true;
56
+ const current = processIdentity(owner.pid);
57
+ return current === undefined || current === owner.processIdentity;
58
+ }
1
59
  /**
2
60
  * Whether a process with the given pid is currently alive.
3
61
  *
4
- * Uses the signal-0 probe, which never actually delivers a signal — it only
5
- * performs the kernel's permission/existence checks. EPERM means the process
6
- * exists but is owned by another user, so it still counts as alive.
62
+ * Signal 0 checks existence without delivering a signal. Linux zombies retain
63
+ * their pid until reaped, so procfs distinguishes them from executable owners.
64
+ * Unavailable inspection and EPERM remain conservatively alive.
7
65
  */
8
66
  export function isAlive(pid) {
9
- if (!pid || pid <= 0)
67
+ if (!Number.isSafeInteger(pid) || !pid || pid <= 0)
10
68
  return false;
11
69
  try {
12
70
  process.kill(pid, 0);
71
+ if (process.platform === 'linux') {
72
+ try {
73
+ return !linuxProcessExited(linuxProcessStat(pid));
74
+ }
75
+ catch {
76
+ return true;
77
+ }
78
+ }
13
79
  return true;
14
80
  }
15
81
  catch (error) {
16
82
  return error.code === 'EPERM';
17
83
  }
18
84
  }
85
+ /** POSIX group liveness includes live descendants after their original owner exits. */
86
+ export function isProcessGroupAlive(group) {
87
+ if (!Number.isSafeInteger(group) || group < 1)
88
+ return false;
89
+ if (process.platform === 'win32')
90
+ return true;
91
+ const probe = () => {
92
+ try {
93
+ process.kill(-group, 0);
94
+ return true;
95
+ }
96
+ catch (error) {
97
+ return error.code === 'ESRCH' ? false : undefined;
98
+ }
99
+ };
100
+ const present = probe();
101
+ if (present !== true)
102
+ return present !== false;
103
+ if (process.platform !== 'linux')
104
+ return true;
105
+ const exitedMembers = () => {
106
+ const members = new Set();
107
+ try {
108
+ for (const name of readdirSync('/proc')) {
109
+ if (!/^\d+$/.test(name))
110
+ continue;
111
+ try {
112
+ const stat = linuxProcessStat(Number(name));
113
+ if (stat.group !== group)
114
+ continue;
115
+ if (!linuxProcessExited(stat))
116
+ return undefined;
117
+ members.add(`${name}:${stat.identity}`);
118
+ }
119
+ catch (error) {
120
+ if (!['ENOENT', 'ESRCH'].includes(error.code ?? ''))
121
+ return undefined;
122
+ }
123
+ }
124
+ return members;
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ };
130
+ // Stable exited membership excludes a child forked while the first scan was in progress.
131
+ const first = exitedMembers();
132
+ if (!first)
133
+ return true;
134
+ const second = exitedMembers();
135
+ if (!second)
136
+ return true;
137
+ if (first.size && first.size === second.size && [...first].every(pid => second.has(pid)))
138
+ return false;
139
+ return probe() !== false;
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/daemon-core",
3
- "version": "2.20.6",
3
+ "version": "2.22.0",
4
4
  "description": "Shared Node daemon lifecycle primitives for Mearl — process liveness, JSON record registry, log rotation, and a detached daemon manager",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",