chore: auto-commit after stop

GSD-Unit: M001
This commit is contained in:
2026-11-01 01:13:43 +03:00
parent 8f3814946d
commit 3377f4726b
2 changed files with 204 additions and 122 deletions
+101 -122
View File
@@ -1,30 +1,13 @@
/** /**
* smoke-server.mjs — boot the packaged standalone server and prove it serves. * Boot a standalone payload, then probe its GET-only desktop API surface.
* * node scripts/smoke-server.mjs [--standalone <directory>] [--runtime <Node/Electron>] [--electron]
* The payload gate that pays for itself (2026-10-08): `next build` success * Defaults to .next/standalone and host Node; --electron retains its legacy meaning.
* and a completed electron-builder run say NOTHING about whether the * Uses an isolated empty home/data/auth environment and a per-launch API capability.
* packaged server actually starts. On its first day this smoke caught two * No real keys, provider generation, installed app UI or production data are used.
* real defects nothing else saw: (1) a trace-exclude regression that
* stripped the pi engine packages to bare package.jsons — the server
* answered 500 on every route with ERR_MODULE_NOT_FOUND; (2) before that,
* the dev auth.json (BYOK keys) shipping inside the bundle. It runs
* between build-server.mjs and electron-builder so a broken payload fails
* in seconds, not after a full packaging pass.
*
* What it does: spawns .next/standalone/server.js exactly the way the
* Electron shell does (electron/main.cjs) — with --electron it uses
* Electron's bundled Node via ELECTRON_RUN_AS_NODE, otherwise host Node —
* 127.0.0.1 on a kernel-assigned
* free port, NODE_ENV=production, a THROWAWAY LH_DATA_DIR and an EMPTY
* LH_AUTH_FILE under os.tmpdir() (no real keys or data ever touch it) —
* polls GET / until 200, then probes the GET-only API surface (graph /
* models / settings — no side effects). Any non-200, an early server
* exit, or a 30s boot timeout fails the gate and prints the server's own
* log tail for diagnosis. The child is ALWAYS killed before exit — a
* leaked server holds its port against the next run (learned the hard
* way, same day).
*/ */
import assert from "node:assert/strict";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs"; import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs";
import { verifyProviderRuntime } from "./smoke-provider-runtime.mjs"; import { verifyProviderRuntime } from "./smoke-provider-runtime.mjs";
import { verifyCompressionRuntime } from "./smoke-compression-runtime.mjs"; import { verifyCompressionRuntime } from "./smoke-compression-runtime.mjs";
@@ -34,161 +17,157 @@ import * as http from "node:http";
import * as net from "node:net"; import * as net from "node:net";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(import.meta.dirname, ".."); const ROOT = path.resolve(import.meta.dirname, "..");
const STANDALONE = path.join(ROOT, ".next", "standalone");
const SERVER = path.join(STANDALONE, "server.js");
const ELECTRON_MODE = process.argv.includes("--electron");
const RUNTIME = ELECTRON_MODE ? createRequire(import.meta.url)("electron") : process.execPath;
const POLL_MS = 500; const POLL_MS = 500;
const BOOT_TIMEOUT_MS = 30_000; const BOOT_TIMEOUT_MS = 30_000;
/** GET-only probes — the routes that exercised today's failure class. */
const PROBES = ["/api/graph", "/api/models", "/api/settings"]; const PROBES = ["/api/graph", "/api/models", "/api/settings"];
if (!fs.existsSync(SERVER)) { /** Match the provider smoke's checkout default and caller-relative explicit paths. */
console.error("[smoke-server] .next/standalone/server.js missing — run next build first."); export function resolveSmokeOptions(args = []) {
process.exit(1); let standalone = path.join(ROOT, ".next", "standalone");
let runtime;
let electron = false;
for (let i = 0; i < args.length; i++) {
const option = args[i];
if (option === "--electron") { electron = true; continue; }
assert.ok(option === "--standalone" || option === "--runtime", `Unknown option: ${option}`);
const value = args[++i];
assert.ok(value && !value.startsWith("--"), `${option} requires a path`);
if (option === "--standalone") standalone = path.resolve(value);
else runtime = path.resolve(value);
}
assert.ok(!(electron && runtime), "Use either --electron or --runtime, not both");
return {
standalone,
server: path.join(standalone, "server.js"),
runtime: runtime ?? (electron ? createRequire(import.meta.url)("electron") : process.execPath),
};
}
export function validateSmokeOptions({ standalone, server, runtime }) {
assert.ok(fs.existsSync(standalone) && fs.statSync(standalone).isDirectory(), `Standalone directory not found: ${standalone}`);
assert.ok(fs.existsSync(server) && fs.statSync(server).isFile(), `Standalone server not found: ${server} — run next build + node scripts/build-server.mjs first`);
assert.ok(fs.existsSync(runtime) && fs.statSync(runtime).isFile(), `Runtime not found: ${runtime}`);
fs.accessSync(runtime, fs.constants.X_OK);
for (const rel of ["auth.json", ".env", ".env.local", ".env.production", ".env.production.local", ".learnharness", "data"]) {
assert.ok(!fs.existsSync(path.join(standalone, rel)), `Credential/data path leaked into standalone: ${rel}`);
}
} }
/** A free TCP port (listen(0) → the kernel assigns one) — mirrors main.cjs. */
function freePort() { function freePort() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const srv = net.createServer(); const srv = net.createServer();
srv.listen(0, "127.0.0.1", () => { srv.listen(0, "127.0.0.1", () => {
const { port } = srv.address(); const { port } = srv.address();
srv.close(() => resolve(port)); srv.close((error) => error ? reject(error) : resolve(port));
}); });
srv.on("error", reject); srv.on("error", reject);
}); });
} }
/** One GET; resolves the status code (0 = connection refused/failed). */ /** Bounded GET; failed/refused connections return status 0 for boot polling. */
function get(url) { function get(url, headers = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
const req = http.get(url, (res) => { const req = http.get(url, { headers }, (res) => {
res.resume(); res.resume();
resolve(res.statusCode ?? 0); resolve(res.statusCode ?? 0);
}); });
req.on("error", () => resolve(0)); req.on("error", () => resolve(0));
req.setTimeout(5000, () => { req.setTimeout(5000, () => { req.destroy(); resolve(0); });
req.destroy();
resolve(0);
});
}); });
} }
async function main() { async function main() {
// HTTP boot alone never imports the dynamically loaded demo engine. const { standalone, server, runtime } = resolveSmokeOptions(process.argv.slice(2));
// Fail before packaging if its standalone dependency graph is incomplete. validateSmokeOptions({ standalone, server, runtime });
verifyDemoRuntime(STANDALONE, RUNTIME); console.log(`[smoke-server] standalone: ${standalone}; runtime: ${runtime}`);
// /api/models only reads catalogs; initialize the real lazy SDK offline too. // All nested gates use the SAME payload and runtime, never staging fallbacks.
verifyProviderRuntime(STANDALONE, RUNTIME); verifyDemoRuntime(standalone, runtime);
// A plain GET without Accept-Encoding never exercises gzip backpressure. verifyProviderRuntime(standalone, runtime);
verifyCompressionRuntime(STANDALONE, RUNTIME); verifyCompressionRuntime(standalone, runtime);
const port = await freePort(); const port = await freePort();
const url = `http://127.0.0.1:${port}`; const url = `http://127.0.0.1:${port}`;
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-")); const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-"));
const dataDir = path.join(home, "data"); const dataDir = path.join(home, "data");
const authFile = path.join(home, "auth.json"); const authFile = path.join(home, "auth.json");
fs.writeFileSync(authFile, "{}\n", "utf-8"); const secret = randomBytes(32).toString("hex");
const headers = { "x-learnharness-desktop": secret };
const tail = []; // rolling log tail for failure diagnosis let tail = "";
let tailBytes = 0; const keep = (buf) => { tail = (tail + buf.toString()).slice(-16_000); };
const keep = (buf) => {
for (const line of buf.toString().split("\n")) {
tail.push(line);
tailBytes += line.length + 1;
}
while (tailBytes > 16_000 && tail.length > 2) {
tailBytes -= tail[0].length + 1;
tail.shift();
}
};
const dumpTail = () => { const dumpTail = () => {
console.error("--- server log tail " + "-".repeat(20)); console.error("--- server log tail " + "-".repeat(20));
for (const line of tail.slice(-40)) if (line.trim()) console.error(` ${line}`); for (const line of tail.replaceAll(secret, "[redacted]").split("\n").slice(-40)) {
if (line.trim()) console.error(` ${line}`);
}
}; };
console.log(`[smoke-server] runtime: ${ELECTRON_MODE ? "Electron bundled Node" : "host Node"}`); let child;
const child = spawn(RUNTIME, [SERVER], {
cwd: STANDALONE,
env: {
...probeEnv(process.env, home),
...(ELECTRON_MODE ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
NODE_ENV: "production",
PORT: String(port),
HOSTNAME: "127.0.0.1",
LH_DATA_DIR: dataDir,
LH_AUTH_FILE: authFile,
},
stdio: ["ignore", "pipe", "pipe"],
});
let exited = false; let exited = false;
child.stdout.on("data", keep); let launchError;
child.stderr.on("data", keep); let failed;
child.on("exit", () => {
exited = true;
});
const kill = () =>
new Promise((resolve) => {
if (exited) return resolve();
const done = () => resolve();
child.once("exit", done);
child.kill("SIGTERM");
setTimeout(() => {
if (!exited) {
try {
child.kill("SIGKILL");
} catch {
/* already gone */
}
}
done();
}, 3000);
});
let failed = null;
try { try {
// 1) Boot: poll GET / until 200 (or timeout / early exit). fs.writeFileSync(authFile, "{}\n", { mode: 0o600 });
child = spawn(runtime, [server], {
cwd: standalone,
env: {
...probeEnv(process.env, home),
ELECTRON_RUN_AS_NODE: "1",
NODE_ENV: "production", PORT: String(port), HOSTNAME: "127.0.0.1",
LH_DATA_DIR: dataDir, LH_AUTH_FILE: authFile,
LH_DESKTOP_SERVER: "1", LH_DESKTOP_API_SECRET: secret,
},
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", keep);
child.stderr.on("data", keep);
child.on("exit", () => { exited = true; });
child.on("error", (error) => { launchError = error; });
let boot = 0; let boot = 0;
const deadline = Date.now() + BOOT_TIMEOUT_MS; const deadline = Date.now() + BOOT_TIMEOUT_MS;
while (Date.now() < deadline) { while (Date.now() < deadline) {
if (exited) throw new Error(`server exited before answering on ${url}`); if (launchError) throw launchError;
if (exited) throw new Error(`server exited before answering on ${url} (code ${child.exitCode}, signal ${child.signalCode})`);
boot = await get(url); boot = await get(url);
if (boot === 200) break; if (boot === 200) break;
await new Promise((r) => setTimeout(r, POLL_MS)); await new Promise((r) => setTimeout(r, POLL_MS));
} }
if (boot !== 200) throw new Error(`server never answered 200 on ${url} (last status: ${boot})`); if (boot !== 200) throw new Error(`server never answered 200 on ${url} (last status: ${boot})`);
console.log(`[smoke-server] boot ok: GET / → 200 (${url})`); console.log(`[smoke-server] boot ok: GET / → 200 (${url})`);
assert.equal(await get(url + "/api/models"), 403, "Desktop API must deny unauthenticated requests");
// 2) API surface: every probe must be 200. console.log("[smoke-server] unauthenticated GET /api/models → 403");
for (const route of PROBES) { for (const route of PROBES) {
const status = await get(url + route); const status = await get(url + route, headers);
if (status !== 200) throw new Error(`GET ${route} → ${status} (expected 200)`); assert.equal(status, 200, `Authenticated GET ${route} → ${status} (expected 200)`);
console.log(`[smoke-server] GET ${route} → 200`); console.log(`[smoke-server] authenticated GET ${route} → 200`);
} }
} catch (err) { } catch (err) {
failed = err; failed = err;
} finally { } finally {
await kill();
try { try {
if (child?.pid && !exited) {
await new Promise((resolve, reject) => {
const force = setTimeout(() => child.kill("SIGKILL"), 3000);
const deadline = setTimeout(() => reject(new Error(`Server cleanup timed out (pid ${child.pid})`)), 5000);
child.once("exit", () => { clearTimeout(force); clearTimeout(deadline); resolve(); });
child.kill("SIGTERM");
});
}
} finally {
fs.rmSync(home, { recursive: true, force: true }); fs.rmSync(home, { recursive: true, force: true });
} catch {
/* best effort — it's under os.tmpdir() */
} }
console.log("[smoke-server] owned server stopped; disposable home removed");
} }
if (failed) { dumpTail(); throw failed; }
if (failed) {
console.error(`[smoke-server] FAIL: ${failed.message}`);
dumpTail();
process.exit(1);
}
console.log("[smoke-server] smoke passed — the packaged server boots and serves."); console.log("[smoke-server] smoke passed — the packaged server boots and serves.");
} }
main().catch((err) => { if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
console.error(`[smoke-server] FAIL: ${err.message}`); main().catch((err) => {
process.exit(1); console.error(`[smoke-server] FAIL: ${err.stack || err}`);
}); process.exitCode = 1;
});
}
+103
View File
@@ -0,0 +1,103 @@
/** Disposable inline fixtures only: never require a built payload or workflow artifacts. */
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it } from "node:test";
import { fileURLToPath } from "node:url";
import { resolveSmokeOptions, validateSmokeOptions } from "../scripts/smoke-server.mjs";
const script = fileURLToPath(new URL("../scripts/smoke-server.mjs", import.meta.url));
const checkout = fileURLToPath(new URL("..", import.meta.url));
const run = (args: string[]) => spawnSync(process.execPath, [script, ...args], { encoding: "utf8", timeout: 10_000 });
function withFixture(test: (root: string) => void) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-server-options-test-"));
try { test(root); }
finally { fs.rmSync(root, { recursive: true, force: true }); }
}
function fails(args: string[], message: RegExp) {
const result = run(args);
assert.equal(result.error, undefined);
assert.equal(result.status, 1, result.stdout + result.stderr);
assert.match(result.stderr, message);
assert.doesNotMatch(result.stdout, /boot ok|smoke passed|import ok/);
}
describe("standalone server smoke options", () => {
it("preserves the checkout staging default and host runtime without booting on import", () => {
const options = resolveSmokeOptions();
assert.equal(options.standalone, path.join(checkout, ".next", "standalone"));
assert.equal(options.server, path.join(options.standalone, "server.js"));
assert.equal(options.runtime, process.execPath);
});
it("resolves explicit relative paths against the caller and accepts an explicit root", () => {
const relative = resolveSmokeOptions(["--standalone", "payload", "--runtime", "bin/runtime"]);
assert.equal(relative.standalone, path.resolve("payload"));
assert.equal(relative.runtime, path.resolve("bin/runtime"));
withFixture((root) => {
fs.writeFileSync(path.join(root, "server.js"), "// fixture; never booted");
const options = resolveSmokeOptions(["--runtime", process.execPath, "--standalone", root]);
assert.equal(options.server, path.join(root, "server.js"));
assert.doesNotThrow(() => validateSmokeOptions(options));
});
});
for (const option of ["--standalone", "--runtime"]) {
for (const suffix of [[], [""], ["--electron"]]) {
it(`rejects missing or malformed ${option} value ${JSON.stringify(suffix)}`, () => {
fails([option, ...suffix], new RegExp(`${option} requires a path`));
});
}
}
it("rejects unknown flags and conflicting runtime selectors", () => {
fails(["--unknown"], /Unknown option/);
fails(["--electron", "--runtime", process.execPath], /Use either --electron or --runtime/);
});
it("retains the legacy Electron runtime selector", () => {
assert.match(resolveSmokeOptions(["--electron"]).runtime, /electron/);
});
it("fails early for nonexistent and non-directory roots", () => {
withFixture((root) => {
fails(["--standalone", path.join(root, "absent")], /Standalone directory not found/);
const file = path.join(root, "file");
fs.writeFileSync(file, "fixture");
fails(["--standalone", file], /Standalone directory not found/);
});
});
it("fails early for missing or non-file server entries", () => {
withFixture((root) => {
fails(["--standalone", root], /Standalone server not found/);
fs.mkdirSync(path.join(root, "server.js"));
fails(["--standalone", root], /Standalone server not found/);
});
});
it("fails early for missing, non-file and non-executable runtimes", () => {
withFixture((root) => {
fs.writeFileSync(path.join(root, "server.js"), "// fixture");
fails(["--standalone", root, "--runtime", path.join(root, "missing")], /Runtime not found/);
fails(["--standalone", root, "--runtime", root], /Runtime not found/);
const runtime = path.join(root, "runtime");
fs.writeFileSync(runtime, "fixture", { mode: 0o600 });
fails(["--standalone", root, "--runtime", runtime], /EACCES/);
});
});
for (const relative of ["auth.json", ".env", ".env.local", ".env.production", ".env.production.local", ".learnharness", "data"]) {
it(`rejects leaked credential/data path ${relative} before dependency probes`, () => {
withFixture((root) => {
fs.writeFileSync(path.join(root, "server.js"), "// fixture");
fs.writeFileSync(path.join(root, relative), "not-a-real-credential");
fails(["--standalone", root], /Credential\/data path leaked into standalone/);
});
});
}
});