srvmd 0.1.0 → 0.1.1

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/ARCHITECTURE.md CHANGED
@@ -42,7 +42,24 @@ flowchart TB
42
42
  - `server` 可以使用 Bun 和 Node API,不导入 `web` 源码,不访问 DOM。
43
43
  - `web` 可以使用 DOM 和浏览器 API,不导入 `server` 源码,不读文件系统。
44
44
  - `shared` 只包含类型、契约和常量,不依赖任何运行时。
45
- - `cli` 只负责进程边界:解析参数、启动服务器、输出、信号。
45
+ - `cli` 只负责进程边界:解析参数、启动服务器、输出、信号,以及跟随终端和父进程退出。
46
+
47
+ ## 进程生命周期
48
+
49
+ `src/cli/lifecycle.ts` 决定服务器何时退出,三条触发路径最终都走同一个 `shutdown`:
50
+
51
+ | 触发 | 机制 | 备注 |
52
+ | --- | --- | --- |
53
+ | `Ctrl+C`、`SIGTERM`、`SIGHUP` | 信号处理器 | 注册后覆盖继承来的 `SIG_IGN`(`nohup`、部分终端启动器) |
54
+ | 终端关闭 | 每秒 `readlink /proc/self/fd/{0,1,2}` | 仅 Linux,其他平台没有 `/proc` |
55
+ | 父进程消失 | 每秒比对 `process.ppid` | 只覆盖启动之后父进程才消失的情况 |
56
+
57
+ 约定:
58
+
59
+ - `shutdown` 先 `running.close()`,再 `process.exit(0)`;重复信号只生效一次。
60
+ - 处理器紧跟 `startServer` 安装,`--open` 等可能阻塞的操作不能挡在中间。
61
+ - 启动时就已是孤儿(reaper 或容器 `PID 1` 为父进程)无法与 supervisor 拉起区分,因此不做处理;要常驻请用 systemd、Docker 等 supervisor。
62
+ - 因为信号处理器覆盖了 `SIG_IGN`,`nohup` 和 `setsid` 都不能让服务脱离终端存活。
46
63
 
47
64
  ## 目录职责
48
65
 
package/README.md CHANGED
@@ -76,6 +76,16 @@ srvmd ./docs --theme dark # Dark theme by default
76
76
  - A directory request without a trailing `/` receives a `301` redirect
77
77
  - Hidden files and directories starting with `.` are not served or listed
78
78
 
79
+ ### Process lifecycle
80
+
81
+ `srvmd` runs in the foreground and stops with whatever started it:
82
+
83
+ - `Ctrl+C`, `SIGTERM` and `SIGHUP` (closing the terminal, killing the tmux session, dropping the SSH connection) shut it down cleanly
84
+ - When the launching shell or `bun run` wrapper exits, the server follows within about a second
85
+ - A server whose launcher is already gone at startup, or whose parent is a supervisor or container `PID 1`, is left running instead of guessing
86
+
87
+ Use a supervisor (systemd, Docker, a tmux session that stays alive) when the server has to outlive the terminal. `nohup` and `setsid` are not enough: the handlers override the ignored hangup, and the tty watch still notices the closed terminal.
88
+
79
89
  ## Architecture
80
90
 
81
91
  ```plaintext
package/README_CN.md CHANGED
@@ -76,6 +76,16 @@ srvmd ./docs --theme dark # 默认深色主题
76
76
  - 访问目录且缺少结尾 `/` 时会收到 `301` 跳转
77
77
  - 以 `.` 开头的隐藏文件不会被提供或列入目录列表
78
78
 
79
+ ### 进程生命周期
80
+
81
+ `srvmd` 在前台运行,随启动它的进程一起退出:
82
+
83
+ - `Ctrl+C`、`SIGTERM` 和 `SIGHUP`(关闭终端、杀掉 tmux 会话、SSH 连接断开)会触发干净退出
84
+ - 启动它的 shell 或 `bun run` 包装进程退出后,服务会在一秒左右跟着退出
85
+ - 启动时启动它的 shell 已经不在了,或父进程本身就是 supervisor、容器 `PID 1` 时,服务会被保留而不做猜测
86
+
87
+ 需要服务脱离终端长期存活时,请交给 supervisor(systemd、Docker、保持存活的 tmux 会话)。`nohup` 和 `setsid` 并不够用:信号处理器会覆盖被忽略的挂断信号,tty 轮询仍会发现终端已关闭。
88
+
79
89
  ## 架构
80
90
 
81
91
  ```plaintext
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "srvmd",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Static file server with live-reloading Markdown rendering, built on Bun",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@ import { Command } from "commander";
2
2
  import pkg from "../../package.json";
3
3
  import { startServer } from "../server/create-server.ts";
4
4
  import { openInBrowser } from "./browser.ts";
5
- import { installSignalHandlers } from "./lifecycle.ts";
5
+ import { installLifecycleHandlers } from "./lifecycle.ts";
6
6
  import { type RawCliOptions, toServerOptions } from "./options.ts";
7
7
  import { printStartupReport } from "./report.ts";
8
8
 
