diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 832ff16f9..0e50d1289 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -1,5 +1,6 @@ import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3"; import Database from "better-sqlite3"; +import type BetterSqlite3 from "better-sqlite3"; import * as schema from "./schema/schema"; import path from "path"; import fs from "fs"; @@ -11,8 +12,69 @@ export const exists = checkFileExists(location); bootstrapVolume(); +/** + * Wraps better-sqlite3 Statement to call `finalize()` immediately after + * execution, freeing native sqlite3_stmt memory deterministically instead + * of waiting for GC. Fixes steady off-heap growth under load (#2120). + * WARNING: Finalizes after first execution — incompatible with drizzle's + * reusable .prepare() builders. No such usage exists in this codebase. + */ +function autoFinalizeStatement( + stmt: BetterSqlite3.Statement +): BetterSqlite3.Statement { + const wrapExec = any>(fn: T): T => { + return function (this: any, ...args: any[]) { + try { + return fn.apply(this, args); + } finally { + try { + // finalize() exists on the native Statement at runtime but + // is missing from @types/better-sqlite3. + (stmt as any).finalize(); + } catch { + // Already finalized — harmless + } + } + } as unknown as T; + }; + + stmt.run = wrapExec(stmt.run); + stmt.get = wrapExec(stmt.get); + stmt.all = wrapExec(stmt.all); + + return stmt; +} + function createDb() { const sqlite = new Database(location); + + if (process.env.ENABLE_SQLITE_WAL_MODE == "true") { + // Enable WAL mode — allows concurrent readers + single writer, preventing + // contention across subsystems (verifySession, Traefik, audit, ping). + sqlite.pragma("journal_mode = WAL"); + // NORMAL sync mode: safe with WAL, reduces write lock hold time. + sqlite.pragma("synchronous = NORMAL"); + } + + // Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log + // retry loops that accumulate memory. + sqlite.pragma("busy_timeout = 5000"); + + // 64 MB page cache (default 2 MB) — reduces I/O round-trips on large + // TraefikConfigManager JOINs that block the event loop. + sqlite.pragma("cache_size = -65536"); + + // 256 MB memory-mapped I/O — OS serves reads from page cache directly, + // reducing event-loop blocking. + sqlite.pragma("mmap_size = 268435456"); + + // Wrap prepare() so every drizzle-orm statement is auto-finalized after + // first use, preventing sqlite3_stmt accumulation between GC cycles. + const originalPrepare = sqlite.prepare.bind(sqlite); + (sqlite as any).prepare = function autoFinalizePrepare(source: string) { + return autoFinalizeStatement(originalPrepare(source)); + }; + return DrizzleSqlite(sqlite, { schema }); @@ -23,7 +85,7 @@ export default db; export const primaryDb = db; export type Transaction = Parameters< Parameters<(typeof db)["transaction"]>[0] - >[0]; +>[0]; export const DB_TYPE: "pg" | "sqlite" = "sqlite"; function checkFileExists(filePath: string): boolean { diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index 0970735e0..c01ebc9eb 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -22,7 +22,7 @@ import { Olm, olms, RemoteExitNode, - remoteExitNodes, + remoteExitNodes } from "@server/db"; import { eq } from "drizzle-orm"; import { db } from "@server/db"; @@ -194,8 +194,6 @@ const connectedClients: Map = new Map(); // Config version tracking map (local to this node, resets on server restart) const clientConfigVersions: Map = new Map(); - - // Recovery tracking let isRedisRecoveryInProgress = false; @@ -406,6 +404,9 @@ const removeClient = async ( const updatedClients = existingClients.filter((client) => client !== ws); if (updatedClients.length === 0) { connectedClients.delete(mapKey); + // Remove clientId from clientConfigVersions on disconnect — prevents + // unbounded memory growth from stale entries. + clientConfigVersions.delete(clientId); if (redisManager.isRedisEnabled()) { try { @@ -1097,6 +1098,11 @@ const disconnectClient = async (clientId: string): Promise => { } }); + // Eagerly remove client — close event may not fire if socket is already + // CLOSING, leaving zombie entries. + connectedClients.delete(mapKey); + clientConfigVersions.delete(clientId); + return true; }; diff --git a/server/routers/ws/ws.ts b/server/routers/ws/ws.ts index 6e6312715..e7dcfe9cb 100644 --- a/server/routers/ws/ws.ts +++ b/server/routers/ws/ws.ts @@ -3,7 +3,15 @@ import zlib from "zlib"; import { Server as HttpServer } from "http"; import { WebSocket, WebSocketServer } from "ws"; import { Socket } from "net"; -import { Newt, newts, NewtSession, olms, Olm, OlmSession, sites } from "@server/db"; +import { + Newt, + newts, + NewtSession, + olms, + Olm, + OlmSession, + sites +} from "@server/db"; import { eq } from "drizzle-orm"; import { db } from "@server/db"; import { recordPing } from "@server/routers/newt/pingAccumulator"; @@ -80,6 +88,9 @@ const removeClient = async ( const updatedClients = existingClients.filter((client) => client !== ws); if (updatedClients.length === 0) { connectedClients.delete(mapKey); + // Remove clientId from clientConfigVersions — prevents unbounded growth + // from stale entries. + clientConfigVersions.delete(clientId); logger.info( `All connections removed for ${clientType.toUpperCase()} ID: ${clientId}` @@ -218,9 +229,13 @@ const hasActiveConnections = async (clientId: string): Promise => { }; // Get the current config version for a client -const getClientConfigVersion = async (clientId: string): Promise => { +const getClientConfigVersion = async ( + clientId: string +): Promise => { const version = clientConfigVersions.get(clientId); - logger.debug(`getClientConfigVersion called for clientId: ${clientId}, returning: ${version} (type: ${typeof version})`); + logger.debug( + `getClientConfigVersion called for clientId: ${clientId}, returning: ${version} (type: ${typeof version})` + ); return version; }; @@ -507,6 +522,11 @@ const disconnectClient = async (clientId: string): Promise => { } }); + // Eagerly remove client — close event may not fire if socket already + // CLOSING, leaving zombie entries. + connectedClients.delete(mapKey); + clientConfigVersions.delete(clientId); + return true; }; diff --git a/src/components/CertificateStatus.tsx b/src/components/CertificateStatus.tsx index cc22b1e88..788c39c21 100644 --- a/src/components/CertificateStatus.tsx +++ b/src/components/CertificateStatus.tsx @@ -31,8 +31,9 @@ export function CertificateStatusContent({ const t = useTranslations(); const labelClass = - "inline-flex shrink-0 items-center self-center text-sm font-medium leading-none"; - const valueClass = "inline-flex items-center gap-2 text-sm leading-none"; + "inline-flex shrink-0 items-center self-center text-sm font-medium leading-normal"; + const valueClass = + "inline-flex items-center gap-2 text-sm leading-normal"; const handleRefresh = async () => { await refreshCert(); @@ -133,14 +134,14 @@ export function CertificateStatusContent({ {isPending && !disableRestartButton ? ( @@ -164,7 +165,7 @@ export function CertificateStatusContent({ ) : null} diff --git a/src/components/CopyToClipboard.tsx b/src/components/CopyToClipboard.tsx index dca14728c..7187694f7 100644 --- a/src/components/CopyToClipboard.tsx +++ b/src/components/CopyToClipboard.tsx @@ -33,7 +33,7 @@ const CopyToClipboard = ({
; + return ( +
+ {children} +
+ ); } export function InfoSectionTitle({ @@ -51,7 +55,11 @@ export function InfoSectionTitle({ children: React.ReactNode; className?: string; }) { - return
{children}
; + return ( +
+ {children} +
+ ); } export function InfoSectionContent({ @@ -62,8 +70,13 @@ export function InfoSectionContent({ className?: string; }) { return ( -
-
+
+
{children}
diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index e87a8b1a8..0bd606671 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -368,7 +368,7 @@ export default function LoginForm({ {hasIdp && ( <> -
+
diff --git a/src/components/MfaInputForm.tsx b/src/components/MfaInputForm.tsx index d52b3169e..221e51218 100644 --- a/src/components/MfaInputForm.tsx +++ b/src/components/MfaInputForm.tsx @@ -145,7 +145,7 @@ export default function MfaInputForm({ )} -
+
-
+
{params.get("gotoapp") ? (