Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` | 从 npm 包内置模板创建应用 |
| `localapp build --package` | 构建并生成不含本地数据的 `.localapp` |
Expand Down
5 changes: 4 additions & 1 deletion docs/local-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 验证当前
Expand Down
3 changes: 2 additions & 1 deletion packages/localapp/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,12 @@ Usage:
Options:
--data-dir <path> Server database, files, and configuration directory
--host <address> Listen address (default: 127.0.0.1)
--port <number> Listen port, 0 selects an available port (default: 0)
--port <number> 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
`],
Expand Down
10 changes: 9 additions & 1 deletion packages/localapp/src/commands/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<number>((resolve, reject) => {
let termination: Promise<void> | undefined;
let settled = false;
Expand Down
28 changes: 27 additions & 1 deletion packages/localapp/tests/server-foreground.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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"]);
});
});
49 changes: 49 additions & 0 deletions packages/server-core/src/lib/__tests__/runtime-errors.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
28 changes: 9 additions & 19 deletions packages/server-core/src/lib/app-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -175,18 +175,18 @@ export async function exportDatabaseSnapshot(dbPath: string): Promise<Buffer> {
});
}

async function openDatabase(dbPath: string, retry = true): Promise<SqlJsDatabase> {
async function openDatabase(dbPath: string): Promise<SqlJsDatabase> {
assertSqlJsRuntimeUsable();
const SQL = await getSqlJs();
try {
const db = fs.existsSync(dbPath)
? new SQL.Database(fs.readFileSync(dbPath))
: new SQL.Database();
return guardDatabase(dbPath, db);
} catch (err) {
if (retry && isWasmRuntimeError(err)) {
resetSqlJsRuntimeAfterError();
return openDatabase(dbPath, false);
}
// A trap during the open means the module instance is torn; retrying on it
// only traps again, so this is where the process stops instead.
recoverFromSqlJsRuntimeError(dbPath, err);
throw err;
}
}
Expand Down Expand Up @@ -243,19 +243,9 @@ function guardSqlJsCall<T>(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 {
Expand Down
66 changes: 64 additions & 2 deletions packages/server-core/src/lib/runtime-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
47 changes: 30 additions & 17 deletions packages/server/src/lib/__tests__/app-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SchemaField> } & Partial<Omit<DataSchema, "fields">>,
Expand Down Expand Up @@ -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();
}
});
});
9 changes: 8 additions & 1 deletion packages/server/src/lib/meta-sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<T>(fn: () => T): T {
Expand All @@ -152,6 +158,7 @@ function guardSqlJsCall<T>(fn: () => T): T {

function assertMetaDatabaseAvailable(): void {
if (commitStateUnknown) throw commitStateUnknown;
assertSqlJsRuntimeUsable();
}

function guardStatement<T extends Record<string, unknown>>(stmt: T): T {
Expand Down
Loading
Loading