Skip to content

Commit 85ed3b8

Browse files
committed
feat: implement embedding tracker controller with lazy and eager modes, enhance process lifecycle management, and add related tests
1 parent dec1e74 commit 85ed3b8

6 files changed

Lines changed: 420 additions & 56 deletions

File tree

package-lock.json

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

src/core/embedding-tracker.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ export interface EmbeddingTrackerOptions {
1111
maxFilesPerTick?: number;
1212
}
1313

14+
export interface EmbeddingTrackerController {
15+
ensureStarted: () => void;
16+
stop: () => void;
17+
isRunning: () => boolean;
18+
}
19+
20+
export interface EmbeddingTrackerControllerOptions extends EmbeddingTrackerOptions {
21+
mode?: string;
22+
starter?: (options: EmbeddingTrackerOptions) => () => void;
23+
}
24+
1425
const MIN_FILES_PER_TICK = 5;
1526
const MAX_FILES_PER_TICK = 10;
1627
const DEFAULT_FILES_PER_TICK = 8;
@@ -44,6 +55,14 @@ function clampDebounceMs(value: number | undefined): number {
4455
return Math.max(100, Math.floor(value ?? DEFAULT_DEBOUNCE_MS));
4556
}
4657

58+
export function parseEmbeddingTrackerMode(value: string | undefined): "off" | "lazy" | "eager" {
59+
if (!value) return "lazy";
60+
const normalized = value.trim().toLowerCase();
61+
if (["false", "0", "no", "off", "disabled", "none"].includes(normalized)) return "off";
62+
if (["eager", "startup", "boot"].includes(normalized)) return "eager";
63+
return "lazy";
64+
}
65+
4766
export function startEmbeddingTracker(options: EmbeddingTrackerOptions): () => void {
4867
const pendingFiles = new Set<string>();
4968
const debounceMs = clampDebounceMs(options.debounceMs);
@@ -111,3 +130,31 @@ export function startEmbeddingTracker(options: EmbeddingTrackerOptions): () => v
111130
watcher = null;
112131
};
113132
}
133+
134+
export function createEmbeddingTrackerController(options: EmbeddingTrackerControllerOptions): EmbeddingTrackerController {
135+
const { mode: rawMode, starter = startEmbeddingTracker, ...trackerOptions } = options;
136+
const mode = parseEmbeddingTrackerMode(rawMode);
137+
138+
let running = false;
139+
let stopTracker = () => { };
140+
141+
const ensureStarted = (): void => {
142+
if (running || mode === "off") return;
143+
stopTracker = starter(trackerOptions);
144+
running = true;
145+
};
146+
147+
if (mode === "eager") ensureStarted();
148+
149+
return {
150+
ensureStarted,
151+
stop: () => {
152+
if (!running) return;
153+
running = false;
154+
const stop = stopTracker;
155+
stopTracker = () => { };
156+
stop();
157+
},
158+
isRunning: () => running,
159+
};
160+
}

src/core/process-lifecycle.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,43 @@ interface ErrorWithCode {
66
}
77

88
const BROKEN_PIPE_CODES = new Set(["EPIPE", "ERR_STREAM_DESTROYED", "ECONNRESET"]);
9+
const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60 * 1000;
10+
const MIN_IDLE_TIMEOUT_MS = 60 * 1000;
11+
const DEFAULT_PARENT_POLL_MS = 5 * 1000;
12+
const MIN_PARENT_POLL_MS = 1 * 1000;
913

