Skip to content

Commit 0fbd174

Browse files
committed
refactor(@probitas/probitas): extract subprocess communication patterns
Reduces code duplication by extracting common IPC patterns into reusable factories and helpers across parent and subprocess sides. ## Subprocess side (_templates/utils.ts) - Add runSubprocess() factory for standardized bootstrap lifecycle - Add createOutputValidator() for type-safe protocol validation - Add serializeError/deserializeError for unified error handling - Extract ~90 lines of duplicated bootstrap code from list.ts and run.ts ## Parent side (subprocess.ts) - Add startSubprocess() for connection setup and stream creation - Add runSubprocessToCompletion() for simple request-response pattern - Add cleanupSubprocess() for proper resource cleanup ordering - Consolidate ~100 lines of duplicated spawn/connect/cleanup logic ## Protocol refactoring - Migrate list_protocol.ts and run_protocol.ts to use shared validators - Re-export error utilities from utils.ts for consistency - Document architectural asymmetry between parent and subprocess ## Testing - Add utils_test.ts covering all new utility functions - Verify error serialization, port parsing, and validator logic Total: ~190 lines of duplication eliminated, improved maintainability.
1 parent 2ac1038 commit 0fbd174

13 files changed

Lines changed: 1440 additions & 982 deletions

File tree

deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"@probitas/reporter": "jsr:@probitas/reporter@^0.7.0",
9090
"@probitas/runner": "jsr:@probitas/runner@^0.5.0",
9191
"@std/assert": "jsr:@std/assert@^1.0.16",
92+
"@std/cbor": "jsr:@std/cbor@^0.1.9",
9293
"@std/cli": "jsr:@std/cli@^1.0.25",
9394
"@std/collections": "jsr:@std/collections@^1.1.3",
9495
"@std/dotenv": "jsr:@std/dotenv@^0.225.6",

deno.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli/_templates/list.ts

Lines changed: 33 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -2,88 +2,45 @@
22
* Subprocess entry point for list command
33
*
44
* Loads scenarios and outputs metadata via TCP IPC.
5-
* Communication is via NDJSON over TCP (not stdin/stdout).
5+
* Communication is via CBOR over TCP (not stdin/stdout).
66
*
77
* @module
88
* @internal
99
*/
1010

