From 99f965b9b4496574c40880789e32dec1a5dc6f14 Mon Sep 17 00:00:00 2001 From: Hyeoncheol Kim Date: Tue, 24 Feb 2026 18:04:17 +0900 Subject: [PATCH 1/2] feat(node): implement runtime java process manager and launcher (#108) --- README.md | 5 +- build.gradle.kts | 9 + package-lock.json | 148 ++++++ packages/memory-server/README.md | 17 +- packages/memory-server/package.json | 1 + packages/memory-server/src/index.ts | 432 +++++++++++++++++- .../memory-server/src/test/runtime.test.ts | 148 ++++++ .../memory-server/src/test/scaffold.test.ts | 4 +- .../org/jongodb/server/TcpMongoServer.java | 42 +- .../server/TcpMongoServerLauncher.java | 96 ++++ 10 files changed, 880 insertions(+), 22 deletions(-) create mode 100644 packages/memory-server/src/test/runtime.test.ts create mode 100644 src/main/java/org/jongodb/server/TcpMongoServerLauncher.java diff --git a/README.md b/README.md index 61c98ce..3f94bbb 100644 --- a/README.md +++ b/README.md @@ -111,9 +111,12 @@ Node.js adapter work is tracked in issue `#106` and lives under `packages/memory Current state: - package scaffold exists (`@jongodb/memory-server`) -- runtime process manager is in progress (`#108`) +- runtime process manager is implemented (`#108`) - Jest/Vitest helpers and compatibility smoke suites are planned (`#109`, `#110`) +Node runtime note: +- provide Java classpath via `classpath` option or `JONGODB_CLASSPATH` + ## Transaction Contract (Current) Supported in current scope: diff --git a/build.gradle.kts b/build.gradle.kts index 834cbde..43f8d14 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -322,6 +322,15 @@ tasks.register("r2CompatibilityEvidence") { ) } +tasks.register("printLauncherClasspath") { + group = "help" + description = "Prints the runtime classpath required for TcpMongoServerLauncher." + dependsOn("classes") + doLast { + println(sourceSets["main"].runtimeClasspath.asPath) + } +} + tasks.register("r2CanaryCertificationEvidence") { group = "verification" description = "Generates R2 canary certification artifacts from project canary results." diff --git a/package-lock.json b/package-lock.json index 0c7b2eb..4992c7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,15 @@ "resolved": "packages/memory-server", "link": true }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", + "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", + "dev": true, + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@types/node": { "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", @@ -22,6 +31,122 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "dev": true + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dev": true, + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "dev": true, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "dev": true + }, + "node_modules/mongodb": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "dev": true, + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "dev": true, + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dev": true, + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -41,12 +166,35 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "packages/memory-server": { "name": "@jongodb/memory-server", "version": "0.0.0-development", "license": "Apache-2.0", "devDependencies": { "@types/node": "^20.17.57", + "mongodb": "^6.21.0", "typescript": "^5.9.2" }, "engines": { diff --git a/packages/memory-server/README.md b/packages/memory-server/README.md index d4a39f9..74d599a 100644 --- a/packages/memory-server/README.md +++ b/packages/memory-server/README.md @@ -3,8 +3,21 @@ Node.js adapter package for starting a `jongodb` test server and obtaining a MongoDB URI. Current status: -- package scaffold is in place -- runtime process manager is tracked in issue `#108` +- runtime process manager is implemented +- Jest/Vitest convenience wrappers are tracked separately (`#109`) + +## Runtime Prerequisite + +The launcher requires a Java classpath that contains `jongodb` and runtime dependencies. + +- pass `classpath` in `startJongodbMemoryServer({ classpath })`, or +- set `JONGODB_CLASSPATH` environment variable. + +Repository-local helper: + +```bash +./.tooling/gradle-8.10.2/bin/gradle -q printLauncherClasspath +``` ## Local Commands diff --git a/packages/memory-server/package.json b/packages/memory-server/package.json index 0cf877b..b86106a 100644 --- a/packages/memory-server/package.json +++ b/packages/memory-server/package.json @@ -29,6 +29,7 @@ }, "devDependencies": { "@types/node": "^20.17.57", + "mongodb": "^6.21.0", "typescript": "^5.9.2" } } diff --git a/packages/memory-server/src/index.ts b/packages/memory-server/src/index.ts index 31acc6c..6b7f27c 100644 --- a/packages/memory-server/src/index.ts +++ b/packages/memory-server/src/index.ts @@ -1,9 +1,29 @@ +import { spawn } from "node:child_process"; +import { delimiter } from "node:path"; +import { createInterface } from "node:readline"; + +const READY_PREFIX = "JONGODB_URI="; +const FAILURE_PREFIX = "JONGODB_START_FAILURE="; +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_DATABASE = "test"; +const DEFAULT_STARTUP_TIMEOUT_MS = 15_000; +const DEFAULT_STOP_TIMEOUT_MS = 5_000; +const DEFAULT_LAUNCHER_CLASS = "org.jongodb.server.TcpMongoServerLauncher"; +const MAX_LOG_LINES = 50; + +type LogLevel = "silent" | "info" | "debug"; + export interface JongodbMemoryServerOptions { host?: string; port?: number; databaseName?: string; startupTimeoutMs?: number; - logLevel?: "silent" | "info" | "debug"; + stopTimeoutMs?: number; + javaPath?: string; + launcherClass?: string; + classpath?: string | string[]; + env?: Record; + logLevel?: LogLevel; } export interface JongodbMemoryServer { @@ -11,18 +31,408 @@ export interface JongodbMemoryServer { stop(): Promise; } -function notImplementedError(): Error { - return new Error( - "Runtime process manager is not implemented yet. Track progress in issue #108." - ); +interface ExitResult { + code: number | null; + signal: NodeJS.Signals | null; } -/** - * Starts a local jongodb-backed MongoDB-compatible endpoint for tests. - * Runtime process lifecycle is implemented in issue #108. - */ export async function startJongodbMemoryServer( - _options: JongodbMemoryServerOptions = {} + options: JongodbMemoryServerOptions = {} ): Promise { - throw notImplementedError(); + const startupTimeoutMs = normalizeTimeout( + options.startupTimeoutMs, + DEFAULT_STARTUP_TIMEOUT_MS, + "startupTimeoutMs" + ); + const stopTimeoutMs = normalizeTimeout( + options.stopTimeoutMs, + DEFAULT_STOP_TIMEOUT_MS, + "stopTimeoutMs" + ); + const host = normalizeHost(options.host); + const port = normalizePort(options.port); + const databaseName = normalizeDatabaseName(options.databaseName); + const launcherClass = options.launcherClass?.trim() || DEFAULT_LAUNCHER_CLASS; + const javaPath = + options.javaPath?.trim() || process.env.JONGODB_JAVA_PATH || "java"; + const classpath = resolveClasspath(options.classpath); + const logLevel = options.logLevel ?? "silent"; + + const args = [ + "-cp", + classpath, + launcherClass, + `--host=${host}`, + `--port=${port}`, + `--database=${databaseName}`, + ]; + + const child = spawn(javaPath, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + env: { + ...process.env, + ...options.env, + }, + }); + + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + let exitResult: ExitResult | null = null; + let stopped = false; + + child.on("exit", (code, signal) => { + exitResult = { code, signal }; + }); + + const stdoutReader = createInterface({ input: child.stdout }); + const stderrReader = createInterface({ input: child.stderr }); + + const startupResult = await waitForStartup({ + child, + stdoutReader, + stderrReader, + stdoutLines, + stderrLines, + startupTimeoutMs, + logLevel, + }).catch(async (error: unknown) => { + await forceStopIfAlive(child, stopTimeoutMs); + throw wrapError(error); + }); + + const stop = async (): Promise => { + if (stopped) { + return; + } + stopped = true; + + stdoutReader.close(); + stderrReader.close(); + + if (exitResult !== null || child.exitCode !== null) { + return; + } + + const terminated = child.kill("SIGTERM"); + if (!terminated) { + if (exitResult !== null || child.exitCode !== null) { + return; + } + throw new Error( + "Failed to stop jongodb server process: unable to send SIGTERM." + ); + } + + const gracefulExit = await waitForExit(child, stopTimeoutMs); + if (gracefulExit !== null) { + return; + } + + const killed = child.kill("SIGKILL"); + if (!killed) { + throw new Error( + "Failed to stop jongodb server process: SIGTERM timeout and SIGKILL failed." + ); + } + + const forcedExit = await waitForExit(child, stopTimeoutMs); + if (forcedExit === null) { + throw new Error( + "Failed to stop jongodb server process: process did not exit after SIGKILL." + ); + } + }; + + return { + uri: startupResult.uri, + stop, + }; +} + +function normalizeTimeout( + value: number | undefined, + fallback: number, + fieldName: string +): number { + const resolved = value ?? fallback; + if (!Number.isFinite(resolved) || resolved <= 0) { + throw new Error(`${fieldName} must be a positive number.`); + } + return Math.floor(resolved); +} + +function normalizeHost(host: string | undefined): string { + const normalized = host?.trim() || DEFAULT_HOST; + if (normalized.length === 0) { + throw new Error("host must not be empty."); + } + return normalized; +} + +function normalizePort(port: number | undefined): number { + const normalized = port ?? 0; + if (!Number.isInteger(normalized) || normalized < 0 || normalized > 65535) { + throw new Error("port must be an integer between 0 and 65535."); + } + return normalized; +} + +function normalizeDatabaseName(databaseName: string | undefined): string { + const normalized = databaseName?.trim() || DEFAULT_DATABASE; + if (normalized.length === 0) { + throw new Error("databaseName must not be empty."); + } + return normalized; +} + +function resolveClasspath( + classpath: string | string[] | undefined +): string { + const explicit = resolveExplicitClasspath(classpath); + if (explicit !== null) { + return explicit; + } + + const fromEnv = process.env.JONGODB_CLASSPATH?.trim(); + if (fromEnv !== undefined && fromEnv.length > 0) { + return fromEnv; + } + + throw new Error( + [ + "Jongodb Java classpath is not configured.", + "Pass options.classpath or set JONGODB_CLASSPATH.", + "Example (repo-local): ./.tooling/gradle-8.10.2/bin/gradle -q printLauncherClasspath", + ].join(" ") + ); +} + +function resolveExplicitClasspath( + classpath: string | string[] | undefined +): string | null { + if (typeof classpath === "string") { + const normalized = classpath.trim(); + if (normalized.length === 0) { + throw new Error("classpath string is empty."); + } + return normalized; + } + + if (Array.isArray(classpath)) { + if (classpath.length === 0) { + throw new Error("classpath array is empty."); + } + + const normalizedParts = classpath + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + if (normalizedParts.length === 0) { + throw new Error("classpath array has no valid entries."); + } + + return normalizedParts.join(delimiter); + } + + return null; +} + +async function waitForStartup(params: { + child: ReturnType; + stdoutReader: ReturnType; + stderrReader: ReturnType; + stdoutLines: string[]; + stderrLines: string[]; + startupTimeoutMs: number; + logLevel: LogLevel; +}): Promise<{ uri: string }> { + const { + child, + stdoutReader, + stderrReader, + stdoutLines, + stderrLines, + startupTimeoutMs, + logLevel, + } = params; + + return new Promise((resolve, reject) => { + let settled = false; + let resolvedUri: string | null = null; + + const timeout = setTimeout(() => { + if (settled) { + return; + } + settled = true; + reject( + new Error( + [ + `Timed out waiting for jongodb startup after ${startupTimeoutMs}ms.`, + formatLogTail("stdout", stdoutLines), + formatLogTail("stderr", stderrLines), + ].join(" ") + ) + ); + }, startupTimeoutMs); + + const finish = (fn: () => void) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + fn(); + }; + + const onStdout = (line: string) => { + appendLine(stdoutLines, line); + maybeLog("stdout", line, logLevel); + + if (!line.startsWith(READY_PREFIX)) { + return; + } + const uri = line.slice(READY_PREFIX.length).trim(); + if (uri.length === 0) { + finish(() => reject(new Error("Launcher emitted empty JONGODB_URI line."))); + return; + } + resolvedUri = uri; + finish(() => resolve({ uri })); + }; + + const onStderr = (line: string) => { + appendLine(stderrLines, line); + maybeLog("stderr", line, logLevel); + }; + + const onError = (error: Error) => { + finish(() => { + reject( + new Error( + [ + `Failed to spawn Java process '${child.spawnfile}': ${error.message}`, + "Check javaPath and classpath configuration.", + ].join(" ") + ) + ); + }); + }; + + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + if (resolvedUri !== null) { + return; + } + finish(() => { + reject( + new Error( + [ + `Jongodb process exited before readiness (code=${code}, signal=${signal}).`, + formatFailureLine(stderrLines), + formatLogTail("stdout", stdoutLines), + formatLogTail("stderr", stderrLines), + ].join(" ") + ) + ); + }); + }; + + stdoutReader.on("line", onStdout); + stderrReader.on("line", onStderr); + child.once("error", onError); + child.once("exit", onExit); + }); +} + +function maybeLog(stream: "stdout" | "stderr", line: string, logLevel: LogLevel) { + if (logLevel === "silent") { + return; + } + if (logLevel === "info" && stream === "stdout") { + return; + } + if (stream === "stdout") { + // eslint-disable-next-line no-console + console.log(`[jongodb:${stream}] ${line}`); + return; + } + // eslint-disable-next-line no-console + console.error(`[jongodb:${stream}] ${line}`); +} + +function appendLine(lines: string[], line: string): void { + lines.push(line); + if (lines.length > MAX_LOG_LINES) { + lines.shift(); + } +} + +function formatLogTail(name: string, lines: string[]): string { + if (lines.length === 0) { + return `${name}:`; + } + return `${name}:` + lines.join(" | "); +} + +function formatFailureLine(stderrLines: string[]): string { + const failureLine = stderrLines.find((line) => line.startsWith(FAILURE_PREFIX)); + if (failureLine === undefined) { + return ""; + } + return failureLine; +} + +async function forceStopIfAlive( + child: ReturnType, + stopTimeoutMs: number +): Promise { + if (child.exitCode !== null) { + return; + } + child.kill("SIGTERM"); + const graceful = await waitForExit(child, stopTimeoutMs); + if (graceful !== null) { + return; + } + child.kill("SIGKILL"); + await waitForExit(child, stopTimeoutMs); +} + +function waitForExit( + child: ReturnType, + timeoutMs: number +): Promise { + if (child.exitCode !== null) { + return Promise.resolve({ + code: child.exitCode, + signal: child.signalCode, + }); + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + cleanup(); + resolve(null); + }, timeoutMs); + + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + resolve({ code, signal }); + }; + + const cleanup = () => { + clearTimeout(timeout); + child.off("exit", onExit); + }; + + child.on("exit", onExit); + }); +} + +function wrapError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + return new Error(String(error)); } diff --git a/packages/memory-server/src/test/runtime.test.ts b/packages/memory-server/src/test/runtime.test.ts new file mode 100644 index 0000000..9ea977d --- /dev/null +++ b/packages/memory-server/src/test/runtime.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createServer } from "node:net"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { MongoClient } from "mongodb"; + +import { startJongodbMemoryServer } from "../index.js"; + +const classpathForRuntime = resolveTestClasspath(); + +test( + "runtime manager starts server and serves mongodb driver requests", + { concurrency: false }, + async () => { + const server = await startJongodbMemoryServer({ + classpath: classpathForRuntime, + databaseName: "node_runtime", + startupTimeoutMs: 20_000, + }); + + try { + const client = new MongoClient(server.uri); + await client.connect(); + try { + const db = client.db("node_runtime"); + const ping = await db.command({ ping: 1 }); + assert.equal(ping.ok, 1); + + await db.collection("users").insertOne({ name: "alice", age: 30 }); + const found = await db.collection("users").findOne({ name: "alice" }); + assert.equal(found?.name, "alice"); + assert.equal(found?.age, 30); + } finally { + await client.close(); + } + } finally { + await server.stop(); + } + } +); + +test( + "runtime manager supports repeated start and stop cycles", + { concurrency: false, timeout: 120_000 }, + async () => { + for (let index = 0; index < 20; index += 1) { + const server = await startJongodbMemoryServer({ + classpath: classpathForRuntime, + startupTimeoutMs: 20_000, + }); + await server.stop(); + } + } +); + +test( + "runtime manager reports actionable error for missing java binary", + { concurrency: false }, + async () => { + await assert.rejects( + async () => { + await startJongodbMemoryServer({ + classpath: classpathForRuntime, + javaPath: "java-command-not-found", + startupTimeoutMs: 3_000, + }); + }, + /spawn|java|classpath/i + ); + } +); + +test( + "runtime manager fails clearly when requested port is already in use", + { concurrency: false }, + async () => { + const blocker = createServer(); + await new Promise((resolve, reject) => { + blocker.once("error", reject); + blocker.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = blocker.address(); + if (address === null || typeof address === "string") { + throw new Error("Unable to allocate blocker TCP port for test."); + } + + try { + await assert.rejects( + async () => { + await startJongodbMemoryServer({ + classpath: classpathForRuntime, + host: "127.0.0.1", + port: address.port, + startupTimeoutMs: 5_000, + }); + }, + /port|bind|in use|failed/i + ); + } finally { + await new Promise((resolve, reject) => { + blocker.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + } +); + +function resolveTestClasspath(): string { + const fromEnv = process.env.JONGODB_TEST_CLASSPATH?.trim(); + if (fromEnv !== undefined && fromEnv.length > 0) { + return fromEnv; + } + + const currentFile = fileURLToPath(import.meta.url); + const testDir = path.dirname(currentFile); + const packageDir = path.resolve(testDir, "..", ".."); + const repoRoot = path.resolve(packageDir, "..", ".."); + const gradle = path.resolve(repoRoot, ".tooling", "gradle-8.10.2", "bin", "gradle"); + + const output = execFileSync( + gradle, + ["--no-daemon", "-q", "printLauncherClasspath"], + { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + } + ); + + const lines = output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const classpath = lines.at(-1); + if (classpath === undefined || classpath.length === 0) { + throw new Error("Failed to resolve launcher classpath from Gradle output."); + } + return classpath; +} diff --git a/packages/memory-server/src/test/scaffold.test.ts b/packages/memory-server/src/test/scaffold.test.ts index cf0fcbc..602aa03 100644 --- a/packages/memory-server/src/test/scaffold.test.ts +++ b/packages/memory-server/src/test/scaffold.test.ts @@ -3,8 +3,8 @@ import assert from "node:assert/strict"; import { startJongodbMemoryServer } from "../index.js"; -test("startJongodbMemoryServer is scaffolded and explicitly not implemented", async () => { +test("startJongodbMemoryServer fails fast when classpath is missing", async () => { await assert.rejects(async () => { await startJongodbMemoryServer(); - }, /not implemented/i); + }, /classpath/i); }); diff --git a/src/main/java/org/jongodb/server/TcpMongoServer.java b/src/main/java/org/jongodb/server/TcpMongoServer.java index 3fe1a55..d552029 100644 --- a/src/main/java/org/jongodb/server/TcpMongoServer.java +++ b/src/main/java/org/jongodb/server/TcpMongoServer.java @@ -34,6 +34,7 @@ */ public final class TcpMongoServer implements AutoCloseable { private static final String DEFAULT_HOST = "127.0.0.1"; + private static final int DEFAULT_PORT = 0; private static final int HEADER_LENGTH = 16; private static final int OP_REPLY_FIELDS_LENGTH = 20; private static final int OP_REPLY = 1; @@ -53,15 +54,29 @@ public static TcpMongoServer inMemory() { return new TcpMongoServer(); } + public static TcpMongoServer inMemory(final String host, final int port) { + return new TcpMongoServer(host, port); + } + public TcpMongoServer() { - this(new EngineBackedCommandStore(new InMemoryEngineStore())); + this(new EngineBackedCommandStore(new InMemoryEngineStore()), DEFAULT_HOST, DEFAULT_PORT); + } + + public TcpMongoServer(final String host, final int port) { + this(new EngineBackedCommandStore(new InMemoryEngineStore()), host, port); } public TcpMongoServer(final CommandStore commandStore) { + this(commandStore, DEFAULT_HOST, DEFAULT_PORT); + } + + public TcpMongoServer(final CommandStore commandStore, final String host, final int port) { Objects.requireNonNull(commandStore, "commandStore"); + final String normalizedHost = normalizeHost(host); + final int normalizedPort = normalizePort(port); this.dispatcher = new CommandDispatcher(commandStore); - this.host = DEFAULT_HOST; - this.serverSocket = newServerSocket(host); + this.host = normalizedHost; + this.serverSocket = newServerSocket(normalizedHost, normalizedPort); this.acceptThread = new Thread(this::acceptLoop, "jongodb-tcp-accept"); this.acceptThread.setDaemon(true); } @@ -106,11 +121,12 @@ public void close() { } } - private static ServerSocket newServerSocket(final String host) { + private static ServerSocket newServerSocket(final String host, final int port) { try { - return new ServerSocket(0, 50, InetAddress.getByName(host)); + return new ServerSocket(port, 50, InetAddress.getByName(host)); } catch (final IOException ioException) { - throw new IllegalStateException("failed to allocate TCP server socket", ioException); + throw new IllegalStateException( + "failed to allocate TCP server socket host=" + host + " port=" + port, ioException); } } @@ -315,4 +331,18 @@ private static String normalizeDatabase(final String database) { } return database.trim(); } + + private static String normalizeHost(final String host) { + if (host == null || host.isBlank()) { + return DEFAULT_HOST; + } + return host.trim(); + } + + private static int normalizePort(final int port) { + if (port < 0 || port > 65535) { + throw new IllegalArgumentException("port must be between 0 and 65535: " + port); + } + return port; + } } diff --git a/src/main/java/org/jongodb/server/TcpMongoServerLauncher.java b/src/main/java/org/jongodb/server/TcpMongoServerLauncher.java new file mode 100644 index 0000000..a8e0cca --- /dev/null +++ b/src/main/java/org/jongodb/server/TcpMongoServerLauncher.java @@ -0,0 +1,96 @@ +package org.jongodb.server; + +import java.util.Objects; +import java.util.concurrent.CountDownLatch; + +/** + * Command-line launcher for starting a {@link TcpMongoServer} in a dedicated JVM process. + * + *

Ready signal is emitted as a single stdout line: {@code JONGODB_URI=}. + */ +public final class TcpMongoServerLauncher { + private static final String READY_PREFIX = "JONGODB_URI="; + private static final String FAILURE_PREFIX = "JONGODB_START_FAILURE="; + + private TcpMongoServerLauncher() {} + + public static void main(final String[] args) throws Exception { + final LaunchConfig config = LaunchConfig.parse(args); + final CountDownLatch stopLatch = new CountDownLatch(1); + final TcpMongoServer server; + try { + server = TcpMongoServer.inMemory(config.host(), config.port()); + server.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + server.close(); + } finally { + stopLatch.countDown(); + } + }, "jongodb-tcp-server-shutdown")); + } catch (final Exception exception) { + System.err.println(FAILURE_PREFIX + exception.getMessage()); + exception.printStackTrace(System.err); + System.exit(1); + return; + } + + System.out.println(READY_PREFIX + server.connectionString(config.database())); + System.out.flush(); + + try { + stopLatch.await(); + } catch (final InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + } + } + + private record LaunchConfig(String host, int port, String database) { + private static LaunchConfig parse(final String[] args) { + String host = "127.0.0.1"; + int port = 0; + String database = "test"; + + for (final String arg : args) { + if (arg == null || arg.isBlank()) { + continue; + } + if (arg.startsWith("--host=")) { + host = requireValue(arg, "--host="); + continue; + } + if (arg.startsWith("--port=")) { + port = parsePort(requireValue(arg, "--port=")); + continue; + } + if (arg.startsWith("--database=")) { + database = requireValue(arg, "--database="); + continue; + } + throw new IllegalArgumentException("unsupported argument: " + arg); + } + + return new LaunchConfig(host, port, database); + } + + private static String requireValue(final String arg, final String prefix) { + final String value = Objects.requireNonNull(arg, "arg").substring(prefix.length()).trim(); + if (value.isEmpty()) { + throw new IllegalArgumentException("argument value is empty for " + prefix); + } + return value; + } + + private static int parsePort(final String value) { + try { + final int parsed = Integer.parseInt(value); + if (parsed < 0 || parsed > 65535) { + throw new IllegalArgumentException("port must be between 0 and 65535: " + parsed); + } + return parsed; + } catch (final NumberFormatException numberFormatException) { + throw new IllegalArgumentException("invalid port: " + value, numberFormatException); + } + } + } +} From 80675e61097b79081daa164cd6ee0e0d36646c09 Mon Sep 17 00:00:00 2001 From: Hyeoncheol Kim Date: Tue, 24 Feb 2026 18:05:39 +0900 Subject: [PATCH 2/2] fix(node): clean startup listeners after process readiness (#108) --- packages/memory-server/src/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/memory-server/src/index.ts b/packages/memory-server/src/index.ts index 6b7f27c..a282daa 100644 --- a/packages/memory-server/src/index.ts +++ b/packages/memory-server/src/index.ts @@ -277,12 +277,20 @@ async function waitForStartup(params: { ); }, startupTimeoutMs); + const cleanupListeners = () => { + stdoutReader.off("line", onStdout); + stderrReader.off("line", onStderr); + child.off("error", onError); + child.off("exit", onExit); + }; + const finish = (fn: () => void) => { if (settled) { return; } settled = true; clearTimeout(timeout); + cleanupListeners(); fn(); };