diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 494ef747545..123e6bf63de 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -3781,9 +3781,10 @@ boundary. `managed-skill-content.test.ts` covers the focused domain behavior; `ephemeral-skills-process.test.ts` requires independent disposable Postgres and S3 fixtures and starts separate Node processes. The optional `PAPERCLIP_TEST_POSTGRES_ADMIN_URL` creates a random test database per suite. -On hosts with unavailable Tailscale, set `PAPERCLIP_TAILNET_BIND_HOST=127.0.0.1` -for local fixtures to avoid repeated CLI discovery. Do not use fixture timings -collected during builds as hosted performance evidence. +The server starts the `tailscale` command only when the bind configuration reads +a tailnet address, and at most one time per process, so local fixtures need no +`PAPERCLIP_TAILNET_BIND_HOST`. Do not use fixture timings collected during +builds as hosted performance evidence. ## App-Shipped Skills Catalog diff --git a/server/scripts/benchmark-config-load.ts b/server/scripts/benchmark-config-load.ts new file mode 100644 index 00000000000..1794cab6382 --- /dev/null +++ b/server/scripts/benchmark-config-load.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const argument = (name: string) => process.argv.find((value) => value.startsWith(`${name}=`))?.slice(name.length + 1); +const calls = Number(argument("--calls") ?? 200); +assert.ok(Number.isInteger(calls) && calls >= 1 && calls <= 100_000, "--calls must be an integer from 1 to 100000"); +assert.notEqual(process.platform, "win32", "the counting tailscale stand-in needs a POSIX shell"); + +const TAILNET_ADDRESS = "100.64.0.1"; +const home = mkdtempSync(path.join(os.tmpdir(), "paperclip-config-load-benchmark-")); +const configPath = path.join(home, "config.json"); +const execLogPath = path.join(home, "tailscale-execs.log"); +const binDir = path.join(home, "bin"); + +function isolateEnvironment() { + for (const name of [ + "PAPERCLIP_BIND", "PAPERCLIP_BIND_HOST", "PAPERCLIP_TAILNET_BIND_HOST", "HOST", "PAPERCLIP_DEPLOYMENT_MODE", + "PAPERCLIP_DEPLOYMENT_EXPOSURE", "PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "PAPERCLIP_MANAGED_CONFIG", + "PAPERCLIP_STORAGE_PROVIDER", "DATABASE_URL", + ]) delete process.env[name]; + process.env.PAPERCLIP_HOME = home; + process.env.PAPERCLIP_INSTANCE_ID = "default"; + process.env.PAPERCLIP_CONFIG = configPath; + if (!process.env.PATH?.startsWith(binDir)) process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; +} + +function writeConfig(server: Record) { + writeFileSync(configPath, JSON.stringify({ + $meta: { version: 1, updatedAt: "2026-11-11T00:00:00.000Z", source: "configure" }, + database: { mode: "embedded-postgres" }, + logging: { mode: "file" }, + server, + })); +} + +const tailscaleExecs = () => existsSync(execLogPath) ? readFileSync(execLogPath, "utf8").split("\n").filter(Boolean).length : 0; + +try { + isolateEnvironment(); + writeConfig({}); + mkdirSync(binDir); + const standIn = path.join(binDir, "tailscale"); + writeFileSync(standIn, `#!/bin/sh\necho exec >> '${execLogPath}'\necho ${TAILNET_ADDRESS}\n`); + chmodSync(standIn, 0o755); + + const { loadConfig } = await import("../src/config.js"); + const { getStorageService } = await import("../src/storage/index.js"); + isolateEnvironment(); + + const scenarios: Array<{ name: string; server: Record; expectedHost: string; run: () => void }> = [ + { name: "loadConfig, bind=lan (hosted shape)", server: { deploymentMode: "authenticated", exposure: "private", bind: "lan" }, + expectedHost: "0.0.0.0", run: () => { loadConfig(); } }, + { name: "getStorageService, bind=lan", server: { deploymentMode: "authenticated", exposure: "private", bind: "lan" }, + expectedHost: "0.0.0.0", run: () => { getStorageService(); } }, + { name: "loadConfig, default loopback", server: {}, expectedHost: "127.0.0.1", run: () => { loadConfig(); } }, + { name: "loadConfig, bind=tailnet", server: { deploymentMode: "authenticated", exposure: "private", bind: "tailnet" }, + expectedHost: TAILNET_ADDRESS, run: () => { loadConfig(); } }, + ]; + + const rows = scenarios.map((scenario) => { + writeConfig(scenario.server); + const execsBefore = tailscaleExecs(); + const started = process.hrtime.bigint(); + for (let index = 0; index < calls; index += 1) scenario.run(); + const totalMs = Number(process.hrtime.bigint() - started) / 1e6; + const execs = tailscaleExecs() - execsBefore; + assert.equal(loadConfig().host, scenario.expectedHost, `${scenario.name}: resolved bind host`); + return { + scenario: scenario.name, + calls, + "tailscale execs": execs, + "total ms (event loop blocked)": Number(totalMs.toFixed(1)), + "ms per call": Number((totalMs / calls).toFixed(3)), + }; + }); + console.table(rows); + console.log(`node ${process.version}, ${os.platform()} ${os.arch()}, rss ${Math.round(process.memoryUsage().rss / 1e6)} MB`); +} finally { + rmSync(home, { recursive: true, force: true }); +} diff --git a/server/src/__tests__/config-tailnet-bind-detection.test.ts b/server/src/__tests__/config-tailnet-bind-detection.test.ts new file mode 100644 index 00000000000..a8319cbc0cc --- /dev/null +++ b/server/src/__tests__/config-tailnet-bind-detection.test.ts @@ -0,0 +1,163 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const DETECTED_TAILNET_ADDRESS = "100.64.0.7"; + +const tailscale = vi.hoisted(() => ({ installed: true, execFileSync: vi.fn() })); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + tailscale.execFileSync.mockImplementation((command: string, ...rest: unknown[]) => { + if (command !== "tailscale") { + return (actual.execFileSync as (...args: unknown[]) => unknown)(command, ...rest); + } + if (!tailscale.installed) throw new Error("spawnSync tailscale ENOENT"); + return `${DETECTED_TAILNET_ADDRESS}\n`; + }); + return { ...actual, execFileSync: tailscale.execFileSync }; +}); + +let home: string; +let configPath: string; + +function tailscaleExecCount(): number { + return tailscale.execFileSync.mock.calls.filter(([command]) => command === "tailscale").length; +} + +function writeServerConfig(server: Record) { + fs.writeFileSync(configPath, JSON.stringify({ + $meta: { version: 1, updatedAt: "2026-11-11T00:00:00.000Z", source: "configure" }, + database: { mode: "embedded-postgres" }, + logging: { mode: "file" }, + server: { deploymentMode: "authenticated", exposure: "private", ...server }, + })); +} + +async function importFreshConfigModules() { + vi.resetModules(); + const [{ loadConfig }, { getStorageService }] = await Promise.all([ + import("../config.js"), + import("../storage/index.js"), + ]); + return { loadConfig, getStorageService }; +} + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-config-tailnet-")); + configPath = path.join(home, "config.json"); + vi.stubEnv("PAPERCLIP_HOME", home); + vi.stubEnv("PAPERCLIP_CONFIG", configPath); + for (const name of [ + "PAPERCLIP_BIND", "PAPERCLIP_BIND_HOST", "PAPERCLIP_TAILNET_BIND_HOST", "HOST", "PAPERCLIP_DEPLOYMENT_MODE", + "PAPERCLIP_DEPLOYMENT_EXPOSURE", "PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "PAPERCLIP_MANAGED_CONFIG", + "PAPERCLIP_STORAGE_PROVIDER", + ]) vi.stubEnv(name, undefined); + tailscale.installed = true; + tailscale.execFileSync.mockClear(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(home, { recursive: true, force: true }); +}); + +describe("tailnet bind host detection in loadConfig", () => { + it.each([ + { server: { bind: "loopback" }, host: "127.0.0.1" }, + { server: { bind: "lan" }, host: "0.0.0.0" }, + { server: { bind: "custom", customBindHost: "10.0.0.5" }, host: "10.0.0.5" }, + { server: { host: "127.0.0.1" }, host: "127.0.0.1" }, + { server: { host: "0.0.0.0" }, host: "0.0.0.0" }, + ])("does not start tailscale when the bind needs no tailnet address: $server", async ({ server, host }) => { + writeServerConfig(server); + const { loadConfig } = await importFreshConfigModules(); + + for (let call = 0; call < 3; call += 1) expect(loadConfig().host).toBe(host); + + expect(tailscaleExecCount()).toBe(0); + }); + + it("does not start tailscale when getStorageService() runs again and again", async () => { + writeServerConfig({ bind: "lan" }); + const { getStorageService } = await importFreshConfigModules(); + + const first = getStorageService(); + for (let call = 0; call < 5; call += 1) expect(getStorageService()).toBe(first); + + expect(tailscaleExecCount()).toBe(0); + }); + + it("detects the tailnet address one time for server.bind=tailnet", async () => { + writeServerConfig({ bind: "tailnet" }); + const { loadConfig } = await importFreshConfigModules(); + + for (let call = 0; call < 3; call += 1) { + expect(loadConfig()).toMatchObject({ bind: "tailnet", host: DETECTED_TAILNET_ADDRESS }); + } + + expect(tailscaleExecCount()).toBe(1); + }); + + it("detects one time when it infers the bind mode from a host without a bind mode", async () => { + const { loadConfig } = await importFreshConfigModules(); + + writeServerConfig({ host: DETECTED_TAILNET_ADDRESS }); + expect(loadConfig()).toMatchObject({ bind: "tailnet", host: DETECTED_TAILNET_ADDRESS }); + writeServerConfig({ host: "10.0.0.5" }); + expect(loadConfig()).toMatchObject({ bind: "custom", host: "10.0.0.5" }); + expect(loadConfig()).toMatchObject({ bind: "custom", host: "10.0.0.5" }); + + expect(tailscaleExecCount()).toBe(1); + }); + + it("uses PAPERCLIP_TAILNET_BIND_HOST without tailscale and sees each change to it", async () => { + writeServerConfig({ bind: "tailnet" }); + const { loadConfig } = await importFreshConfigModules(); + + vi.stubEnv("PAPERCLIP_TAILNET_BIND_HOST", "100.64.0.9"); + expect(loadConfig().host).toBe("100.64.0.9"); + vi.stubEnv("PAPERCLIP_TAILNET_BIND_HOST", "100.64.0.10"); + expect(loadConfig().host).toBe("100.64.0.10"); + expect(tailscaleExecCount()).toBe(0); + + vi.stubEnv("PAPERCLIP_TAILNET_BIND_HOST", undefined); + expect(loadConfig().host).toBe(DETECTED_TAILNET_ADDRESS); + expect(loadConfig().host).toBe(DETECTED_TAILNET_ADDRESS); + expect(tailscaleExecCount()).toBe(1); + }); + + it("sees a later change of the bind mode in the environment and in the config file", async () => { + writeServerConfig({ bind: "lan" }); + const { loadConfig } = await importFreshConfigModules(); + expect(loadConfig()).toMatchObject({ bind: "lan", host: "0.0.0.0" }); + expect(tailscaleExecCount()).toBe(0); + + vi.stubEnv("PAPERCLIP_BIND", "tailnet"); + expect(loadConfig()).toMatchObject({ bind: "tailnet", host: DETECTED_TAILNET_ADDRESS }); + + vi.stubEnv("PAPERCLIP_BIND", undefined); + expect(loadConfig()).toMatchObject({ bind: "lan", host: "0.0.0.0" }); + writeServerConfig({ bind: "tailnet" }); + expect(loadConfig()).toMatchObject({ bind: "tailnet", host: DETECTED_TAILNET_ADDRESS }); + + expect(tailscaleExecCount()).toBe(1); + }); + + it("refuses server.bind=tailnet when no tailnet address exists, and does not try tailscale again", async () => { + tailscale.installed = false; + writeServerConfig({ bind: "tailnet" }); + const { loadConfig } = await importFreshConfigModules(); + + for (let call = 0; call < 3; call += 1) { + expect(() => loadConfig()).toThrow( + "server.bind=tailnet requires a detected Tailscale address or PAPERCLIP_TAILNET_BIND_HOST", + ); + } + expect(tailscaleExecCount()).toBe(1); + + vi.stubEnv("PAPERCLIP_TAILNET_BIND_HOST", "100.64.0.9"); + expect(loadConfig()).toMatchObject({ bind: "tailnet", host: "100.64.0.9" }); + }); +}); diff --git a/server/src/config.ts b/server/src/config.ts index a30c7335142..538d6fa3703 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -125,10 +125,17 @@ export interface Config extends StorageConfig { announcementsFeedUrl: string; } +let tailscaleAddressForProcess: { address: string | undefined } | undefined; + function detectTailnetBindHost(): string | undefined { const explicit = process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim(); if (explicit) return explicit; + tailscaleAddressForProcess ??= { address: runTailscaleAddressCommand() }; + return tailscaleAddressForProcess.address; +} + +function runTailscaleAddressCommand(): string | undefined { try { const stdout = execFileSync("tailscale", ["ip", "-4"], { encoding: "utf8", @@ -203,11 +210,12 @@ export function loadConfig(override?: PaperclipConfig | null): Config { ? (bindFromEnvRaw as BindMode) : null; const configuredHost = process.env.HOST ?? fileConfig?.server.host ?? "127.0.0.1"; - const tailnetBindHost = detectTailnetBindHost(); - const bind = - bindFromEnv ?? - fileConfig?.server.bind ?? - inferBindModeFromHost(configuredHost, { tailnetBindHost }); + const configuredBind = bindFromEnv ?? fileConfig?.server.bind; + const bindReadsTailnetAddress = configuredBind + ? configuredBind === "tailnet" + : inferBindModeFromHost(configuredHost) === "custom"; + const tailnetBindHost = bindReadsTailnetAddress ? detectTailnetBindHost() : undefined; + const bind = configuredBind ?? inferBindModeFromHost(configuredHost, { tailnetBindHost }); const customBindHost = process.env.PAPERCLIP_BIND_HOST ?? fileConfig?.server.customBindHost; const authBaseUrlModeFromEnvRaw = process.env.PAPERCLIP_AUTH_BASE_URL_MODE; const authBaseUrlModeFromEnv =