From 2deaf59f7b8e802dc70d765420ca89e02d5bfb42 Mon Sep 17 00:00:00 2001 From: Agent Board Date: Sat, 25 Jul 2026 14:56:42 +0800 Subject: [PATCH] feat: run mnote-web prod under systemd --user with auto-restart Replace detached spawn with mnote-web-prod.service (Restart=always, enable + Linger). prod:start installs the unit, writes env/current symlink, and restarts via systemctl so the gateway survives kills and logout. Also drop unused std::fs import in web_shell. --- deploy/systemd/mnote-web-prod.service | 30 ++ rust/crates/mnote-web/src/routes/web_shell.rs | 1 - scripts/prod-build-start.js | 276 ++++++++++++++++-- 3 files changed, 279 insertions(+), 28 deletions(-) create mode 100644 deploy/systemd/mnote-web-prod.service diff --git a/deploy/systemd/mnote-web-prod.service b/deploy/systemd/mnote-web-prod.service new file mode 100644 index 00000000..2f3cef0e --- /dev/null +++ b/deploy/systemd/mnote-web-prod.service @@ -0,0 +1,30 @@ +[Unit] +Description=MNote web production gateway (user) +Documentation=file:///mnt/Data1T/mnote/scripts/prod-build-start.js +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# current 软链由 npm run prod:start 指向最新 release 目录 +WorkingDirectory=/mnt/Data1T/mnote/dist/run/mnote-web-prod/current +# 生产 env 由 prod-build-start 生成(含 .env.all + bind/control-plane 覆盖) +EnvironmentFile=-/mnt/Data1T/mnote/dist/run/mnote-web-prod/env +Environment=RUST_LOG=info +ExecStart=/mnt/Data1T/mnote/dist/run/mnote-web-prod/current/mnote-web +# 崩溃/被杀后自动拉起 +Restart=always +RestartSec=3 +# 优雅退出窗口 +TimeoutStopSec=30 +KillMode=mixed +# 日志落到 journal;release 目录内 mnote-web.log 仍可由 journalctl 旁路查看 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=mnote-web-prod + +# 轻量资源上限,避免异常 runaway +LimitNOFILE=65536 + +[Install] +WantedBy=default.target diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 54e41e80..65a4fbc1 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -39,7 +39,6 @@ use control_plane::UpsertNavigationRecentInput; use core_protocol::KernelProjectionKind; use serde::Deserialize; use serde_json::{json, Value}; -use std::fs; use std::path::{Component, Path as FsPath, PathBuf}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; diff --git a/scripts/prod-build-start.js b/scripts/prod-build-start.js index ed82b431..2af1940a 100644 --- a/scripts/prod-build-start.js +++ b/scripts/prod-build-start.js @@ -9,6 +9,10 @@ * - 新 release 从 0.0.1 开始递增,0.0.9 后是 0.1.0。 * - 每个 release 保留独立源码快照和二进制,不覆盖旧 release。 * - 默认启动端口是 3003,避免占用开发端口 3000。 + * - 进程由 systemd --user(mnote-web-prod.service)托管:Restart=always + enable, + * 本机需 loginctl Linger=yes(已配置则登出后仍运行)。 + * - 管理:systemctl --user status|restart|stop mnote-web-prod.service + * 日志:journalctl --user -u mnote-web-prod.service -f */ const fs = require("node:fs"); @@ -23,11 +27,47 @@ const RELEASE_ROOT = path.join(ROOT, "dist", "releases", "mnote-web"); const BUILD_CACHE_DIR = path.join(ROOT, "dist", "prod-build-cache", "mnote-web"); const RUN_DIR = path.join(ROOT, "dist", "run", "mnote-web-prod"); const RUN_STATE_PATH = path.join(RUN_DIR, "process.json"); +const RUN_ENV_PATH = path.join(RUN_DIR, "env"); +const RUN_CURRENT_LINK = path.join(RUN_DIR, "current"); +const SYSTEMD_UNIT_NAME = "mnote-web-prod.service"; +const SYSTEMD_UNIT_SOURCE = path.join(ROOT, "deploy", "systemd", "mnote-web-prod.service"); +const SYSTEMD_USER_DIR = path.join(os.homedir(), ".config", "systemd", "user"); +const SYSTEMD_UNIT_TARGET = path.join(SYSTEMD_USER_DIR, SYSTEMD_UNIT_NAME); const DEFAULT_PORT = 3003; const DEFAULT_TURSO_LOCAL_PATH = "/mnt/Data1T/Mnote_data/control-plane/control-plane-prod-libsql.db"; const DEFAULT_CONTROL_PLANE_BACKEND = "libsql-local"; const ENV_ALL_PATH = path.join(ROOT, ".env.all"); const RELEASE_BUILD_SCHEMA = "mnote-web-prod-runtime-assets-v2"; +// 不写入 systemd EnvironmentFile 的敏感/会话壳变量 +const ENV_FILE_DENYLIST = new Set([ + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", + "SSH_AUTH_SOCK", + "SSH_CLIENT", + "SSH_CONNECTION", + "SSH_TTY", + "XAUTHORITY", + "WAYLAND_DISPLAY", + "GPG_AGENT_INFO", + "OLDPWD", + "PWD", + "SHLVL", + "_", + "npm_config_user_agent", + "npm_lifecycle_event", + "npm_lifecycle_script", + "npm_package_name", + "npm_package_version", + "npm_command", + "npm_execpath", + "npm_node_execpath", + "INIT_CWD", + "COLOR", + "COLORTERM", + "TERM", + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", +]); const SUPPLEMENTAL_RUNTIME_ASSETS = [ { source: "node_modules/jszip/dist/jszip.min.js", @@ -516,6 +556,174 @@ async function verifyStartupAssets(port) { } } +function escapeEnvFileValue(value) { + const text = String(value ?? ""); + // systemd EnvironmentFile:含空白/引号/反斜杠时用双引号并转义 + if (/[\s"'\\$`]/.test(text) || text === "") { + return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$")}"`; + } + return text; +} + +function buildProdRuntimeEnv(port, bind, publicBind, controlPlaneBackend) { + // 只落 .env.all + 生产覆盖,不把整份 shell/npm 环境写进 systemd + const env = { + ...loadEnvFile(ENV_ALL_PATH), + }; + // 允许调用方在进程环境里临时覆盖关键项(如 MNOTE_PROD_PORT) + const passThroughKeys = [ + "MNOTE_PROD_PORT", + "MNOTE_WEB_BIND", + "MNOTE_WEB_PUBLIC_BIND", + "MNOTE_CONTROL_PLANE_BACKEND", + "MNOTE_TURSO_LOCAL_PATH", + "MNOTE_TURSO_URL", + "MNOTE_TURSO_AUTH_TOKEN", + "MNOTE_TURSO_SYNC_URL", + "RUST_LOG", + "MNOTE_LIGHTRAG_API_KEY", + "MNOTE_LIGHTRAG_BASE_URL", + "LIGHTRAG_URL", + "LIGHTRAG_API_KEY", + ]; + for (const key of passThroughKeys) { + if (process.env[key] !== undefined && process.env[key] !== "") { + env[key] = process.env[key]; + } + } + + env.MNOTE_WEB_BIND = bind; + env.MNOTE_WEB_PUBLIC_BIND = publicBind; + env.MNOTE_CONTROL_PLANE_BACKEND = controlPlaneBackend; + env.RUST_LOG = env.RUST_LOG || process.env.RUST_LOG || "info"; + if ( + controlPlaneBackend === "libsql-local" + || controlPlaneBackend === "turso-local" + || controlPlaneBackend === "turso" + ) { + env.MNOTE_TURSO_LOCAL_PATH = + env.MNOTE_TURSO_LOCAL_PATH + || process.env.MNOTE_TURSO_LOCAL_PATH + || DEFAULT_TURSO_LOCAL_PATH; + } + env.MNOTE_PROD_PORT = String(port); + // PATH 给子进程一个干净可预期值(不依赖 npm 注入的 node_modules/.bin) + env.PATH = [ + path.join(os.homedir(), ".local", "bin"), + path.join(os.homedir(), ".cargo", "bin"), + path.join(os.homedir(), ".npm-global", "bin"), + "/usr/local/bin", + "/usr/bin", + "/bin", + ].join(":"); + env.HOME = os.homedir(); + env.LANG = process.env.LANG || "C.UTF-8"; + return env; +} + +async function writeSystemdEnvFile(env) { + const lines = [ + "# Generated by scripts/prod-build-start.js — do not edit by hand", + `# generatedAt=${new Date().toISOString()}`, + ]; + const keys = Object.keys(env).sort(); + for (const key of keys) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (ENV_FILE_DENYLIST.has(key)) continue; + if (key.startsWith("npm_") || key.startsWith("NPM_")) continue; + const value = env[key]; + if (value === undefined || value === null) continue; + // 跳过多行值,避免 EnvironmentFile 解析失败 + if (String(value).includes("\n") || String(value).includes("\r")) continue; + lines.push(`${key}=${escapeEnvFileValue(value)}`); + } + await fsp.mkdir(RUN_DIR, { recursive: true }); + await fsp.writeFile(RUN_ENV_PATH, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 }); + await fsp.chmod(RUN_ENV_PATH, 0o600); +} + +async function installSystemdUserUnit() { + if (!(await pathExists(SYSTEMD_UNIT_SOURCE))) { + throw new Error(`缺少 systemd unit 模板: ${SYSTEMD_UNIT_SOURCE}`); + } + await fsp.mkdir(SYSTEMD_USER_DIR, { recursive: true }); + const sourceText = await fsp.readFile(SYSTEMD_UNIT_SOURCE, "utf8"); + let needWrite = true; + if (await pathExists(SYSTEMD_UNIT_TARGET)) { + const existing = await fsp.readFile(SYSTEMD_UNIT_TARGET, "utf8"); + needWrite = existing !== sourceText; + } + if (needWrite) { + await fsp.writeFile(SYSTEMD_UNIT_TARGET, sourceText, "utf8"); + console.log(`[prod] 已安装 user unit: ${SYSTEMD_UNIT_TARGET}`); + } + runChecked("systemctl", ["--user", "daemon-reload"]); + // enable 保证登录/linger 后自动拉起 + runChecked("systemctl", ["--user", "enable", SYSTEMD_UNIT_NAME]); +} + +async function pointCurrentRelease(releaseDir) { + await fsp.mkdir(RUN_DIR, { recursive: true }); + const tmpLink = `${RUN_CURRENT_LINK}.new-${process.pid}`; + try { + await fsp.unlink(tmpLink); + } catch { + // ignore + } + await fsp.symlink(releaseDir, tmpLink); + // 原子替换 current → 新 release + await fsp.rename(tmpLink, RUN_CURRENT_LINK); +} + +function systemctlUser(args, options = {}) { + return runChecked("systemctl", ["--user", ...args], options); +} + +function systemctlUserStatusText() { + try { + return execFileSync("systemctl", ["--user", "status", SYSTEMD_UNIT_NAME, "--no-pager"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + return (error.stdout || "") + (error.stderr || "") || String(error.message || error); + } +} + +function mainPidFromSystemd() { + try { + const out = execFileSync( + "systemctl", + ["--user", "show", SYSTEMD_UNIT_NAME, "-p", "MainPID", "--value"], + { encoding: "utf8" }, + ).trim(); + const pid = Number(out); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +async function stopSystemdManagedService(port) { + // 先停 systemd 托管(若已 enable) + try { + const active = execFileSync( + "systemctl", + ["--user", "is-active", SYSTEMD_UNIT_NAME], + { encoding: "utf8" }, + ).trim(); + if (active === "active" || active === "activating" || active === "reloading") { + console.log(`[prod] systemctl --user stop ${SYSTEMD_UNIT_NAME}`); + systemctlUser(["stop", SYSTEMD_UNIT_NAME]); + } + } catch { + // unit 未安装 / 未 active:忽略 + } + + // 再清掉历史 detached 裸进程 / process.json + await stopPreviousProcess(port); +} + async function startRelease(release) { const port = Number(process.env.MNOTE_PROD_PORT || DEFAULT_PORT); if (!Number.isInteger(port) || port <= 0 || port > 65535) { @@ -523,11 +731,9 @@ async function startRelease(release) { } await fsp.mkdir(RUN_DIR, { recursive: true }); - await stopPreviousProcess(port); const binaryPath = path.join(release.releaseDir, "mnote-web"); const logPath = path.join(release.releaseDir, "mnote-web.log"); - const logFd = fs.openSync(logPath, "a"); const bind = process.env.MNOTE_WEB_BIND || `0.0.0.0:${port}`; const publicBind = process.env.MNOTE_WEB_PUBLIC_BIND || defaultPublicBind(port); const controlPlaneBackend = @@ -535,31 +741,41 @@ async function startRelease(release) { if (controlPlaneBackend === "sqlite") { throw new Error("prod runtime 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-remote/turso-local-replica/turso-synced"); } + if (!(await pathExists(binaryPath))) { + throw new Error(`release 二进制不存在: ${binaryPath}`); + } - const child = spawn(binaryPath, [], { - cwd: release.metadata?.sourceDir || release.releaseDir, - detached: true, - stdio: ["ignore", logFd, logFd], - env: { - ...process.env, - MNOTE_WEB_BIND: bind, - MNOTE_WEB_PUBLIC_BIND: publicBind, - MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend, - ...(controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso" - ? { - MNOTE_TURSO_LOCAL_PATH: - process.env.MNOTE_TURSO_LOCAL_PATH || DEFAULT_TURSO_LOCAL_PATH, - } - : {}), - }, - }); + // 1) 停旧(systemd + 裸进程) + await stopSystemdManagedService(port); - child.unref(); - fs.closeSync(logFd); + // 2) 写 env / current 软链 / unit + const runtimeEnv = buildProdRuntimeEnv(port, bind, publicBind, controlPlaneBackend); + await writeSystemdEnvFile(runtimeEnv); + await pointCurrentRelease(release.releaseDir); + await installSystemdUserUnit(); + + // 3) 由 systemd 拉起(崩溃自动重启、linger 登录后自启) + console.log(`[prod] systemctl --user restart ${SYSTEMD_UNIT_NAME}`); + systemctlUser(["restart", SYSTEMD_UNIT_NAME]); + + // 等 MainPID 出现 + let pid = null; + for (let i = 0; i < 40; i += 1) { + pid = mainPidFromSystemd(); + if (pid) break; + await sleep(250); + } + if (!pid) { + throw new Error( + `systemd 未给出 MainPID,status:\n${systemctlUserStatusText()}`, + ); + } const state = { - schema: "mnote.prod_process.v1", - pid: child.pid, + schema: "mnote.prod_process.v2", + managedBy: "systemd-user", + unit: SYSTEMD_UNIT_NAME, + pid, version: release.version, gitHead: release.metadata?.gitHead, startedAt: new Date().toISOString(), @@ -567,19 +783,25 @@ async function startRelease(release) { publicBind, releaseDir: release.releaseDir, binaryPath, + currentLink: RUN_CURRENT_LINK, + envPath: RUN_ENV_PATH, logPath, + journal: `journalctl --user -u ${SYSTEMD_UNIT_NAME} -f`, }; await fsp.writeFile(RUN_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf8"); - await waitForReady(port, logPath, child.pid); + + await waitForReady(port, logPath, pid); await verifyStartupAssets(port); - console.log(`[prod] 已启动 mnote-web release ${release.version}`); - console.log(`[prod] pid=${child.pid}`); + console.log(`[prod] 已启动 mnote-web release ${release.version}(systemd --user)`); + console.log(`[prod] unit=${SYSTEMD_UNIT_NAME} pid=${pid}`); console.log(`[prod] local URL=http://127.0.0.1:${port}`); for (const address of lanAddresses()) { console.log(`[prod] LAN URL=http://${address}:${port}`); } - console.log(`[prod] log=${logPath}`); + console.log(`[prod] journal: journalctl --user -u ${SYSTEMD_UNIT_NAME} -f`); + console.log(`[prod] log file=${logPath}(历史;新日志优先 journal)`); + console.log(`[prod] enable+linger: 开机/登出后仍保持运行(本机 Linger=yes)`); } async function main() {