1111
import { loadScenarios } from "@probitas/core/loader";
1212
import { applySelectors } from "@probitas/core/selector";
13-
import { toErrorObject } from "@core/errorutil/error-object";
1413
import type { ListInput, ListOutput, ScenarioMeta } from "./list_protocol.ts";
15-
import {
16-
closeIpc,
17-
connectIpc,
18-
parseIpcPort,
19-
readInput,
20-
writeOutput,
21-
} from "./utils.ts";
22-
23-
/**
24-
* Main entry point
25-
*/
26-
async function main(): Promise<void> {
27-
// Parse IPC port from command line arguments
28-
const port = parseIpcPort(Deno.args);
29-
30-
// Connect to parent process IPC server
31-
const ipc = await connectIpc(port);
32-
33-
try {
34-
// Read input from IPC (TCP connection establishes readiness)
35-
const input = await readInput(ipc) as ListInput;
36-
37-
// Load scenarios from files
38-
const scenarios = await loadScenarios(input.filePaths, {
39-
onImportError: (file, err) => {
40-
const m = err instanceof Error ? err.message : String(err);
41-
throw new Error(`Failed to load scenario from ${file}: ${m}`);
42-
},
43-
});
44-
45-
// Apply selectors to filter scenarios
46-
const filtered = input.selectors.length > 0
47-
? applySelectors(scenarios, input.selectors)
48-
: scenarios;
49-
50-
// Build output metadata
51-
const scenarioMetas: ScenarioMeta[] = filtered.map((s) => ({
52-
name: s.name,
53-
tags: s.tags,
54-
steps: s.steps.filter((e) => e.kind === "step").length,
55-
file: s.origin?.path || "unknown",
56-
}));
57-
58-
await writeOutput(
59-
ipc,
60-
{
61-
type: "result",
62-
scenarios: scenarioMetas,
63-
} satisfies ListOutput,
64-
);
65-
} catch (error) {
66-
const err = error instanceof Error ? error : new Error(String(error));
67-
try {
68-
await writeOutput(
69-
ipc,
70-
{
71-
type: "error",
72-
error: toErrorObject(err),
73-
} satisfies ListOutput,
74-
);
75-
} catch {
76-
// Failed to write error to IPC, log to console as fallback
77-
console.error("Subprocess error:", error);
78-
}
79-
} finally {
80-
// Await close to ensure all pending writes are flushed
81-
await closeIpc(ipc);
82-
}
83-
}
84-
85-
// Run main and exit explicitly to avoid async operations keeping process alive
86-
main().finally(() => {
87-
// Ensure process exits after output is flushed
88-
setTimeout(() => Deno.exit(0), 0);
14+
import { runSubprocess, writeOutput } from "./utils.ts";
15+
16+
// Run subprocess with bootstrap handling
17+
runSubprocess<ListInput>(async (ipc, input) => {
18+
// Load scenarios from files
19+
const scenarios = await loadScenarios(input.filePaths, {
20+
onImportError: (file, err) => {
21+
const m = err instanceof Error ? err.message : String(err);
22+
throw new Error(`Failed to load scenario from ${file}: ${m}`);
23+
},
24+
});
25+
26+
// Apply selectors to filter scenarios
27+
const filtered = input.selectors.length > 0
28+
? applySelectors(scenarios, input.selectors)
29+
: scenarios;
30+
31+
// Build output metadata
32+
const scenarioMetas: ScenarioMeta[] = filtered.map((s) => ({
33+
name: s.name,
34+
tags: s.tags,
35+
steps: s.steps.filter((e) => e.kind === "step").length,
36+
file: s.origin?.path || "unknown",
37+
}));
38+
39+
await writeOutput(
40+
ipc,
41+
{
42+
type: "result",
43+
scenarios: scenarioMetas,
44+
} satisfies ListOutput,
45+
);
8946
});

src/cli/_templates/list_protocol.ts

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import type { ErrorObject } from "@core/errorutil/error-object";
8+
import { createOutputValidator } from "./utils.ts";
89

910
/**
1011
* Input sent to subprocess via IPC
@@ -51,20 +52,10 @@ export interface ScenarioMeta {
5152
readonly file: string;
5253
}
5354

54-
/**
55-
* Valid ListOutput type values
56-
*/
57-
const LIST_OUTPUT_TYPES = new Set(["result", "error"]);
58-
5955
/**
6056
* Type guard to check if a value is a valid ListOutput
6157
*/
62-
export function isListOutput(value: unknown): value is ListOutput {
63-
return (
64-
value !== null &&
65-
typeof value === "object" &&
66-
"type" in value &&
67-
typeof value.type === "string" &&
68-
LIST_OUTPUT_TYPES.has(value.type)
69-
);
70-
}
58+
export const isListOutput = createOutputValidator<ListOutput>([
59+
"result",
60+
"error",
61+
]);

src/cli/_templates/run.ts

Lines changed: 7 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Subprocess entry point for run command
33
*
44
* Executes scenarios and streams progress via TCP IPC.
5-
* Communication is via NDJSON over TCP (not stdin/stdout).
5+
* Communication is via CBOR over TCP (not stdin/stdout).
66
*
77
* @module
88
* @internal
@@ -19,15 +19,7 @@ import {
1919
serializeError,
2020
serializeRunResult,
2121
} from "./run_protocol.ts";
22-
import {
23-
closeIpc,
24-
configureLogging,
25-
connectIpc,
26-
type IpcConnection,
27-
parseIpcPort,
28-
readInput,
29-
writeOutput,
30-
} from "./utils.ts";
22+
import { configureLogging, runSubprocess, writeOutput } from "./utils.ts";
3123

3224
const logger = getLogger(["probitas", "cli", "run", "subprocess"]);
3325

@@ -60,11 +52,12 @@ globalThis.addEventListener(
6052

6153
/**
6254
* Execute all scenarios
55+
*
56+
* This handler manages its own error handling because it needs to:
57+
* 1. Clean up the abort controller on error
58+
* 2. Write structured error output for scenario execution failures
6359
*/
64-
async function runScenarios(
65-
ipc: IpcConnection,
66-
input: RunCommandInput,
67-
): Promise<void> {
60+
runSubprocess<RunCommandInput>(async (ipc, input) => {
6861
const {
6962
filePaths,
7063
selectors,
@@ -142,44 +135,4 @@ async function runScenarios(
142135
} finally {
143136
globalAbortController = null;
144137
}
145-
}
146-
147-
/**
148-
* Main entry point
149-
*/
150-
async function main(): Promise<void> {
151-
// Parse IPC port from command line arguments
152-
const port = parseIpcPort(Deno.args);
153-
154-
// Connect to parent process IPC server
155-
const ipc = await connectIpc(port);
156-
157-
try {
158-
// Read input from IPC (TCP connection establishes readiness)
159-
const input = await readInput(ipc) as RunCommandInput;
160-
161-
await runScenarios(ipc, input);
162-
} catch (error) {
163-
try {
164-
await writeOutput(
165-
ipc,
166-
{
167-
type: "error",
168-
error: serializeError(error),
169-
} satisfies RunOutput,
170-
);
171-
} catch {
172-
// Failed to write error to IPC, log to console as fallback
173-
console.error("Subprocess error:", error);
174-
}
175-
} finally {
176-
// Await close to ensure all pending writes are flushed
177-
await closeIpc(ipc);
178-
}
179-
}
180-
181-
// Run main and exit explicitly to avoid LogTape keeping process alive
182-
main().finally(() => {
183-
// Ensure process exits after output is flushed
184-
setTimeout(() => Deno.exit(0), 0);
185138
});

0 commit comments

Comments
 (0)