@ttsc/graph 0.19.3 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/lib/TtscGraphApplication.js +3 -1
  2. package/lib/TtscGraphApplication.js.map +1 -1
  3. package/lib/bin.js +15 -3
  4. package/lib/bin.js.map +1 -1
  5. package/lib/index.d.ts +13 -2
  6. package/lib/index.js +24 -30
  7. package/lib/index.js.map +1 -1
  8. package/lib/launcherArgs.d.ts +24 -0
  9. package/lib/launcherArgs.js +136 -0
  10. package/lib/launcherArgs.js.map +1 -0
  11. package/lib/model/TtscGraphMemory.d.ts +9 -5
  12. package/lib/model/TtscGraphMemory.js +35 -58
  13. package/lib/model/TtscGraphMemory.js.map +1 -1
  14. package/lib/model/TtscGraphNodeId.d.ts +17 -0
  15. package/lib/model/TtscGraphNodeId.js +69 -0
  16. package/lib/model/TtscGraphNodeId.js.map +1 -0
  17. package/lib/model/TtscGraphSession.d.ts +23 -7
  18. package/lib/model/TtscGraphSession.js +402 -210
  19. package/lib/model/TtscGraphSession.js.map +1 -1
  20. package/lib/model/TtscGraphSourceReader.d.ts +27 -0
  21. package/lib/model/TtscGraphSourceReader.js +97 -0
  22. package/lib/model/TtscGraphSourceReader.js.map +1 -0
  23. package/lib/model/loadGraph.d.ts +4 -4
  24. package/lib/model/loadGraph.js +233 -168
  25. package/lib/model/loadGraph.js.map +1 -1
  26. package/lib/reduce.d.ts +4 -1
  27. package/lib/reduce.js +112 -29
  28. package/lib/reduce.js.map +1 -1
  29. package/lib/server/createServer.js +17 -6
  30. package/lib/server/createServer.js.map +1 -1
  31. package/lib/server/pathPolicy.d.ts +6 -0
  32. package/lib/server/pathPolicy.js +10 -1
  33. package/lib/server/pathPolicy.js.map +1 -1
  34. package/lib/server/resolveHandle.js +22 -18
  35. package/lib/server/resolveHandle.js.map +1 -1
  36. package/lib/server/resultAudit.d.ts +41 -20
  37. package/lib/server/resultAudit.js +62 -34
  38. package/lib/server/resultAudit.js.map +1 -1
  39. package/lib/server/resultNext.d.ts +5 -0
  40. package/lib/server/resultNext.js.map +1 -1
  41. package/lib/server/runDetails.d.ts +2 -2
  42. package/lib/server/runDetails.js +54 -108
  43. package/lib/server/runDetails.js.map +1 -1
  44. package/lib/server/runEntrypoints.js +1 -1
  45. package/lib/server/runEntrypoints.js.map +1 -1
  46. package/lib/server/runLookup.js +1 -1
  47. package/lib/server/runLookup.js.map +1 -1
  48. package/lib/server/runTour.js +113 -30
  49. package/lib/server/runTour.js.map +1 -1
  50. package/lib/server/runTrace.d.ts +22 -0
  51. package/lib/server/runTrace.js +293 -63
  52. package/lib/server/runTrace.js.map +1 -1
  53. package/lib/structures/ITtscGraphDump.d.ts +13 -13
  54. package/lib/structures/ITtscGraphNode.d.ts +34 -0
  55. package/lib/structures/ITtscGraphTrace.d.ts +1 -1
  56. package/lib/view.js +18 -28
  57. package/lib/view.js.map +1 -1
  58. package/package.json +5 -5
  59. package/src/TtscGraphApplication.ts +5 -1
  60. package/src/bin.ts +13 -3
  61. package/src/index.ts +34 -27
  62. package/src/launcherArgs.ts +168 -0
  63. package/src/model/TtscGraphMemory.ts +34 -60
  64. package/src/model/TtscGraphNodeId.ts +77 -0
  65. package/src/model/TtscGraphSession.ts +228 -55
  66. package/src/model/TtscGraphSourceReader.ts +117 -0
  67. package/src/model/loadGraph.ts +4 -4
  68. package/src/reduce.ts +136 -31
  69. package/src/server/createServer.ts +12 -2
  70. package/src/server/pathPolicy.ts +10 -1
  71. package/src/server/resolveHandle.ts +21 -22
  72. package/src/server/resultAudit.ts +64 -33
  73. package/src/server/resultNext.ts +6 -0
  74. package/src/server/runDetails.ts +57 -113
  75. package/src/server/runEntrypoints.ts +1 -1
  76. package/src/server/runLookup.ts +1 -1
  77. package/src/server/runTour.ts +133 -39
  78. package/src/server/runTrace.ts +401 -65
  79. package/src/structures/ITtscGraphDump.ts +13 -13
  80. package/src/structures/ITtscGraphNode.ts +40 -0
  81. package/src/structures/ITtscGraphTrace.ts +1 -1
  82. package/src/view.ts +25 -23
