trilium/apps/server/spec/setup.ts
Elian Doran 56a6a84c1a
perf(server): back the execution context with AsyncLocalStorage
cls-hooked installs its own async_hooks hook at import time and keeps a
JS Map of contexts keyed by asyncId. Enabling a promiseResolve hook
turns off V8's promise fast paths for the whole process, so the cost is
not confined to code that uses the context. On Node 24 (the floor in
.nvmrc) AsyncLocalStorage is backed by AsyncContextFrame instead, which
is native and enables no hooks at all.

Measured on Node v24.19.0. Creating 200k contexts each running a
five-await chain: 890ms and 139MB RSS under cls-hooked, 80ms and 54MB
under AsyncLocalStorage, against 50ms with no context propagation at
all. Merely loading cls-hooked and creating a namespace, with no context
ever entered, slows 500k unrelated awaits from 18ms to 215ms; the
AsyncLocalStorage instance costs nothing measurable. The Electron main
process paid that too, since it loads the same provider.

Two behaviours the routes depend on carry over rather than being
dropped. A nested init() inherits from the enclosing scope, which
routes/api/llm.ts opens while its request is still on the stack and
reads componentId and hoistedNoteId through; init() therefore runs the
callback against a copy of the enclosing store, so writes shadow instead
of reaching back. And set() still throws outside a context: a silent
no-op there would drop an entity change from the sync queue without
anything to notice.

bindEmitter is reimplemented on AsyncResource.bind. AsyncLocalStorage
alone covers a listener whose emit descends from the scope that added it
but not one the socket raises on its own, which is what a client
aborting mid-response does — and #customRequestHandler scripts are
handed the Express response and invited by the User Guide to use it.
Three details it has to get right: only addListener, on and
prependListener are patched, because Node routes once() and
prependOnceListener() through them and patching all five wraps twice;
removal needs both a per-emitter WeakMap, so once()'s internal
removeListener finds its own wrapper, and a `listener` property naming
the innermost function, so off(originalFn) and listeners() still report
what the caller passed; and the fallback branch is a `function` so the
emitter arrives as `this`, which express's own listeners read.

The class is renamed from ClsHookedExecutionContext at its import sites.
The file keeps its name, since cls.* is still the vocabulary elsewhere.

Verified against the contract specs added in the previous commit, which
pass unchanged apart from reset() moving from a cls-hooked spy to an
observable assertion. Stubbing the new bindEmitter to a no-op fails the
same four tests as stubbing the old one did, so it is doing the work
rather than AsyncLocalStorage covering it incidentally. 898 tests across
the ETAPI and routes suites pass with cls-hooked removed from disk.

Removing the dependency also drops async-hook-jl, emitter-listener and
shimmer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:17:04 +03:00

54 lines
2.5 KiB
TypeScript

import { beforeAll } from "vitest";
import { readFileSync } from "fs";
import { join } from "path";
import { initializeCore, options } from "@triliumnext/core";
import { serverZipExportProviderFactory } from "../src/services/export/zip/factory.js";
import ServerBackupService from "../src/backup_provider.js";
import AsyncLocalStorageExecutionContext from "../src/cls_provider.js";
import NodejsCryptoProvider from "../src/crypto_provider.js";
import NodejsZipProvider from "../src/zip_provider.js";
import ServerPlatformProvider from "../src/platform_provider.js";
import BetterSqlite3Provider from "../src/sql_provider.js";
import NodejsInAppHelpProvider from "../src/in_app_help_provider.js";
import { initializeTranslationsWithParams } from "../src/services/i18n.js";
import ServerLogService from "../src/log_provider.js";
import { serverImageProvider } from "../src/services/image_provider.js";
// Initialize environment variables.
process.env.TRILIUM_DATA_DIR = join(__dirname, "db");
process.env.TRILIUM_RESOURCE_DIR = join(__dirname, "../src");
process.env.TRILIUM_INTEGRATION_TEST = "memory";
process.env.TRILIUM_ENV = "dev";
process.env.TRILIUM_PUBLIC_SERVER = "http://localhost:4200";
beforeAll(async () => {
// Load the integration test database into memory. The fixture at
// packages/trilium-core/src/test/fixtures/document.db is pre-seeded with
// the schema, demo content, and a known password ("demo1234") that the
// ETAPI tests log in with. Each test file runs in its own vitest fork
// (pool: "forks"), so each gets a fresh in-memory copy and mutations
// don't leak across files.
const dbProvider = new BetterSqlite3Provider();
dbProvider.loadFromBuffer(readFileSync(require.resolve("@triliumnext/core/src/test/fixtures/document.db")));
await initializeCore({
dbConfig: {
provider: dbProvider,
isReadOnly: false,
onTransactionCommit() {},
onTransactionRollback() {}
},
crypto: new NodejsCryptoProvider(),
zip: new NodejsZipProvider(),
zipExportProviderFactory: serverZipExportProviderFactory,
executionContext: new AsyncLocalStorageExecutionContext(),
schema: readFileSync(require.resolve("@triliumnext/core/src/assets/schema.sql"), "utf-8"),
platform: new ServerPlatformProvider(),
translations: initializeTranslationsWithParams,
inAppHelp: new NodejsInAppHelpProvider(),
backup: new ServerBackupService(options),
log: new ServerLogService(),
image: serverImageProvider
});
});