From d3f37b7d44c8392ff059b5c670d631c1ff6a399f Mon Sep 17 00:00:00 2001 From: Patodo Date: Sat, 19 Sep 2026 16:16:29 +0800 Subject: [PATCH 1/2] feat(cli): give the foreground Server a stable default port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `localapp server run` asked the OS for an ephemeral port on every start, so the address changed on each restart. Anything that has to name the Server — a saved profile, a reverse proxy, a published container port, or a terminal that keeps one process alive — could not pin it, and the port a running Server reported had to be copied by hand into every client. Default to 50524 and keep `--port` as the override, including `--port 0` for callers that really want an ephemeral port. The container image already passes an explicit port, so its behaviour is unchanged. The foreground mode is also a first-class way to run a personal Server when a fixed address matters or when the machine cannot register a scheduled task: docs/local-runtime.md now says so next to the daemon path. Verified on the packaged build: no arguments listens on 127.0.0.1:50524 with /health answering 200, and `--port 55441` moves it there and releases 50524. --- README.md | 2 +- docs/local-runtime.md | 5 +++- packages/localapp/src/cli/help.ts | 3 +- packages/localapp/src/commands/server.ts | 10 ++++++- .../localapp/tests/server-foreground.test.ts | 28 ++++++++++++++++++- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 514d27e..74eecaa 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ backend/ | 命令 | 说明 | | --- | --- | | `localapp server [start]` | 注册系统集成并启动当前用户 daemon | -| `localapp server run` | 以前台模式运行同一 Server,适合容器和服务管理器;Windows 上使用包内 native helper | +| `localapp server run` | 以前台模式运行同一 Server,默认监听 `127.0.0.1:50524`(`--port` 覆盖);适合容器、服务管理器和需要固定地址的机器 | | `localapp server stop/restart/status/logs/uninstall` | 管理当前用户 daemon | | `localapp init ` | 从 npm 包内置模板创建应用 | | `localapp build --package` | 构建并生成不含本地数据的 `.localapp` | diff --git a/docs/local-runtime.md b/docs/local-runtime.md index 65365eb..10ee821 100644 --- a/docs/local-runtime.md +++ b/docs/local-runtime.md @@ -6,11 +6,14 @@ LocalApp 只有一个 Server 实现。它可以作为开发机上的本地 Serve Windows 上的 daemon 是当前用户的计划任务:登录时启动,并以该用户自身的普通权限运行,不继承提权令牌。因此它需要有交互登录会话——只通过 SSH 使用、没有桌面会话的机器请改用 `localapp server run` 前台模式。首次注册计划任务可能需要管理员终端,之后 `status`、`stop`、`dev` 等命令在普通终端即可操作 daemon。 +前台模式同样是一种正式用法,适合需要**稳定监听地址**的机器(已保存的 profile、反向代理、容器端口映射,或本机无法注册计划任务):它监听固定的 **50524**,可用 `--port` 覆盖(`--port 0` 才表示临时端口),由启动它的终端显式维持进程,不涉及计划任务与 daemon。 + ```bash npm install --global @patodo/localapp localapp server # 等同于 localapp server start,注册并启动用户 daemon localapp server status -localapp server run # 容器/前台运行 +localapp server run # 容器/前台运行,默认监听 127.0.0.1:50524 +localapp server run --port 55441 # 固定到指定端口 ``` 不进行全局安装时,可使用 `npx --package @patodo/localapp localapp --version` 验证当前 diff --git a/packages/localapp/src/cli/help.ts b/packages/localapp/src/cli/help.ts index 0e6cefc..69bf4f7 100644 --- a/packages/localapp/src/cli/help.ts +++ b/packages/localapp/src/cli/help.ts @@ -123,11 +123,12 @@ Usage: Options: --data-dir Server database, files, and configuration directory --host
Listen address (default: 127.0.0.1) - --port Listen port, 0 selects an available port (default: 0) + --port Listen port, 0 selects an available port (default: 50524) -h, --help Show this help Examples: localapp server run + localapp server run --port 55441 localapp server run --data-dir ./localapp-data --port 3000 localapp server run --host 0.0.0.0 --port 3000 `], diff --git a/packages/localapp/src/commands/server.ts b/packages/localapp/src/commands/server.ts index 430165c..1176a7b 100644 --- a/packages/localapp/src/commands/server.ts +++ b/packages/localapp/src/commands/server.ts @@ -11,6 +11,14 @@ import { createNativeAdapter, type NativeAdapter, type NativeAdapterOptions } fr export type ServerCommandAction = "start" | "stop" | "restart" | "status" | "logs" | "uninstall"; export interface RunServerCommandOptions { action: ServerCommandAction; } + +/** + * A foreground Server keeps one address across restarts unless the operator + * asks for another one: a saved profile, a reverse proxy, or a container port + * mapping must not have to follow a port the Server picked for itself. Pass + * `--port 0` to ask for any available port instead. + */ +export const DEFAULT_FOREGROUND_PORT = 50524; export interface ServerCommandDependencies { layout?: RuntimeLayout; artifactDirectory?: string; @@ -79,7 +87,7 @@ export async function runServerForeground(options: { dataDir?: string; host?: st if (typeof manifest.serverEntrypoint !== "string") throw lifecycleError("canonical_server_unavailable", "The packed canonical LocalApp Server runtime is unavailable"); const entrypoint = path.join(artifact, ...manifest.serverEntrypoint.split("/")); const child = (dependencies.spawnOwnedProcess ?? spawnOwnedProcess)(process.execPath, [entrypoint, "start", "--data-dir", options.dataDir ?? layout.dataDir, - "--host", options.host ?? "127.0.0.1", "--port", String(options.port ?? 0)], { stdio: "inherit" }); + "--host", options.host ?? "127.0.0.1", "--port", String(options.port ?? DEFAULT_FOREGROUND_PORT)], { stdio: "inherit" }); return await new Promise((resolve, reject) => { let termination: Promise | undefined; let settled = false; diff --git a/packages/localapp/tests/server-foreground.test.ts b/packages/localapp/tests/server-foreground.test.ts index 88b8b03..2777eb6 100644 --- a/packages/localapp/tests/server-foreground.test.ts +++ b/packages/localapp/tests/server-foreground.test.ts @@ -5,7 +5,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest import { runServerForeground } from "../src/commands/server.js"; import { buildLocalAppPackage } from "../scripts/build-package.mjs"; import type { OwnedProcess } from "../src/process/process-tree.js"; - const root = path.resolve(process.cwd(), "../.."); const testRoot = path.join(root, "tmp/task-7b-foreground-tests"); const packageArtifact = path.join(testRoot, "package-artifact"); @@ -37,4 +36,31 @@ describe("server run foreground ownership", () => { releaseTerminate(); await expect(result).resolves.toBe(0); }); + + it("keeps one stable default port unless the operator asks for another", async () => { + // Break caught: the foreground Server picked an ephemeral port on every + // start, so a saved profile, a proxy target, or a published container port + // had to follow a port the Server chose for itself. + const invocations: string[][] = []; + const ownedStub = () => { + const child = new EventEmitter() as unknown as OwnedProcess["child"]; + return { + child, + pid: 42, + exited: Promise.resolve({ code: 0, signal: null }), + terminate: async () => undefined, + } as unknown as OwnedProcess; + }; + const run = (options: { port?: number }) => runServerForeground(options, { + artifactDirectory: packageArtifact, + spawnOwnedProcess: (_command, args) => { invocations.push([...args]); return ownedStub(); }, + }); + + await expect(run({})).resolves.toBe(0); + await expect(run({ port: 55441 })).resolves.toBe(0); + await expect(run({ port: 0 })).resolves.toBe(0); + + const portOf = (args: string[]) => args[args.indexOf("--port") + 1]; + expect(invocations.map(portOf)).toEqual(["50524", "55441", "0"]); + }); }); From dcfedbf5f378ea37e08340dcf26c0cfda81c0dcc Mon Sep 17 00:00:00 2001 From: Patodo Date: Sat, 19 Sep 2026 18:17:55 +0800 Subject: [PATCH 2/2] fix(server): treat a WebAssembly trap as terminal instead of reopening forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A production Server died from `RuntimeError: memory access out of bounds` at `openMetaDbFromDisk` -> `new SqlJs.Database(...)`. That stack is the clue: the open only happens when the cached database was already evicted, so the trap had been recognized before — the recovery was the bug. `initSqlJs()` returns the same Emscripten module instance on every call, so once a trap tears it, reopening the database in-process traps again on the very next construction. The previous code evicted the handle and reopened anyway, which turned one trap into every database request failing, and — because the 5s request-log flush swallows its own errors — did so invisibly for 46 minutes, until the 6h cleanup callback hit the same trap without a catch and killed the process. Record the runtime as terminal on the first trap, refuse later database access with `db_runtime_restart_required` (503) instead of a doomed reopen, and stop the process once so its supervisor starts a clean one — the only recovery that exists for a torn module instance. Timer callbacks that touch SQLite also had no error boundary at all: six of them plus two SSE heartbeats. Route them through `guardTimerCallback`, which sends a trap to the same terminal stop, keeps a genuine timer failure from vanishing, and captures asynchronous rejections. The previous app-db test asserted the opposite guarantee — it simulated the trap with a JS-thrown RuntimeError, which leaves the module intact, so in-process reopen appeared to work. It now asserts the real one. --- .../src/lib/__tests__/runtime-errors.test.ts | 49 ++++++++++++++ packages/server-core/src/lib/app-db.ts | 28 +++----- .../server-core/src/lib/runtime-errors.ts | 66 ++++++++++++++++++- .../server/src/lib/__tests__/app-db.test.ts | 47 ++++++++----- packages/server/src/lib/meta-sqlite.ts | 9 ++- packages/server/src/lib/request-logger.ts | 10 ++- packages/server/src/lib/timer-guard.ts | 39 +++++++++++ packages/server/src/plugins/verification.ts | 3 +- packages/server/src/routes/desktop-actions.ts | 7 +- packages/server/src/routes/device-actions.ts | 7 +- packages/server/src/server.ts | 3 +- packages/server/tests/timer-guard.test.ts | 50 ++++++++++++++ 12 files changed, 268 insertions(+), 50 deletions(-) create mode 100644 packages/server-core/src/lib/__tests__/runtime-errors.test.ts create mode 100644 packages/server/src/lib/timer-guard.ts create mode 100644 packages/server/tests/timer-guard.test.ts diff --git a/packages/server-core/src/lib/__tests__/runtime-errors.test.ts b/packages/server-core/src/lib/__tests__/runtime-errors.test.ts new file mode 100644 index 0000000..c0f006a --- /dev/null +++ b/packages/server-core/src/lib/__tests__/runtime-errors.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + assertSqlJsRuntimeUsable, + isSqlJsRuntimeUnusable, + markSqlJsRuntimeUnusable, + resetSqlJsRuntimeUsability, + setSqlJsRuntimeStopHook, +} from "../runtime-errors.js"; + +afterEach(() => { + setSqlJsRuntimeStopHook(undefined); + resetSqlJsRuntimeUsability(); +}); + +describe("terminal SQLite runtime failures", () => { + it("records a WebAssembly trap as terminal and stops the process once", () => { + // Break caught: the runtime reported a trap, evicted the database and reopened + // it, but `initSqlJs()` returns the same trapped module instance, so every + // later database request trapped again — including the reopen itself. + const stops: string[] = []; + setSqlJsRuntimeStopHook((reason) => stops.push(reason)); + expect(isSqlJsRuntimeUnusable()).toBe(false); + + const trap = new WebAssembly.RuntimeError("memory access out of bounds"); + const first = markSqlJsRuntimeUnusable(trap, "meta database"); + expect(first.code).toBe("db_runtime_restart_required"); + expect(first.status).toBe(503); + expect(first.details?.scope).toBe("meta database"); + expect(isSqlJsRuntimeUnusable()).toBe(true); + + // Repeated traps (every later request) must not restart the process again. + const second = markSqlJsRuntimeUnusable(new WebAssembly.RuntimeError("memory access out of bounds"), "meta database"); + expect(second.code).toBe("db_runtime_restart_required"); + expect(stops).toHaveLength(1); + }); + + it("rejects later database access with the restart code instead of a doomed reopen", () => { + setSqlJsRuntimeStopHook(() => undefined); + markSqlJsRuntimeUnusable(new WebAssembly.RuntimeError("memory access out of bounds"), "application database"); + expect(() => assertSqlJsRuntimeUsable()).toThrowError( + expect.objectContaining({ code: "db_runtime_restart_required", status: 503 }), + ); + }); + + it("leaves a healthy runtime usable", () => { + expect(() => assertSqlJsRuntimeUsable()).not.toThrow(); + }); +}); diff --git a/packages/server-core/src/lib/app-db.ts b/packages/server-core/src/lib/app-db.ts index 998c17f..9358faa 100644 --- a/packages/server-core/src/lib/app-db.ts +++ b/packages/server-core/src/lib/app-db.ts @@ -2,7 +2,7 @@ import initSqlJs, { Database as SqlJsDatabase } from "sql.js"; import fs from "node:fs"; import path from "node:path"; import { AsyncLocalStorage } from "node:async_hooks"; -import { LocalAppRuntimeError, isWasmRuntimeError, wrapDatabaseRuntimeError } from "./runtime-errors.js"; +import { LocalAppRuntimeError, assertSqlJsRuntimeUsable, isWasmRuntimeError, markSqlJsRuntimeUnusable, wrapDatabaseRuntimeError } from "./runtime-errors.js"; import { collectConvertibleIssueTasks, replaceIssueTaskContent } from "./issue-task-conversion.js"; import type { FieldType, FieldConstraints, DataSchema } from "../types/models.js"; @@ -175,7 +175,8 @@ export async function exportDatabaseSnapshot(dbPath: string): Promise { }); } -async function openDatabase(dbPath: string, retry = true): Promise { +async function openDatabase(dbPath: string): Promise { + assertSqlJsRuntimeUsable(); const SQL = await getSqlJs(); try { const db = fs.existsSync(dbPath) @@ -183,10 +184,9 @@ async function openDatabase(dbPath: string, retry = true): Promise(dbPath: string, fn: () => T): T { function recoverFromSqlJsRuntimeError(dbPath: string, err: unknown): void { if (!isWasmRuntimeError(err)) return; evictConnectionForDbPath(dbPath); - resetSqlJsRuntimeAfterError(); -} - -function resetSqlJsRuntimeAfterError(): void { - for (const [dbPath, entry] of _connections) { - try { - entry.db.close(); - } catch { - // A WASM runtime error can leave the module in a bad state; reset is best-effort. - } - _connections.delete(dbPath); - } - SqlJs = null; + // Clearing the cached module cannot help: `initSqlJs()` hands back the trapped + // instance again, so this trap is terminal for the process. + markSqlJsRuntimeUnusable(err, "application database"); } function markDirty(dbPath: string): void { diff --git a/packages/server-core/src/lib/runtime-errors.ts b/packages/server-core/src/lib/runtime-errors.ts index bf17447..b2c0182 100644 --- a/packages/server-core/src/lib/runtime-errors.ts +++ b/packages/server-core/src/lib/runtime-errors.ts @@ -4,6 +4,7 @@ export type LocalAppRuntimeErrorCode = | "action_runtime_error" | "action_concurrency_timeout" | "db_runtime_error" + | "db_runtime_restart_required" | "db_contract_error" | "db_queue_timeout" | "named_sql_result_too_large"; @@ -44,8 +45,7 @@ export function isWasmRuntimeError(err: unknown): boolean { return /sql-wasm\.js/i.test(stack) && (message.trim() === "" || message.includes("\uFFFD")); } -export function summarizeError(err: unknown): LocalAppRuntimeErrorDetails { - if (err instanceof Error) { +export function summarizeError(err: unknown): LocalAppRuntimeErrorDetails { if (err instanceof Error) { return { originalName: err.name, originalMessage: err.message, @@ -94,3 +94,65 @@ export function wrapDatabaseContractError( }, }); } + +type SqlJsRuntimeStopHook = (reason: string) => void; + +let sqlJsRuntimeStop: SqlJsRuntimeStopHook | undefined; +let sqlJsRuntimeStopReason: string | undefined; + +/** Tests observe the stop instead of ending the process under test. */ +export function setSqlJsRuntimeStopHook(hook: SqlJsRuntimeStopHook | undefined): void { + sqlJsRuntimeStop = hook; +} + +export function isSqlJsRuntimeUnusable(): boolean { + return sqlJsRuntimeStopReason !== undefined; +} + +/** + * A WebAssembly trap tears the Emscripten module instance, and `initSqlJs()` + * hands that same instance back on every later call — so re-opening a database + * in this process traps again on the next `new SQL.Database(...)`. Recovery + * therefore cannot be in-process: the trap is terminal, and the process stops + * (non-zero) so whatever supervises it starts a clean one. Serving requests on + * the poisoned instance is what turned a single trap into every database + * request failing until an unguarded timer callback finally killed the process. + */ +export function markSqlJsRuntimeUnusable(err: unknown, scope: string): LocalAppRuntimeError { + const fatal = new LocalAppRuntimeError( + `The LocalApp SQLite runtime is unusable after a WebAssembly trap in ${scope}; the Server must restart`, + { + status: 503, + code: "db_runtime_restart_required", + cause: err, + details: { scope, ...summarizeError(err) }, + }, + ); + if (sqlJsRuntimeStopReason !== undefined) return fatal; + sqlJsRuntimeStopReason = fatal.message; + process.stderr.write(`[localapp] ${fatal.message} (${JSON.stringify(summarizeError(err))})\n`); + if (sqlJsRuntimeStop) { + sqlJsRuntimeStop(fatal.message); + return fatal; + } + // The process is unusable from here on; leave the log line behind before it goes. + setImmediate(() => process.exit(1)); + return fatal; +} + +/** The failure later requests get once the runtime is terminal, instead of a doomed reopen. */ +export function unusableSqlJsRuntimeError(): LocalAppRuntimeError { + return new LocalAppRuntimeError( + "The LocalApp SQLite runtime is unusable after a WebAssembly trap; the Server must restart", + { status: 503, code: "db_runtime_restart_required", details: {} }, + ); +} + +export function assertSqlJsRuntimeUsable(): void { + if (sqlJsRuntimeStopReason !== undefined) throw unusableSqlJsRuntimeError(); +} + +/** Tests only: clears the terminal state so each case starts from a healthy runtime. */ +export function resetSqlJsRuntimeUsability(): void { + sqlJsRuntimeStopReason = undefined; +} diff --git a/packages/server/src/lib/__tests__/app-db.test.ts b/packages/server/src/lib/__tests__/app-db.test.ts index 92b602e..2b9b484 100644 --- a/packages/server/src/lib/__tests__/app-db.test.ts +++ b/packages/server/src/lib/__tests__/app-db.test.ts @@ -12,6 +12,7 @@ import { getDbPath, } from "../app-db.js"; import type { SchemaField, DataSchema } from "../../types/models.js"; +import { resetSqlJsRuntimeUsability, setSqlJsRuntimeStopHook } from "@localapp/server-core"; function testSchema( overrides: { fields: Record } & Partial>, @@ -119,23 +120,35 @@ describe("execRawSql", () => { expect(result.rows).toEqual([{ title: "persisted" }]); }); - it("evicts cached sql.js runtime after sql-wasm surfaces an empty runtime error", async () => { - const schema = testSchema({ - fields: { title: { type: "string" } }, - }); - await createTable(tmpDir, schema); - const dbPath = await prepareDb(tmpDir); - const before = await getConnection(dbPath); - (before as unknown as { create_function: (name: string, fn: () => unknown) => void }) - .create_function("localapp_boom", () => { - throw new WebAssembly.RuntimeError("memory access out of bounds"); + it("treats a WebAssembly trap as terminal instead of pretending to reopen the database", async () => { + // Break caught: this case used to expect eviction + reopen to keep working. + // It only passed because the trap here is a JS-thrown RuntimeError, which + // leaves the Emscripten module intact; a real trap tears it, and initSqlJs() + // hands that same torn module back, so the reopen trapped again on every + // request until an unguarded timer callback killed the process. The runtime + // is now terminal: later access is refused with a restart code, and the + // process is asked to stop exactly once. + const stops: string[] = []; + setSqlJsRuntimeStopHook((reason) => stops.push(reason)); + try { + const schema = testSchema({ + fields: { title: { type: "string" } }, }); - - expect(() => execRawSql(dbPath, "SELECT localapp_boom()")).toThrow(); - - const after = await getConnection(dbPath); - expect(after).not.toBe(before); - execRawSql(dbPath, "INSERT INTO bugs (title) VALUES (?)", ["recovered"]); - expect(execRawSql(dbPath, "SELECT title FROM bugs").rows).toEqual([{ title: "recovered" }]); + await createTable(tmpDir, schema); + const dbPath = await prepareDb(tmpDir); + const before = await getConnection(dbPath); + (before as unknown as { create_function: (name: string, fn: () => unknown) => void }) + .create_function("localapp_boom", () => { + throw new WebAssembly.RuntimeError("memory access out of bounds"); + }); + + expect(() => execRawSql(dbPath, "SELECT localapp_boom()")).toThrow(); + + await expect(getConnection(dbPath)).rejects.toMatchObject({ code: "db_runtime_restart_required", status: 503 }); + expect(stops).toHaveLength(1); + } finally { + setSqlJsRuntimeStopHook(undefined); + resetSqlJsRuntimeUsability(); + } }); }); diff --git a/packages/server/src/lib/meta-sqlite.ts b/packages/server/src/lib/meta-sqlite.ts index 6d387e0..7278d2b 100644 --- a/packages/server/src/lib/meta-sqlite.ts +++ b/packages/server/src/lib/meta-sqlite.ts @@ -2,7 +2,7 @@ import initSqlJs, { Database as SqlJsDatabase } from "sql.js"; import fs from "node:fs"; import path from "node:path"; import { createHash, randomBytes } from "node:crypto"; -import { ISSUE_SAVED_REPLY_LIMIT, normalizeIssueSavedReplyInput, type IssueSavedReplyInput } from "@localapp/server-core"; +import { ISSUE_SAVED_REPLY_LIMIT, assertSqlJsRuntimeUsable, markSqlJsRuntimeUnusable, normalizeIssueSavedReplyInput, type IssueSavedReplyInput } from "@localapp/server-core"; export const BOOTSTRAP_USER_ID = "localadmin"; export const MAX_NOTIFICATION_DELIVERY_SEQUENCE = Number.MAX_SAFE_INTEGER; @@ -139,6 +139,12 @@ function evictMetaDbAfterRuntimeError(err: unknown): void { } catch { // The sql.js instance may already be poisoned; recovery happens by reopening from disk. } + // Reopening cannot succeed in this process: `initSqlJs()` returns the trapped + // module instance again, so the next `new SqlJs.Database(...)` traps the same + // way — that is exactly the trap seen at `openMetaDbFromDisk`. Record the + // terminal state so the process stops instead of failing every database + // request until an unguarded timer callback kills it. + markSqlJsRuntimeUnusable(err, "meta database"); } function guardSqlJsCall(fn: () => T): T { @@ -152,6 +158,7 @@ function guardSqlJsCall(fn: () => T): T { function assertMetaDatabaseAvailable(): void { if (commitStateUnknown) throw commitStateUnknown; + assertSqlJsRuntimeUsable(); } function guardStatement>(stmt: T): T { diff --git a/packages/server/src/lib/request-logger.ts b/packages/server/src/lib/request-logger.ts index bf5925b..eb3a9fb 100644 --- a/packages/server/src/lib/request-logger.ts +++ b/packages/server/src/lib/request-logger.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from "fastify"; import { insertRequestLogs, insertPageViews, type RequestLogEntry, type PageViewEntry } from "./meta-sqlite.js"; +import { guardTimerCallback } from "./timer-guard.js"; const FLUSH_INTERVAL_MS = 5_000; const MAX_BUFFER_SIZE = 100; @@ -17,8 +18,11 @@ function flush(): void { try { insertRequestLogs(requests); insertPageViews(views); - } catch { - // Logging should never crash the server + } catch (error) { + // Logging should never crash the server, but swallowing a terminal SQLite + // trap here is how a single trap stayed invisible while every database + // request failed for the next 46 minutes. + guardTimerCallback("request log flush", () => { throw error; })(); } } @@ -43,7 +47,7 @@ export function pushPageView(entry: PageViewEntry): void { export function startRequestLogger(): void { if (timer) return; - timer = setInterval(flush, FLUSH_INTERVAL_MS); + timer = setInterval(guardTimerCallback("request log flush", flush), FLUSH_INTERVAL_MS); } export function stopRequestLogger(): void { diff --git a/packages/server/src/lib/timer-guard.ts b/packages/server/src/lib/timer-guard.ts new file mode 100644 index 0000000..14d35fb --- /dev/null +++ b/packages/server/src/lib/timer-guard.ts @@ -0,0 +1,39 @@ +import { isWasmRuntimeError, markSqlJsRuntimeUnusable } from "@localapp/server-core"; + +/** + * Timer callbacks that touch SQLite run with no request around them, so a + * WebAssembly trap there has no error boundary. Unguarded it either becomes an + * uncaught exception that kills the process on the spot, or — when the callback + * swallows its own errors — leaves the Server answering every database request + * with a failure while nobody notices. Both happened in production: a 5s log + * flush swallowed the first trap, and the 6h cleanup callback later crashed the + * process with the same trap. + * + * A trap is terminal, so the guard routes it to the shared stop, and every other + * failure is reported instead of vanishing. + */ +export function guardTimerCallback(label: string, callback: () => unknown): () => void { + return () => { + try { + const result: unknown = callback(); + if (isPromiseLike(result)) { + void result.catch((error: unknown) => reportTimerFailure(label, error)); + } + } catch (error) { + reportTimerFailure(label, error); + } + }; +} + +function reportTimerFailure(label: string, error: unknown): void { + if (isWasmRuntimeError(error)) { + markSqlJsRuntimeUnusable(error, label); + return; + } + const message = error instanceof Error ? error.message : String(error ?? "unknown error"); + process.stderr.write(`[localapp] ${label} failed: ${message}\n`); +} + +function isPromiseLike(value: unknown): value is Promise { + return typeof value === "object" && value !== null && typeof (value as { then?: unknown }).then === "function"; +} diff --git a/packages/server/src/plugins/verification.ts b/packages/server/src/plugins/verification.ts index 36cdec8..81aaffa 100644 --- a/packages/server/src/plugins/verification.ts +++ b/packages/server/src/plugins/verification.ts @@ -1,4 +1,5 @@ import type { FastifyInstance } from "fastify"; +import { guardTimerCallback } from "../lib/timer-guard.js"; import fp from "fastify-plugin"; import { VerificationSessionStore } from "../lib/verification-sessions.js"; @@ -12,7 +13,7 @@ async function verification(app: FastifyInstance) { const store = new VerificationSessionStore(app.config.dataDir); store.initialize(); app.decorate("verificationSessions", store); - const cleanupTimer = setInterval(() => store.cleanupExpired(), 30_000); + const cleanupTimer = setInterval(guardTimerCallback("verification session cleanup", () => store.cleanupExpired()), 30_000); cleanupTimer.unref(); app.addHook("onClose", async () => clearInterval(cleanupTimer)); } diff --git a/packages/server/src/routes/desktop-actions.ts b/packages/server/src/routes/desktop-actions.ts index 68b1da7..3a048fc 100644 --- a/packages/server/src/routes/desktop-actions.ts +++ b/packages/server/src/routes/desktop-actions.ts @@ -1,4 +1,5 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { guardTimerCallback } from "../lib/timer-guard.js"; import { claimDesktopAction, cleanupDesktopActions, @@ -177,7 +178,7 @@ export async function desktopActionsRoutes(app: FastifyInstance) { new Date(Date.now() - DESKTOP_ACTION_TERMINAL_RETENTION_MS), ); cleanup(); - const cleanupTimer = setInterval(cleanup, DESKTOP_ACTION_CLEANUP_INTERVAL_MS); + const cleanupTimer = setInterval(guardTimerCallback("desktop action cleanup", cleanup), DESKTOP_ACTION_CLEANUP_INTERVAL_MS); cleanupTimer.unref(); app.addHook("onClose", async () => { clearInterval(cleanupTimer); @@ -354,11 +355,11 @@ export async function desktopActionsRoutes(app: FastifyInstance) { }); sseClients.add(client); client.write(initial); - const heartbeat = setInterval(() => { + const heartbeat = setInterval(guardTimerCallback("desktop action heartbeat", () => { const snapshot = getDesktopActionSnapshot(userId, req.params.id); if (snapshot) client.write(snapshot); reply.raw.write(": heartbeat\n\n"); - }, 15_000); + }), 15_000); heartbeat.unref(); req.raw.on("close", () => { clearInterval(heartbeat); diff --git a/packages/server/src/routes/device-actions.ts b/packages/server/src/routes/device-actions.ts index 4457816..b019334 100644 --- a/packages/server/src/routes/device-actions.ts +++ b/packages/server/src/routes/device-actions.ts @@ -1,4 +1,5 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { guardTimerCallback } from "../lib/timer-guard.js"; import { claimDeviceAction, cleanupDeviceActions, @@ -181,7 +182,7 @@ export async function handleDeviceActionCreation( export async function deviceActionsRoutes(app: FastifyInstance) { const cleanup = () => cleanupDeviceActions(new Date(Date.now() - DEVICE_ACTION_TERMINAL_RETENTION_MS)); cleanup(); - const cleanupTimer = setInterval(cleanup, DEVICE_ACTION_CLEANUP_INTERVAL_MS); + const cleanupTimer = setInterval(guardTimerCallback("device action cleanup", cleanup), DEVICE_ACTION_CLEANUP_INTERVAL_MS); cleanupTimer.unref(); app.addHook("onClose", async () => clearInterval(cleanupTimer)); @@ -273,11 +274,11 @@ export async function deviceActionsRoutes(app: FastifyInstance) { reply.raw.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no" }); sseClients.add(client); client.write(initial); - const heartbeat = setInterval(() => { + const heartbeat = setInterval(guardTimerCallback("device action heartbeat", () => { const snapshot = getDeviceActionSnapshot(userId, req.params.id); if (snapshot) client.write(snapshot); reply.raw.write(": heartbeat\n\n"); - }, 15_000); + }), 15_000); heartbeat.unref(); req.raw.on("close", () => { clearInterval(heartbeat); sseClients.delete(client); }); reply.hijack(); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 0671e93..8d0437f 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,4 +1,5 @@ import Fastify, { type FastifyInstance } from "fastify"; +import { guardTimerCallback } from "./lib/timer-guard.js"; import fastifyStatic from "@fastify/static"; import multipart from "@fastify/multipart"; import cookie from "@fastify/cookie"; @@ -223,7 +224,7 @@ async function registerServerPluginsAndRoutes( } }); - const idleConnectionTimer = setInterval(closeIdleConnections, 60_000); + const idleConnectionTimer = setInterval(guardTimerCallback("idle database close", closeIdleConnections), 60_000); idleConnectionTimer.unref(); app.addHook("onListen", async () => { startRequestLogger(); diff --git a/packages/server/tests/timer-guard.test.ts b/packages/server/tests/timer-guard.test.ts new file mode 100644 index 0000000..75ad149 --- /dev/null +++ b/packages/server/tests/timer-guard.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isSqlJsRuntimeUnusable, resetSqlJsRuntimeUsability, setSqlJsRuntimeStopHook } from "@localapp/server-core"; + +import { guardTimerCallback } from "../src/lib/timer-guard.js"; + +afterEach(() => { + setSqlJsRuntimeStopHook(undefined); + resetSqlJsRuntimeUsability(); + vi.restoreAllMocks(); +}); + +describe("timer callback guard", () => { + it("routes a WebAssembly trap to the terminal stop instead of crashing the process", () => { + // Break caught: the 6h cleanup callback touched the database with no error + // boundary, so the trap became an uncaught exception and killed the Server. + const stops: string[] = []; + setSqlJsRuntimeStopHook((reason) => stops.push(reason)); + const guarded = guardTimerCallback("desktop action cleanup", () => { + throw new WebAssembly.RuntimeError("memory access out of bounds"); + }); + + expect(() => guarded()).not.toThrow(); + expect(stops).toHaveLength(1); + expect(isSqlJsRuntimeUnusable()).toBe(true); + }); + + it("reports an ordinary timer failure without ending the process", () => { + const stops: string[] = []; + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + setSqlJsRuntimeStopHook((reason) => stops.push(reason)); + + guardTimerCallback("verification session cleanup", () => { throw new Error("cleanup exploded"); })(); + + expect(stops).toEqual([]); + expect(isSqlJsRuntimeUnusable()).toBe(false); + expect(stderr).toHaveBeenCalledWith(expect.stringContaining("cleanup exploded")); + }); + + it("captures an asynchronous rejection from a timer callback", async () => { + const stops: string[] = []; + setSqlJsRuntimeStopHook((reason) => stops.push(reason)); + + guardTimerCallback("idle database close", async () => { + throw new WebAssembly.RuntimeError("memory access out of bounds"); + })(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(stops).toHaveLength(1); + }); +});