@@ -4,16 +4,19 @@ import { ITtscGraphEvidence } from "../structures/ITtscGraphEvidence";
4
4
  import { ITtscGraphNode } from "../structures/ITtscGraphNode";
5
5
  import { ITtscGraphSpan } from "../structures/ITtscGraphSpan";
6
6
  import { TtscGraphEdgeKind } from "../structures/TtscGraphEdgeKind";
7
+ import { ttscGraphNodeIdPath } from "./TtscGraphNodeId";
8
+ import { TtscGraphSourceReader } from "./TtscGraphSourceReader";
7
9
 
8
10
  /**
9
11
  * The in-memory resident graph the MCP tools answer from.
10
12
  *
11
13
  * It loads one `ttscgraph dump` — the checker-resolved fact graph — then
12
14
  * synthesizes the structural relationships the dump deliberately leaves to this
13
- * layer: `file` container nodes, the `contains` ownership tree, and `exports`
14
- * edges, plus class/interface member implementation edges and the refinement of
15
- * a class-member `variable` to a `property`. Every tool call is then a lookup
16
- * or a traversal over the indexes built here; nothing recompiles.
15
+ * layer: `file` container nodes and the `contains` ownership tree, plus the
16
+ * refinement of a class-member `variable` to a `property`. Export and member
17
+ * implementation relationships are checker facts already present in the dump.
18
+ * Every tool call is then a lookup or traversal over the indexes built here;
19
+ * nothing recompiles.
17
20
  */