1014
export interface CleanupOptions {
1115
stopTracker: () => void;
1216
closeServer: () => Promise<void> | void;
1317
closeTransport: () => Promise<void> | void;
18+
stopMonitors?: () => void;
19+
}
20+
21+
export interface IdleMonitor {
22+
touch: () => void;
23+
stop: () => void;
24+
}
25+
26+
export interface IdleMonitorOptions {
27+
timeoutMs: number;
28+
onIdle: () => void;
29+
}
30+
31+
export interface ParentMonitorOptions {
32+
parentPid: number;
33+
pollIntervalMs?: number;
34+
onParentExit: () => void;
35+
isProcessAlive?: (pid: number) => boolean;
36+
}
37+
38+
function toIntegerOr(value: string | undefined, fallback: number): number {
39+
if (!value) return fallback;
40+
const parsed = Number.parseInt(value, 10);
41+
return Number.isFinite(parsed) ? parsed : fallback;
42+
}
43+
44+
function unrefHandle(handle: { unref?: () => void } | null): void {
45+
handle?.unref?.();
1446
}
1547

1648
export function isBrokenPipeError(error: unknown): boolean {
@@ -19,7 +51,89 @@ export function isBrokenPipeError(error: unknown): boolean {
1951
return typeof code === "string" && BROKEN_PIPE_CODES.has(code);
2052
}
2153

54+
export function getIdleShutdownMs(value: string | undefined): number {
55+
const normalized = value?.trim().toLowerCase();
56+
if (normalized && ["0", "false", "off", "disabled", "none"].includes(normalized)) return 0;
57+
return Math.max(MIN_IDLE_TIMEOUT_MS, toIntegerOr(value, DEFAULT_IDLE_TIMEOUT_MS));
58+
}
59+
60+
export function getParentPollMs(value: string | undefined): number {
61+
return Math.max(MIN_PARENT_POLL_MS, toIntegerOr(value, DEFAULT_PARENT_POLL_MS));
62+
}
63+
64+
export function isProcessAlive(pid: number, killCheck: (pid: number, signal: number) => void = process.kill): boolean {
65+
if (!Number.isFinite(pid) || pid <= 0) return false;
66+
67+
try {
68+
killCheck(pid, 0);
69+
return true;
70+
} catch (error) {
71+
if (!error || typeof error !== "object") return false;
72+
const { code } = error as ErrorWithCode;
73+
return code !== "ESRCH";
74+
}
75+
}
76+
77+
export function createIdleMonitor(options: IdleMonitorOptions): IdleMonitor {
78+
if (options.timeoutMs <= 0) {
79+
return {
80+
touch: () => { },
81+
stop: () => { },
82+
};
83+
}
84+
85+
let timer: NodeJS.Timeout | null = null;
86+
87+
const schedule = (): void => {
88+
if (timer) clearTimeout(timer);
89+
timer = setTimeout(() => {
90+
timer = null;
91+
options.onIdle();
92+
}, options.timeoutMs);
93+
unrefHandle(timer);
94+
};
95+
96+
schedule();
97+
98+
return {
99+
touch: schedule,
100+
stop: () => {
101+
if (!timer) return;
102+
clearTimeout(timer);
103+
timer = null;
104+
},
105+
};
106+
}
107+
108+
export function startParentMonitor(options: ParentMonitorOptions): () => void {
109+
if (!Number.isFinite(options.parentPid) || options.parentPid <= 1 || options.parentPid === process.pid) {
110+
return () => { };
111+
}
112+
113+
const pollIntervalMs = Math.max(MIN_PARENT_POLL_MS, Math.floor(options.pollIntervalMs ?? DEFAULT_PARENT_POLL_MS));
114+
const isAlive = options.isProcessAlive ?? isProcessAlive;
115+
let stopped = false;
116+
117+
const stop = (): void => {
118+
if (stopped) return;
119+
stopped = true;
120+
clearInterval(interval);
121+
};
122+
123+
const interval = setInterval(() => {
124+
if (stopped) return;
125+
if (process.ppid !== options.parentPid || !isAlive(options.parentPid)) {
126+
stop();
127+
options.onParentExit();
128+
}
129+
}, pollIntervalMs);
130+
131+
unrefHandle(interval);
132+
return stop;
133+
}
134+
22135
export async function runCleanup(options: CleanupOptions): Promise<void> {
136+
options.stopMonitors?.();
23137
options.stopTracker();
24138
await Promise.allSettled([
25139
Promise.resolve(options.closeServer()),

0 commit comments

Comments
 (0)