@@ -35,6 +35,9 @@ export function createCommand(): Command {
35
35
  .action(async (directory: string, raw: RawCliOptions) => {
36
36
  const options = toServerOptions(directory, raw);
37
37
  const running = await startServer(options);
38
+ // Installed right after the listener exists: `--open` can block in the
39
+ // browser launcher, and a signal arriving there would skip cleanup.
40
+ installLifecycleHandlers(() => running.close());
38
41
 
39
42
  printStartupReport({
40
43
  root: running.root,
@@ -51,6 +54,5 @@ export function createCommand(): Command {
51
54
  });
52
55
 
53
56
  if (raw.open) await openInBrowser(running.url);
54
- installSignalHandlers(() => running.close());
55
57
  });
56
58
  }
@@ -1,13 +1,117 @@
1
- export function installSignalHandlers(close: () => void): void {
1
+ import { readlinkSync } from "node:fs";
2
+
3
+ /**
4
+ * Signals that shut the server down gracefully.
5
+ *
6
+ * SIGHUP covers a closing terminal, a killed tmux session or window, and a dropped
7
+ * SSH connection. Registering a JS handler also overrides an inherited SIG_IGN
8
+ * disposition (nohup, supervisors, some terminal launchers) which would otherwise
9
+ * swallow the hangup.
10
+ */
11
+ const SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
12
+
13
+ const WATCH_INTERVAL_MS = 1000;
14
+
15
+ /** Returns true once the condition it watches for has happened. */
16
+ type Probe = () => boolean;
17
+
18
+ export function installLifecycleHandlers(close: () => void): void {
2
19
  let stopping = false;
3
20
 
4
21
  const shutdown = () => {
5
22
  if (stopping) return;
6
23
  stopping = true;
7
- close();
8
- process.exit(0);
24
+ try {
25
+ close();
26
+ } finally {
27
+ process.exit(0);
28
+ }
9
29
  };
10
30
 
11
- process.on("SIGINT", shutdown);
12
- process.on("SIGTERM", shutdown);
31
+ for (const signal of SHUTDOWN_SIGNALS) process.on(signal, shutdown);
32
+
33
+ // One timer for both probes: a single tick is enough to notice either condition.
34
+ const probes = [parentProbe(), terminalProbe()].filter((probe): probe is Probe => probe !== null);
35
+ watchUntil(probes, shutdown);
36
+ }
37
+
38
+ /**
39
+ * A hangup only reaches a child when the shell forwards SIGHUP. When the parent
40
+ * disappears quietly (a background job, a wrapper that exits right away) nothing
41
+ * reaches the process at all, so poll the parent pid and stop once it changes.
42
+ *
43
+ * This covers a parent that dies after startup. A process that is already
44
+ * reparented when the probe starts (an orphan, a `PID 1` container entrypoint)
45
+ * looks exactly like a process started by a supervisor, so it is left running.
46
+ */
47
+ export function watchParent(onGone: () => void): void {
48
+ watchUntil([parentProbe()], onGone);
49
+ }
50
+
51
+ /**
52
+ * Closing a terminal unlinks its devpts node, so a live process still holding the
53
+ * hung-up tty reads `/dev/pts/N (deleted)`. Polling keeps the check passive:
54
+ * reading stdin would stop a background job with SIGTTIN, and it cannot tell a
55
+ * Ctrl+D from a hangup.
56
+ *
57
+ * Linux-only; other platforms have no `/proc`, and the parent watch still applies.
58
+ */
59
+ export function watchTerminal(onGone: () => void): void {
60
+ const probe = terminalProbe();
61
+ if (probe) watchUntil([probe], onGone);
62
+ }
63
+
64
+ function parentProbe(): Probe {
65
+ const parent = process.ppid;
66
+ return () => process.ppid !== parent;
67
+ }
68
+
69
+ function terminalProbe(): Probe | null {
70
+ if (process.platform !== "linux") return null;
71
+
72
+ const fds = ([0, 1, 2] as const).filter(isStdioTty);
73
+ if (fds.length === 0) return null;
74
+
75
+ return () => fds.some(isDeletedTty);
76
+ }
77
+
78
+ /** Polls every probe on one unref'd timer and calls `onGone` once any of them fires. */
79
+ function watchUntil(probes: readonly Probe[], onGone: () => void): void {
80
+ if (probes.length === 0) return;
81
+
82
+ const timer = setInterval(() => {
83
+ if (!probes.some(probe => probe())) return;
84
+ clearInterval(timer);
85
+ onGone();
86
+ }, WATCH_INTERVAL_MS);
87
+ timer.unref();
88
+ }
89
+
90
+ function isStdioTty(fd: 0 | 1 | 2): boolean {
91
+ if (fd === 0) return process.stdin.isTTY === true;
92
+ if (fd === 1) return process.stdout.isTTY === true;
93
+ return process.stderr.isTTY === true;
94
+ }
95
+
96
+ function isDeletedTty(fd: number): boolean {
97
+ try {
98
+ return isDeletedTtyTarget(readlinkSync(`/proc/self/fd/${fd}`));
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Device paths that get unlinked when their terminal goes away. devpts nodes are
106
+ * the common case; serial consoles and `/dev/console` disappear the same way.
107
+ * Virtual consoles (`/dev/ttyN`) are absent on purpose: they are never unlinked.
108
+ */
109
+ const HUNG_UP_TTY_TARGET = /^\/dev\/(?:pts\/\d+|ttyS\d+|ttyUSB\d+|ttyACM\d+|console)$/;
110
+
111
+ /** Matches the `readlink` of a hung-up tty fd, e.g. `/dev/pts/7 (deleted)`. */
112
+ export function isDeletedTtyTarget(target: string): boolean {
113
+ const suffix = " (deleted)";
114
+ if (!target.endsWith(suffix)) return false;
115
+
116
+ return HUNG_UP_TTY_TARGET.test(target.slice(0, -suffix.length));
13
117
  }