18
21
  export class TtscGraphMemory {
19
22
  private readonly byId: Map<string, ITtscGraphNode>;
@@ -26,17 +29,21 @@ export class TtscGraphMemory {
26
29
  readonly project: string;
27
30
  /** Every node, raw plus synthesized (file containers). */
28
31
  readonly nodes: readonly ITtscGraphNode[];
29
- /** Every edge, raw plus synthesized (contains, exports). */
32
+ /** Every edge, raw plus synthesized containment. */
30
33
  readonly edges: readonly ITtscGraphEdge[];
34
+ /** Provenance-gated source display facts cached for this exact snapshot. */
35
+ readonly source: TtscGraphSourceReader;
31
36
 
32
37
  private constructor(
33
38
  project: string,
34
39
  nodes: ITtscGraphNode[],
35
40
  edges: ITtscGraphEdge[],
41
+ provenance: ITtscGraphDump.IProvenance,
36
42
  ) {
37
43
  this.project = project;
38
44
  this.nodes = nodes;
39
45
  this.edges = edges;
46
+ this.source = new TtscGraphSourceReader(project, provenance);
40
47
 
41
48
  this.byId = new Map(nodes.map((n) => [n.id, n]));
42
49
  this.byNameIndex = new Map();
@@ -63,7 +70,7 @@ export class TtscGraphMemory {
63
70
  /** Build a model from a parsed dump, synthesizing structural relationships. */
64
71
  static from(dump: ITtscGraphDump): TtscGraphMemory {
65
72
  const { nodes, edges } = synthesize(dump);
66
- return new TtscGraphMemory(dump.project, nodes, edges);
73
+ return new TtscGraphMemory(dump.project, nodes, edges, dump.provenance);
67
74
  }
68
75
 
69
76
  /** The node with this id, or undefined. */
@@ -113,13 +120,23 @@ function keyOf(node: ITtscGraphNode): string {
113
120
  return node.qualifiedName ?? node.name;
114
121
  }
115
122
 
116
- /** The owner key of a dotted key (`A.B.c` -> `A.B`), or "" for a top-level key. */
117
- function ownerKey(key: string): string {
118
- const dot = key.lastIndexOf(".");
119
- return dot >= 0 ? key.slice(0, dot) : "";
123
+ /**
124
+ * The owner key derived from facts the producer serialized separately.
125
+ *
126
+ * A quoted member named `"a.b"` has Name `a.b` and QualifiedName `Box.a.b`.
127
+ * Cutting the qualified name at its last dot invents owner `Box.a`; removing
128
+ * the exact `.${name}` suffix instead preserves the producer's real boundary.
129
+ */
130
+ function ownerKey(node: ITtscGraphNode): string | undefined {
131
+ if (node.qualifiedName === undefined || node.qualifiedName === node.name)
132
+ return undefined;
133
+ const suffix = `.${node.name}`;
134
+ if (!node.qualifiedName.endsWith(suffix)) return undefined;
135
+ const owner = node.qualifiedName.slice(0, -suffix.length);
136
+ return owner === "" ? undefined : owner;
120
137
  }
121
138
 
122
- /** A file's id and node name from its project-relative path. */
139
+ /** A file's id and node name from its schema-v6 path coordinate. */
123
140
  function fileNodeId(file: string): string {
124
141
  return file;
125
142
  }
@@ -138,8 +155,7 @@ function spanIn(span: ITtscGraphSpan, file: string): ITtscGraphEvidence {
138
155
  * file node's id is the path itself.
139
156
  */
140
157
  function fileOfNodeId(id: string): string {
141
- const hash = id.indexOf("#");
142
- return hash === -1 ? id : id.slice(0, hash);
158
+ return ttscGraphNodeIdPath(id) ?? id;
143
159
  }
144
160
 
145
161
  function basename(file: string): string {
@@ -149,8 +165,8 @@ function basename(file: string): string {
149
165
 
150
166
  /**
151
167
  * Derive the structural layer from a dump's faithful facts: refine class-member
152
- * variables to properties, add a `file` node per workspace source, and connect
153
- * the `contains` ownership tree and `exports` surface.
168
+ * variables to properties, add a `file` node per workspace source, connect the
169
+ * `contains` ownership tree, and re-anchor compiler-owned `exports` edges.
154
170
  */
155
171
  function synthesize(dump: ITtscGraphDump): {
156
172
  nodes: ITtscGraphNode[];
@@ -207,8 +223,8 @@ function synthesize(dump: ITtscGraphDump): {
207
223
  if (!node.external) byFileKey.set(node.file + "\0" + keyOf(node), node);
208
224
  }
209
225
  const owner = (node: ITtscGraphNode): ITtscGraphNode | undefined => {
210
- const parent = ownerKey(keyOf(node));
211
- if (parent === "") return undefined;
226
+ const parent = ownerKey(node);
227
+ if (parent === undefined) return undefined;
212
228
  return byFileKey.get(node.file + "\0" + parent);
213
229
  };
214
230
 
@@ -250,11 +266,9 @@ function synthesize(dump: ITtscGraphDump): {
250
266
  // public, which is the fact that cannot tell a package's front door from its
251
267
  // legacy subpath.
252
268
  const structural: ITtscGraphEdge[] = [];
253
- const membersByOwner = new Map<string, ITtscGraphNode[]>();
254
269
  for (const node of nodes) {
255
270
  if (node.external || node.file === "") continue;
256
271
  const parent = owner(node);
257
- if (parent !== undefined) push(membersByOwner, parent.id, node);
258
272
  const container = parent ? parent.id : fileNodeId(node.file);
259
273
  structural.push({
260
274
  from: container,
@@ -263,48 +277,8 @@ function synthesize(dump: ITtscGraphDump): {
263
277
  });
264
278
  }
265
279
 
266
- const synthesized = [...edges, ...structural];
267
- const edgeKeys = new Set(
268
- synthesized.map((edge) => `${edge.kind}\0${edge.from}\0${edge.to}`),
269
- );
270
- const byId = new Map(nodes.map((node) => [node.id, node]));
271
- for (const edge of edges) {
272
- const kind: TtscGraphEdgeKind | undefined =
273
- edge.kind === "implements"
274
- ? "implements"
275
- : edge.kind === "extends"
276
- ? "overrides"
277
- : undefined;
278
- if (kind === undefined) continue;
279
- const derived = byId.get(edge.from);
280
- const base = byId.get(edge.to);
281
- if (derived === undefined || base === undefined) continue;
282
- const derivedMembers = membersByOwner.get(derived.id) ?? [];
283
- const baseMembers = membersByOwner.get(base.id) ?? [];
284
- for (const baseMember of baseMembers) {
285
- const derivedMember = derivedMembers.find(
286
- (member) =>
287
- member.name === baseMember.name &&
288
- implementationMemberKinds.has(member.kind) &&
289
- implementationMemberKinds.has(baseMember.kind),
290
- );
291
- if (derivedMember === undefined) continue;
292
- const key = `${kind}\0${derivedMember.id}\0${baseMember.id}`;
293
- if (edgeKeys.has(key)) continue;
294
- edgeKeys.add(key);
295
- synthesized.push({
296
- from: derivedMember.id,
297
- to: baseMember.id,
298
- kind,
299
- evidence: derivedMember.implementation ?? derivedMember.evidence,
300
- });
301
- }
302
- }
303
-
304
280
  return {
305
281
  nodes: [...nodes, ...fileNodes.values()],
306
- edges: synthesized,
282
+ edges: [...edges, ...structural],
307
283
  };
308
284
  }
309
-
310
- const implementationMemberKinds = new Set(["method", "property"]);
@@ -0,0 +1,77 @@
1
+ /** The structured components carried by a graph symbol id. */
2
+ export interface ITtscGraphNodeId {
3
+ path: string;
4
+ name: string;
5
+ kind?: string;
6
+ }
7
+
8
+ /**
9
+ * Decode the graph's position-invariant `path#name:kind` identity.
10
+ *
11
+ * The producer quotes `#` and `\\` inside path and name. This reader also
12
+ * accepts pre-codec ordinary ids, so a current package can read an older dump.
13
+ */
14
+ export function parseTtscGraphNodeId(id: string): ITtscGraphNodeId | undefined {
15
+ const hash = graphNodeIdHash(id);
16
+ if (hash < 0) return undefined;
17
+ const tail = id.slice(hash + 1);
18
+ if (tail === "") return undefined;
19
+ const colon = tail.lastIndexOf(":");
20
+ if (colon === 0 || colon === tail.length - 1) return undefined;
21
+ return {
22
+ path: unescapeGraphNodeIdPart(id.slice(0, hash)),
23
+ name: unescapeGraphNodeIdPart(colon < 0 ? tail : tail.slice(0, colon)),
24
+ ...(colon < 0 ? {} : { kind: tail.slice(colon + 1) }),
25
+ };
26
+ }
27
+
28
+ /** Encode a symbol identity without making its component boundaries ambiguous. */
29
+ export function writeTtscGraphNodeId(
30
+ path: string,
31
+ name: string,
32
+ kind: string,
33
+ ): string {
34
+ return `${escapeGraphNodeIdPart(path)}#${escapeGraphNodeIdPart(name)}:${kind}`;
35
+ }
36
+
37
+ /** Return the raw path component when id is a symbol identity. */
38
+ export function ttscGraphNodeIdPath(id: string): string | undefined {
39
+ return parseTtscGraphNodeId(id)?.path;
40
+ }
41
+
42
+ function escapeGraphNodeIdPart(value: string): string {
43
+ return value.replaceAll("\\", "\\\\").replaceAll("#", "\\#");
44
+ }
45
+
46
+ function unescapeGraphNodeIdPart(value: string): string {
47
+ let result = "";
48
+ for (let index = 0; index < value.length; index++) {
49
+ const next = value[index + 1];
50
+ if (value[index] === "\\" && next !== undefined) {
51
+ if (next === "#" || (next === "\\" && !legacyUNCStart(value, index))) {
52
+ result += next;
53
+ index++;
54
+ continue;
55
+ }
56
+ }
57
+ result += value[index];
58
+ }
59
+ return result;
60
+ }
61
+
62
+ function legacyUNCStart(value: string, index: number): boolean {
63
+ return (
64
+ index === 0 && value.length > 2 && value[2] !== "\\" && value[2] !== "#"
65
+ );
66
+ }
67
+
68
+ function graphNodeIdHash(id: string): number {
69
+ for (let index = 0; index < id.length; index++) {
70
+ if (id[index] !== "#") continue;
71
+ let slashes = 0;
72
+ for (let slash = index - 1; slash >= 0 && id[slash] === "\\"; slash--)
73
+ slashes++;
74
+ if (slashes % 2 === 0) return index;
75
+ }
76
+ return -1;
77
+ }
@@ -5,8 +5,8 @@ import typia from "typia";
5
5
  import { ensureExecutable } from "../nativeExecutable";
6
6
  import { resolveGraphBinary } from "../resolveGraphBinary";
7
7
  import { ITtscGraphSnapshot } from "../structures/ITtscGraphSnapshot";
8
- import { DUMP_SCHEMA_VERSION } from "./loadGraph";
9
8
  import { TtscGraphMemory } from "./TtscGraphMemory";
9
+ import { DUMP_SCHEMA_VERSION } from "./loadGraph";
10
10
 
11
11
  /**
12
12
  * The serve protocol version this client speaks.
@@ -17,10 +17,44 @@ import { TtscGraphMemory } from "./TtscGraphMemory";
17
17
  * constant out of this file and fails if the pair drifts.
18
18
  */
19
19
  const PROTOCOL_VERSION = 1;
20
+ const DEFAULT_REQUEST_TIMEOUT_MS = 300_000;
21
+ const MAX_TIMER_MS = 2_147_483_647;
22
+ const TERMINATION_GRACE_MS = 1_000;
20
23
 
21
24
  interface Pending {
25
+ child: NativeChild;
22
26
  resolve: (response: ITtscGraphSnapshot) => void;
23
27
  reject: (error: Error) => void;
28
+ timer: NodeJS.Timeout;
29
+ signal?: AbortSignal;
30
+ abort?: () => void;
31
+ }
32
+
33
+ interface NativeChild {
34
+ process: ChildProcessWithoutNullStreams;
35
+ lines: readline.Interface;
36
+ stderr: string;
37
+ }
38
+
39
+ /** Construction options for a resident native graph session. */
40
+ export interface TtscGraphSessionOptions {
41
+ /** Project root passed to `ttscgraph serve`. */
42
+ cwd: string;
43
+ /** Project tsconfig passed to `ttscgraph serve`. */
44
+ tsconfig: string;
45
+ /** Absolute native binary path, resolved from `cwd` when omitted. */
46
+ binary?: string;
47
+ /**
48
+ * Maximum time for one native snapshot response. Defaults to five minutes,
49
+ * which is more than ten times the published 28.7-second VS Code cold index.
50
+ */
51
+ requestTimeoutMs?: number;
52
+ }
53
+
54
+ /** Per-call controls for a native graph refresh. */
55
+ export interface TtscGraphRequestOptions {
56
+ /** Cancel this refresh and retire its native session. */
57
+ signal?: AbortSignal;
24
58
  }
25
59
 
26
60
  /**
@@ -35,19 +69,26 @@ export class TtscGraphSession {
35
69
  private readonly cwd: string;
36
70
  private readonly tsconfig: string;
37
71
  private readonly binary: string;
38
- private child: ChildProcessWithoutNullStreams | undefined;
39
- private stderr = "";
72
+ private readonly requestTimeoutMs: number;
73
+ private child: NativeChild | undefined;
40
74
  private nextId = 0;
41
75
  private readonly pending = new Map<number, Pending>();
42
76
  private queue: Promise<void> = Promise.resolve();
43
77
  private current: TtscGraphMemory | undefined;
44
78
  private closed = false;
45
79
 
46
- public constructor(options: {
47
- cwd: string;
48
- tsconfig: string;
49
- binary?: string;
50
- }) {
80
+ public constructor(options: TtscGraphSessionOptions) {
81
+ const requestTimeoutMs =
82
+ options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
83
+ if (
84
+ !Number.isSafeInteger(requestTimeoutMs) ||
85
+ requestTimeoutMs <= 0 ||
86
+ requestTimeoutMs > MAX_TIMER_MS
87
+ ) {
88
+ throw new TypeError(
89
+ `@ttsc/graph: requestTimeoutMs must be an integer between 1 and ${String(MAX_TIMER_MS)}`,
90
+ );
91
+ }
51
92
  // Resolve the platform binary from the project this session serves, so the
52
93
  // MCP server started from an unrelated directory still finds the target's
53
94
  // installed `ttsc`.
@@ -64,21 +105,48 @@ export class TtscGraphSession {
64
105
  this.cwd = options.cwd;
65
106
  this.tsconfig = options.tsconfig;
66
107
  this.binary = binary;
108
+ this.requestTimeoutMs = requestTimeoutMs;
67
109
  }
68
110
 
69
111
  /** Return a graph for the current disk snapshot, serialized per tool call. */
70
- public graph(): Promise<TtscGraphMemory> {
112
+ public graph(
113
+ options: TtscGraphRequestOptions = {},
114
+ ): Promise<TtscGraphMemory> {
115
+ if (this.closed) {
116
+ return Promise.reject(new Error("@ttsc/graph: native session is closed"));
117
+ }
71
118
  let resolve!: (graph: TtscGraphMemory) => void;
72
119
  let reject!: (error: Error) => void;
120
+ let started = false;
121
+ let settled = false;
73
122
  const result = new Promise<TtscGraphMemory>((res, rej) => {
74
- resolve = res;
75
- reject = rej;
123
+ resolve = (graph) => {
124
+ if (settled) return;
125
+ settled = true;
126
+ res(graph);
127
+ };
128
+ reject = (error) => {
129
+ if (settled) return;
130
+ settled = true;
131
+ rej(error);
132
+ };
76
133
  });
134
+ const cancelQueued = () => {
135
+ if (!started) reject(cancelledError(options.signal));
136
+ };
137
+ if (options.signal?.aborted) {
138
+ reject(cancelledError(options.signal));
139
+ return result;
140
+ }
141
+ options.signal?.addEventListener("abort", cancelQueued, { once: true });
77
142
  this.queue = this.queue
78
143
  .catch(() => undefined)
79
144
  .then(async () => {
145
+ started = true;
146
+ options.signal?.removeEventListener("abort", cancelQueued);
147
+ if (settled) return;
80
148
  try {
81
- resolve(await this.refresh());
149
+ resolve(await this.refresh(options.signal));
82
150
  } catch (error) {
83
151
  reject(asError(error));
84
152
  }
@@ -88,17 +156,17 @@ export class TtscGraphSession {
88
156
 
89
157
  /** Close the native session. Safe to call more than once. */
90
158
  public close(): void {
159
+ if (this.closed) return;
91
160
  this.closed = true;
92
- const child = this.child;
93
- this.child = undefined;
94
- if (child !== undefined && !child.killed) child.stdin.end();
95
- this.failPending(new Error("@ttsc/graph: native session closed"));
161
+ const error = new Error("@ttsc/graph: native session closed");
162
+ if (this.child !== undefined) this.failChild(this.child, error);
163
+ else this.failPending(error);
96
164
  }
97
165
 
98
- private async refresh(): Promise<TtscGraphMemory> {
166
+ private async refresh(signal?: AbortSignal): Promise<TtscGraphMemory> {
99
167
  // The protocol version and the envelope shape were both settled in onLine,
100
168
  // before this frame was ever routed here.
101
- const response = await this.request();
169
+ const response = await this.request(signal);
102
170
  if (response.error !== undefined) {
103
171
  throw new Error(`@ttsc/graph: ${response.error}`);
104
172
  }
@@ -118,15 +186,41 @@ export class TtscGraphSession {
118
186
  return this.current;
119
187
  }
120
188
 
121
- private request(): Promise<ITtscGraphSnapshot> {
189
+ private request(signal?: AbortSignal): Promise<ITtscGraphSnapshot> {
190
+ if (signal?.aborted) throw cancelledError(signal);
122
191
  const child = this.ensureChild();
123
192
  const id = ++this.nextId;
124
193
  return new Promise<ITtscGraphSnapshot>((resolve, reject) => {
125
- this.pending.set(id, { resolve, reject });
126
- child.stdin.write(`${JSON.stringify({ id })}\n`, (error) => {
194
+ const pending: Pending = {
195
+ child,
196
+ resolve,
197
+ reject,
198
+ timer: setTimeout(() => {
199
+ this.failChild(
200
+ child,
201
+ new Error(
202
+ `@ttsc/graph: native snapshot request timed out after ${String(this.requestTimeoutMs)} ms${stderrSuffix(child)}`,
203
+ ),
204
+ );
205
+ }, this.requestTimeoutMs),
206
+ signal,
207
+ };
208
+ pending.timer.unref();
209
+ if (signal !== undefined) {
210
+ pending.abort = () =>
211
+ this.failChild(child, cancelledError(signal, child));
212
+ signal.addEventListener("abort", pending.abort, { once: true });
213
+ }
214
+ this.pending.set(id, pending);
215
+ if (signal?.aborted) {
216
+ pending.abort!();
217
+ return;
218
+ }
219
+ child.process.stdin.write(`${JSON.stringify({ id })}\n`, (error) => {
127
220
  if (error === null || error === undefined) return;
128
- this.pending.delete(id);
129
- reject(
221
+ if (this.pending.get(id) !== pending) return;
222
+ this.failChild(
223
+ child,
130
224
  new Error(
131
225
  `@ttsc/graph: could not request native snapshot: ${error.message}`,
132
226
  ),
@@ -135,49 +229,61 @@ export class TtscGraphSession {
135
229
  });
136
230
  }
137
231
 
138
- private ensureChild(): ChildProcessWithoutNullStreams {
232
+ private ensureChild(): NativeChild {
139
233
  if (this.closed) {
140
234
  // A request queued behind the close must not respawn the native
141
235
  // process; an orphaned resident compiler would outlive the MCP server.
142
236
  throw new Error("@ttsc/graph: native session is closed");
143
237
  }
144
- if (this.child !== undefined && this.child.exitCode === null) {
238
+ if (
239
+ this.child !== undefined &&
240
+ this.child.process.exitCode === null &&
241
+ this.child.process.signalCode === null
242
+ ) {
145
243
  return this.child;
146
244
  }
147
- this.stderr = "";
148
- const child = spawn(
245
+ const process = spawn(
149
246
  this.binary,
150
247
  ["serve", "--cwd", this.cwd, "--tsconfig", this.tsconfig],
151
248
  { stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
152
249
  );
250
+ const lines = readline.createInterface({ input: process.stdout });
251
+ const child: NativeChild = { process, lines, stderr: "" };
153
252
  this.child = child;
154
- child.stderr.setEncoding("utf8");
155
- child.stderr.on("data", (chunk: string) => {
156
- this.stderr = (this.stderr + chunk).slice(-64 * 1024);
253
+ process.stderr.setEncoding("utf8");
254
+ process.stderr.on("data", (chunk: string) => {
255
+ child.stderr = (child.stderr + chunk).slice(-64 * 1024);
157
256
  });
158
- const lines = readline.createInterface({ input: child.stdout });
159
- lines.on("line", (line) => this.onLine(line));
160
- child.on("error", (error) => this.failChild(child, error));
161
- child.on("exit", (code, signal) => {
257
+ lines.on("line", (line) => this.onLine(child, line));
258
+ process.on("error", (error) =>
259
+ this.failChild(
260
+ child,
261
+ new Error(`@ttsc/graph: native session failed: ${error.message}`),
262
+ ),
263
+ );
264
+ process.on("exit", (code, signal) => {
162
265
  if (this.child !== child) return;
163
- this.child = undefined;
164
- this.failPending(
266
+ this.failChild(
267
+ child,
165
268
  new Error(
166
- `@ttsc/graph: native session exited (code=${String(code)}, signal=${String(signal)})${
167
- this.stderr.trim() === "" ? "" : `: ${this.stderr.trim()}`
168
- }`,
269
+ `@ttsc/graph: native session exited (code=${String(code)}, signal=${String(signal)})${stderrSuffix(
270
+ child,
271
+ )}`,
169
272
  ),
273
+ false,
170
274
  );
171
275
  });
172
276
  return child;
173
277
  }
174
278
 
175
- private onLine(line: string): void {
279
+ private onLine(child: NativeChild, line: string): void {
280
+ if (this.child !== child) return;
176
281
  let parsed: unknown;
177
282
  try {
178
283
  parsed = JSON.parse(line);
179
284
  } catch (error) {
180
- this.failPending(
285
+ this.failChild(
286
+ child,
181
287
  new Error(
182
288
  `@ttsc/graph: native session returned invalid JSON: ${asError(error).message}`,
183
289
  ),
@@ -199,7 +305,8 @@ export class TtscGraphSession {
199
305
  if (version !== PROTOCOL_VERSION) {
200
306
  // Session-wide: a version mismatch is not one bad frame, it is the wrong
201
307
  // binary, and every request against it is equally doomed.
202
- this.failPending(
308
+ this.failChild(
309
+ child,
203
310
  new Error(
204
311
  `@ttsc/graph: ttscgraph speaks serve protocol ${
205
312
  version === undefined ? "an unknown version" : `v${String(version)}`
@@ -220,7 +327,8 @@ export class TtscGraphSession {
220
327
  // envelope belongs on this side of that line.
221
328
  response = typia.assert<ITtscGraphSnapshot>(parsed);
222
329
  } catch (error) {
223
- this.failPending(
330
+ this.failChild(
331
+ child,
224
332
  new Error(
225
333
  `@ttsc/graph: native session returned an unreadable response: ${asError(error).message}`,
226
334
  ),
@@ -241,7 +349,8 @@ export class TtscGraphSession {
241
349
  ) {
242
350
  // Session-wide, for the same reason the protocol mismatch above is: it is
243
351
  // the wrong binary, not one bad frame.
244
- this.failPending(
352
+ this.failChild(
353
+ child,
245
354
  new Error(
246
355
  `@ttsc/graph: ttscgraph sends dump schema v${String(
247
356
  response.dump.provenance.schemaVersion,
@@ -254,24 +363,88 @@ export class TtscGraphSession {
254
363
  }
255
364
 
256
365
  const pending = this.pending.get(response.id);
257
- if (pending === undefined) return;
258
- this.pending.delete(response.id);
259
- pending.resolve(response);
366
+ if (pending === undefined || pending.child !== child) return;
367
+ this.settlePending(response.id, pending, response);
260
368
  }
261
369
 
262
- private failChild(child: ChildProcessWithoutNullStreams, error: Error): void {
263
- if (this.child === child) this.child = undefined;
264
- this.failPending(
265
- new Error(`@ttsc/graph: native session failed: ${error.message}`),
266
- );
370
+ private failChild(child: NativeChild, error: Error, terminate = true): void {
371
+ if (this.child !== child) return;
372
+ this.child = undefined;
373
+ this.current = undefined;
374
+ child.lines.close();
375
+ this.failPending(error, child);
376
+ if (terminate) terminateChild(child.process);
377
+ }
378
+
379
+ private failPending(error: Error, child?: NativeChild): void {
380
+ for (const [id, pending] of this.pending) {
381
+ if (child === undefined || pending.child === child) {
382
+ this.settlePending(id, pending, error);
383
+ }
384
+ }
385
+ }
386
+
387
+ private settlePending(
388
+ id: number,
389
+ pending: Pending,
390
+ result: ITtscGraphSnapshot | Error,
391
+ ): void {
392
+ if (this.pending.get(id) !== pending) return;
393
+ this.pending.delete(id);
394
+ clearTimeout(pending.timer);
395
+ if (pending.signal !== undefined && pending.abort !== undefined) {
396
+ pending.signal.removeEventListener("abort", pending.abort);
397
+ }
398
+ if (result instanceof Error) pending.reject(result);
399
+ else pending.resolve(result);
267
400
  }
401
+ }
402
+
403
+ function terminateChild(child: ChildProcessWithoutNullStreams): void {
404
+ if (!child.stdin.destroyed) child.stdin.destroy();
405
+ if (child.exitCode !== null || child.signalCode !== null) return;
406
+ try {
407
+ child.kill();
408
+ } catch {
409
+ return;
410
+ }
411
+ const force = setTimeout(() => {
412
+ if (child.exitCode !== null || child.signalCode !== null) return;
413
+ try {
414
+ child.kill("SIGKILL");
415
+ } catch {
416
+ // The process exited between the liveness check and the signal.
417
+ }
418
+ }, TERMINATION_GRACE_MS);
419
+ force.unref();
420
+ child.once("exit", () => clearTimeout(force));
421
+ }
422
+
423
+ function cancelledError(signal?: AbortSignal, child?: NativeChild): Error {
424
+ const error = new Error(
425
+ `@ttsc/graph: native snapshot request cancelled${abortDetail(signal)}${
426
+ child === undefined ? "" : stderrSuffix(child)
427
+ }`,
428
+ );
429
+ error.name = "AbortError";
430
+ return error;
431
+ }
268
432
 
269
- private failPending(error: Error): void {
270
- for (const pending of this.pending.values()) pending.reject(error);
271
- this.pending.clear();
433
+ function abortDetail(signal?: AbortSignal): string {
434
+ const reason = signal?.reason;
435
+ if (reason === undefined) return "";
436
+ try {
437
+ return `: ${reason instanceof Error ? reason.message : String(reason)}`;
438
+ } catch {
439
+ return "";
272
440
  }
273
441
  }
274
442
 
443
+ function stderrSuffix(child: NativeChild): string {
444
+ const stderr = child.stderr.trim();
445
+ return stderr === "" ? "" : `: ${stderr}`;
446
+ }
447
+
275
448
  function asError(error: unknown): Error {
276
449
  return error instanceof Error ? error : new Error(String(error));
277
450
  }