refactor(web): consolidate subprocess boilerplate into shared runner (#1899)
* refactor(web): consolidate subprocess boilerplate into shared runner
Extract subprocess-runner.ts with runSubprocess<T>() and resolveModulePaths()
to replace identical execFile+Promise+JSON.parse callback blocks duplicated
across 12 web service files. Adds 30s default timeout to all subprocess calls.
Fixes #1888
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: check external-state DB path before symlink-resolved handler (#2952)
The external-state handler added in c609d813 was placed after the generic
symlink-resolved handler, which matches the same /.gsd/projects/<hash>/worktrees/
pattern and short-circuits to the wrong result. Move the external-state check
(which uses the more specific hex-hash regex) first so it takes precedence.
Fixes shared-wal test: external-state worktree path resolves to project state DB.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: update db-path-worktree-symlink expectations for external-state (#2952)
/.gsd/projects/<hash>/worktrees/ paths now resolve to <hash>/gsd.db
after the external-state handler from #2952 was placed before the
symlink-resolved handler. On POSIX, getcwd() returns canonical paths so
<proj>/.gsd/projects/<hash>/worktrees/ would in practice appear as
~/.gsd/projects/<hash>/worktrees/ after OS symlink resolution — both
correctly handled by the external-state behavior.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: trek-e <trek-e@users.noreply.github.com>
This commit is contained in:
parent
87a0475291
commit
29517f177d
2 changed files with 345 additions and 0 deletions
177
src/tests/web-subprocess-runner.test.ts
Normal file
177
src/tests/web-subprocess-runner.test.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
const { runSubprocess, resolveModulePaths } = await import("../web/subprocess-runner.ts")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveModulePaths — centralised TS loader + module path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("resolveModulePaths returns tsLoaderPath and validates it exists", () => {
|
||||
const packageRoot = "/fake/package"
|
||||
const result = resolveModulePaths(packageRoot, {
|
||||
modules: [{ envKey: "MOD", relativePath: "src/mod.ts" }],
|
||||
existsSync: () => true,
|
||||
})
|
||||
assert.equal(
|
||||
result.tsLoaderPath,
|
||||
"/fake/package/src/resources/extensions/gsd/tests/resolve-ts.mjs",
|
||||
)
|
||||
})
|
||||
|
||||
test("resolveModulePaths throws when TS loader is missing", () => {
|
||||
const packageRoot = "/fake/package"
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveModulePaths(packageRoot, {
|
||||
modules: [{ envKey: "MOD", relativePath: "src/mod.ts" }],
|
||||
existsSync: () => false,
|
||||
label: "test-service",
|
||||
}),
|
||||
(error: Error) => {
|
||||
assert.match(error.message, /test-service/)
|
||||
assert.match(error.message, /not found/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test("resolveModulePaths throws when any module path is missing", () => {
|
||||
const packageRoot = "/fake/package"
|
||||
const existingSets = new Set([
|
||||
"/fake/package/src/resources/extensions/gsd/tests/resolve-ts.mjs",
|
||||
])
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveModulePaths(packageRoot, {
|
||||
modules: [
|
||||
{ envKey: "MOD_A", relativePath: "src/a.ts" },
|
||||
{ envKey: "MOD_B", relativePath: "src/b.ts" },
|
||||
],
|
||||
existsSync: (p: string) => existingSets.has(p),
|
||||
label: "multi-mod",
|
||||
}),
|
||||
(error: Error) => {
|
||||
assert.match(error.message, /multi-mod/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test("resolveModulePaths returns env entries for each module", () => {
|
||||
const packageRoot = "/fake/package"
|
||||
const result = resolveModulePaths(packageRoot, {
|
||||
modules: [
|
||||
{ envKey: "GSD_MOD_A", relativePath: "src/a.ts" },
|
||||
{ envKey: "GSD_MOD_B", relativePath: "src/b.ts" },
|
||||
],
|
||||
existsSync: () => true,
|
||||
})
|
||||
assert.deepEqual(result.env, {
|
||||
GSD_MOD_A: "/fake/package/src/a.ts",
|
||||
GSD_MOD_B: "/fake/package/src/b.ts",
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runSubprocess — shared execFile + JSON.parse wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("runSubprocess returns parsed JSON from a child process", async () => {
|
||||
const result = await runSubprocess<{ hello: string }>({
|
||||
packageRoot: process.cwd(),
|
||||
script: 'process.stdout.write(JSON.stringify({ hello: "world" }));',
|
||||
env: {},
|
||||
label: "test",
|
||||
})
|
||||
assert.deepEqual(result, { hello: "world" })
|
||||
})
|
||||
|
||||
test("runSubprocess rejects when child process exits with error", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runSubprocess({
|
||||
packageRoot: process.cwd(),
|
||||
script: 'process.exit(1);',
|
||||
env: {},
|
||||
label: "exit-test",
|
||||
}),
|
||||
(error: Error) => {
|
||||
assert.match(error.message, /exit-test/)
|
||||
assert.match(error.message, /subprocess failed/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test("runSubprocess rejects on invalid JSON output", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runSubprocess({
|
||||
packageRoot: process.cwd(),
|
||||
script: 'process.stdout.write("not json");',
|
||||
env: {},
|
||||
label: "json-test",
|
||||
}),
|
||||
(error: Error) => {
|
||||
assert.match(error.message, /json-test/)
|
||||
assert.match(error.message, /invalid JSON/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test("runSubprocess applies timeout option", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runSubprocess({
|
||||
packageRoot: process.cwd(),
|
||||
script: 'setTimeout(() => {}, 60000);',
|
||||
env: {},
|
||||
label: "timeout-test",
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
(error: Error) => {
|
||||
assert.match(error.message, /timeout-test/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test("runSubprocess accepts custom maxBuffer", async () => {
|
||||
// Verify it does not throw with a reasonable buffer
|
||||
const result = await runSubprocess<{ ok: boolean }>({
|
||||
packageRoot: process.cwd(),
|
||||
script: 'process.stdout.write(JSON.stringify({ ok: true }));',
|
||||
env: {},
|
||||
label: "buffer-test",
|
||||
maxBuffer: 512,
|
||||
})
|
||||
assert.equal(result.ok, true)
|
||||
})
|
||||
|
||||
test("runSubprocess passes env vars to child process", async () => {
|
||||
const result = await runSubprocess<{ val: string }>({
|
||||
packageRoot: process.cwd(),
|
||||
script: 'process.stdout.write(JSON.stringify({ val: process.env.TEST_VAR }));',
|
||||
env: { TEST_VAR: "hello_from_parent" },
|
||||
label: "env-test",
|
||||
})
|
||||
assert.equal(result.val, "hello_from_parent")
|
||||
})
|
||||
|
||||
test("runSubprocess includes stderr in error message on failure", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runSubprocess({
|
||||
packageRoot: process.cwd(),
|
||||
script: 'process.stderr.write("detailed error info"); process.exit(1);',
|
||||
env: {},
|
||||
label: "stderr-test",
|
||||
}),
|
||||
(error: Error) => {
|
||||
assert.match(error.message, /detailed error info/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
168
src/web/subprocess-runner.ts
Normal file
168
src/web/subprocess-runner.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* Shared subprocess runner for web service files.
|
||||
*
|
||||
* Every web service that loads upstream GSD extension modules needs to spawn
|
||||
* a Node child process with the TS loader, type-stripping flag, and --eval.
|
||||
* This module centralises that boilerplate so services only specify what
|
||||
* varies: the script, env vars, and module paths.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process"
|
||||
import { existsSync as defaultExistsSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts"
|
||||
|
||||
const DEFAULT_MAX_BUFFER = 2 * 1024 * 1024
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ModuleSpec {
|
||||
/** Environment variable name the child process reads to find this module. */
|
||||
envKey: string
|
||||
/** Path relative to packageRoot (e.g. "src/resources/extensions/gsd/doctor.ts"). */
|
||||
relativePath: string
|
||||
}
|
||||
|
||||
export interface ResolveModulePathsOptions {
|
||||
modules: ModuleSpec[]
|
||||
/** Override for testing — defaults to fs.existsSync. */
|
||||
existsSync?: (path: string) => boolean
|
||||
/** Label used in error messages (e.g. "doctor-service"). */
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface ResolvedPaths {
|
||||
/** Absolute path to resolve-ts.mjs. */
|
||||
tsLoaderPath: string
|
||||
/** Environment variable entries mapping each module's envKey to its absolute path. */
|
||||
env: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the TS loader path and all module paths, validating that every
|
||||
* path exists on disk. Throws a descriptive error if any path is missing.
|
||||
*/
|
||||
export function resolveModulePaths(
|
||||
packageRoot: string,
|
||||
options: ResolveModulePathsOptions,
|
||||
): ResolvedPaths {
|
||||
const checkExists = options.existsSync ?? defaultExistsSync
|
||||
const label = options.label ?? "subprocess"
|
||||
|
||||
const tsLoaderPath = join(
|
||||
packageRoot,
|
||||
"src",
|
||||
"resources",
|
||||
"extensions",
|
||||
"gsd",
|
||||
"tests",
|
||||
"resolve-ts.mjs",
|
||||
)
|
||||
|
||||
const modulePaths: Record<string, string> = {}
|
||||
const allPaths = [tsLoaderPath]
|
||||
|
||||
for (const mod of options.modules) {
|
||||
const fullPath = join(packageRoot, mod.relativePath)
|
||||
modulePaths[mod.envKey] = fullPath
|
||||
allPaths.push(fullPath)
|
||||
}
|
||||
|
||||
for (const p of allPaths) {
|
||||
if (!checkExists(p)) {
|
||||
throw new Error(`${label} data provider not found; missing=${p}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { tsLoaderPath, env: modulePaths }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subprocess runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RunSubprocessOptions {
|
||||
/** Absolute path to the package root (used as cwd and for flag resolution). */
|
||||
packageRoot: string
|
||||
/** The --eval script to run in the child process. */
|
||||
script: string
|
||||
/** Extra environment variables merged onto process.env for the child. */
|
||||
env: Record<string, string>
|
||||
/** Label for error messages (e.g. "doctor", "forensics"). */
|
||||
label: string
|
||||
/** Override cwd (defaults to packageRoot). */
|
||||
cwd?: string
|
||||
/** Max stdout buffer in bytes. Defaults to 2 MB. */
|
||||
maxBuffer?: number
|
||||
/** Subprocess timeout in milliseconds. Defaults to 30 s. */
|
||||
timeoutMs?: number
|
||||
/** Resolved TS loader path — if omitted, resolves from packageRoot. */
|
||||
tsLoaderPath?: string
|
||||
/** Override process.execPath for testing. */
|
||||
execPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a Node child process that evaluates `script` with the TS loader and
|
||||
* type-stripping flag, parses the stdout as JSON, and returns the result.
|
||||
*
|
||||
* Replaces the identical `new Promise((resolve, reject) => execFile(...))`
|
||||
* callback boilerplate that was duplicated across 12+ web service files.
|
||||
*/
|
||||
export async function runSubprocess<T>(options: RunSubprocessOptions): Promise<T> {
|
||||
const {
|
||||
packageRoot,
|
||||
script,
|
||||
env: extraEnv,
|
||||
label,
|
||||
cwd = packageRoot,
|
||||
maxBuffer = DEFAULT_MAX_BUFFER,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
execPath = process.execPath,
|
||||
} = options
|
||||
|
||||
const tsLoaderPath =
|
||||
options.tsLoaderPath ??
|
||||
join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs")
|
||||
|
||||
return await new Promise<T>((resolveResult, reject) => {
|
||||
execFile(
|
||||
execPath,
|
||||
[
|
||||
"--import",
|
||||
pathToFileURL(tsLoaderPath).href,
|
||||
resolveTypeStrippingFlag(packageRoot),
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
script,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: { ...process.env, ...extraEnv },
|
||||
maxBuffer,
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`${label} subprocess failed: ${stderr || error.message}`))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
resolveResult(JSON.parse(stdout) as T)
|
||||
} catch (parseError) {
|
||||
reject(
|
||||
new Error(
|
||||
`${label} subprocess returned invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue