pidnap 0.0.0-dev.0

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.
@@ -0,0 +1,397 @@
1
+ import * as v from "valibot";
2
+ import { LazyProcess, type ProcessDefinition, type ProcessState } from "./lazy-process.ts";
3
+ import type { Logger } from "./logger.ts";
4
+
5
+ // Restart policies
6
+ export const RestartPolicySchema = v.picklist([
7
+ "always",
8
+ "on-failure",
9
+ "never",
10
+ "unless-stopped",
11
+ "on-success",
12
+ ]);
13
+
14
+ export type RestartPolicy = v.InferOutput<typeof RestartPolicySchema>;
15
+
16
+ // Backoff strategy schema
17
+ export const BackoffStrategySchema = v.union([
18
+ v.object({
19
+ type: v.literal("fixed"),
20
+ delayMs: v.number(),
21
+ }),
22
+ v.object({
23
+ type: v.literal("exponential"),
24
+ initialDelayMs: v.number(),
25
+ maxDelayMs: v.number(),
26
+ multiplier: v.optional(v.number()),
27
+ }),
28
+ ]);
29
+
30
+ export type BackoffStrategy = v.InferOutput<typeof BackoffStrategySchema>;
31
+
32
+ // Crash loop detection config schema
33
+ export const CrashLoopConfigSchema = v.object({
34
+ maxRestarts: v.number(),
35
+ windowMs: v.number(),
36
+ backoffMs: v.number(),
37
+ });
38
+
39
+ export type CrashLoopConfig = v.InferOutput<typeof CrashLoopConfigSchema>;
40
+
41
+ // Restarting process options schema
42
+ export const RestartingProcessOptionsSchema = v.object({
43
+ restartPolicy: RestartPolicySchema,
44
+ backoff: v.optional(BackoffStrategySchema),
45
+ crashLoop: v.optional(CrashLoopConfigSchema),
46
+ minUptimeMs: v.optional(v.number()),
47
+ maxTotalRestarts: v.optional(v.number()),
48
+ });
49
+
50
+ export type RestartingProcessOptions = v.InferOutput<typeof RestartingProcessOptionsSchema>;
51
+
52
+ // State
53
+ export const RestartingProcessStateSchema = v.picklist([
54
+ "idle",
55
+ "running",
56
+ "restarting",
57
+ "stopping",
58
+ "stopped",
59
+ "crash-loop-backoff",
60
+ "max-restarts-reached",
61
+ ]);
62
+
63
+ export type RestartingProcessState = v.InferOutput<typeof RestartingProcessStateSchema>;
64
+
65
+ const DEFAULT_BACKOFF: BackoffStrategy = { type: "fixed", delayMs: 1000 };
66
+ const DEFAULT_CRASH_LOOP: CrashLoopConfig = { maxRestarts: 5, windowMs: 60000, backoffMs: 60000 };
67
+
68
+ export class RestartingProcess {
69
+ readonly name: string;
70
+ private lazyProcess: LazyProcess;
71
+ private definition: ProcessDefinition;
72
+ private options: Required<Omit<RestartingProcessOptions, "maxTotalRestarts">> & {
73
+ maxTotalRestarts?: number;
74
+ };
75
+ private logger: Logger;
76
+
77
+ // State tracking
78
+ private _state: RestartingProcessState = "idle";
79
+ private _restartCount: number = 0;
80
+ private restartTimestamps: number[] = []; // For crash loop detection
81
+ private consecutiveFailures: number = 0; // For exponential backoff
82
+ private lastStartTime: number | null = null;
83
+ private stopRequested: boolean = false;
84
+ private pendingDelayTimeout: ReturnType<typeof setTimeout> | null = null;
85
+
86
+ constructor(
87
+ name: string,
88
+ definition: ProcessDefinition,
89
+ options: RestartingProcessOptions,
90
+ logger: Logger,
91
+ ) {
92
+ this.name = name;
93
+ this.definition = definition;
94
+ this.logger = logger;
95
+ this.options = {
96
+ restartPolicy: options.restartPolicy,
97
+ backoff: options.backoff ?? DEFAULT_BACKOFF,
98
+ crashLoop: options.crashLoop ?? DEFAULT_CRASH_LOOP,
99
+ minUptimeMs: options.minUptimeMs ?? 0,
100
+ maxTotalRestarts: options.maxTotalRestarts,
101
+ };
102
+ this.lazyProcess = new LazyProcess(name, definition, logger);
103
+ }
104
+
105
+ get state(): RestartingProcessState {
106
+ return this._state;
107
+ }
108
+
109
+ get restarts(): number {
110
+ return this._restartCount;
111
+ }
112
+
113
+ start(): void {
114
+ if (this._state === "running" || this._state === "restarting") {
115
+ throw new Error(`Process "${this.name}" is already ${this._state}`);
116
+ }
117
+
118
+ if (this._state === "stopping") {
119
+ throw new Error(`Process "${this.name}" is currently stopping`);
120
+ }
121
+
122
+ // Fresh start from terminal states - reset counters
123
+ if (
124
+ this._state === "stopped" ||
125
+ this._state === "idle" ||
126
+ this._state === "max-restarts-reached"
127
+ ) {
128
+ this.resetCounters();
129
+ }
130
+
131
+ this.stopRequested = false;
132
+ this.startProcess();
133
+ }
134
+
135
+ async stop(timeout?: number): Promise<void> {
136
+ this.stopRequested = true;
137
+
138
+ // Clear any pending delays
139
+ if (this.pendingDelayTimeout) {
140
+ clearTimeout(this.pendingDelayTimeout);
141
+ this.pendingDelayTimeout = null;
142
+ }
143
+
144
+ if (
145
+ this._state === "idle" ||
146
+ this._state === "stopped" ||
147
+ this._state === "max-restarts-reached"
148
+ ) {
149
+ this._state = "stopped";
150
+ return;
151
+ }
152
+
153
+ this._state = "stopping";
154
+ await this.lazyProcess.stop(timeout);
155
+ this._state = "stopped";
156
+ this.logger.info(`RestartingProcess stopped`);
157
+ }
158
+
159
+ async restart(force: boolean = false): Promise<void> {
160
+ // Fresh start from terminal states - reset counters and no delay
161
+ if (
162
+ this._state === "stopped" ||
163
+ this._state === "idle" ||
164
+ this._state === "max-restarts-reached"
165
+ ) {
166
+ this.resetCounters();
167
+ this.stopRequested = false;
168
+ this.startProcess();
169
+ return;
170
+ }
171
+
172
+ // Stop the current process first
173
+ await this.stop();
174
+
175
+ this.stopRequested = false;
176
+
177
+ if (force) {
178
+ // Force restart - no delay
179
+ this.startProcess();
180
+ } else {
181
+ // Follow normal delay strategy
182
+ const delay = this.calculateDelay();
183
+ if (delay > 0) {
184
+ this._state = "restarting";
185
+ this.logger.info(`Restarting in ${delay}ms`);
186
+ await this.delay(delay);
187
+ if (this.stopRequested) return;
188
+ }
189
+ this.startProcess();
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Update process definition and optionally restart with new config
195
+ */
196
+ async reload(
197
+ newDefinition: ProcessDefinition,
198
+ restartImmediately: boolean = true,
199
+ ): Promise<void> {
200
+ this.logger.info(`Reloading process with new definition`);
201
+ this.definition = newDefinition;
202
+ this.lazyProcess.updateDefinition(newDefinition);
203
+
204
+ if (restartImmediately) {
205
+ // Restart with force=true to apply changes immediately
206
+ await this.restart(true);
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Update restart options
212
+ */
213
+ updateOptions(newOptions: Partial<RestartingProcessOptions>): void {
214
+ this.logger.info(`Updating restart options`);
215
+ this.options = {
216
+ ...this.options,
217
+ restartPolicy: newOptions.restartPolicy ?? this.options.restartPolicy,
218
+ backoff: newOptions.backoff ?? this.options.backoff,
219
+ crashLoop: newOptions.crashLoop ?? this.options.crashLoop,
220
+ minUptimeMs: newOptions.minUptimeMs ?? this.options.minUptimeMs,
221
+ maxTotalRestarts: newOptions.maxTotalRestarts ?? this.options.maxTotalRestarts,
222
+ };
223
+ }
224
+
225
+ private resetCounters(): void {
226
+ this._restartCount = 0;
227
+ this.consecutiveFailures = 0;
228
+ this.restartTimestamps = [];
229
+ }
230
+
231
+ private startProcess(): void {
232
+ this.lastStartTime = Date.now();
233
+ this._state = "running";
234
+
235
+ this.lazyProcess
236
+ .reset()
237
+ .then(() => {
238
+ if (this.stopRequested) return;
239
+ this.lazyProcess.start();
240
+ return this.lazyProcess.waitForExit();
241
+ })
242
+ .then((exitState) => {
243
+ if (!exitState) return;
244
+ if (this.stopRequested && exitState === "error") {
245
+ this._state = "stopped";
246
+ return;
247
+ }
248
+ if (exitState === "stopped" || exitState === "error") {
249
+ this.handleProcessExit(exitState);
250
+ }
251
+ })
252
+ .catch((err) => {
253
+ if (this.stopRequested) return;
254
+ this._state = "stopped";
255
+ this.logger.error(`Failed to start process:`, err);
256
+ });
257
+ }
258
+
259
+ private handleProcessExit(exitState: ProcessState): void {
260
+ if (this.stopRequested) {
261
+ this._state = "stopped";
262
+ return;
263
+ }
264
+
265
+ const uptime = this.lastStartTime ? Date.now() - this.lastStartTime : 0;
266
+ const wasHealthy = uptime >= this.options.minUptimeMs;
267
+ const exitedWithError = exitState === "error";
268
+
269
+ // Reset consecutive failures if the process ran long enough
270
+ if (wasHealthy) {
271
+ this.consecutiveFailures = 0;
272
+ } else {
273
+ this.consecutiveFailures++;
274
+ }
275
+
276
+ // Check if policy allows restart
277
+ if (!this.shouldRestart(exitedWithError)) {
278
+ this._state = "stopped";
279
+ this.logger.info(
280
+ `Process exited, policy "${this.options.restartPolicy}" does not allow restart`,
281
+ );
282
+ return;
283
+ }
284
+
285
+ // Check max total restarts
286
+ if (
287
+ this.options.maxTotalRestarts !== undefined &&
288
+ this._restartCount >= this.options.maxTotalRestarts
289
+ ) {
290
+ this._state = "max-restarts-reached";
291
+ this.logger.warn(`Max total restarts (${this.options.maxTotalRestarts}) reached`);
292
+ return;
293
+ }
294
+
295
+ // Record restart timestamp for crash loop detection
296
+ const now = Date.now();
297
+ this.restartTimestamps.push(now);
298
+
299
+ // Check for crash loop
300
+ if (this.isInCrashLoop()) {
301
+ this._state = "crash-loop-backoff";
302
+ this.logger.warn(
303
+ `Crash loop detected (${this.options.crashLoop.maxRestarts} restarts in ${this.options.crashLoop.windowMs}ms), backing off for ${this.options.crashLoop.backoffMs}ms`,
304
+ );
305
+ this.scheduleCrashLoopRecovery();
306
+ return;
307
+ }
308
+
309
+ // Schedule restart with delay
310
+ this._restartCount++;
311
+ this.scheduleRestart();
312
+ }
313
+
314
+ private shouldRestart(exitedWithError: boolean): boolean {
315
+ switch (this.options.restartPolicy) {
316
+ case "always":
317
+ return true;
318
+ case "never":
319
+ return false;
320
+ case "on-failure":
321
+ return exitedWithError;
322
+ case "on-success":
323
+ return !exitedWithError;
324
+ case "unless-stopped":
325
+ return !this.stopRequested;
326
+ default:
327
+ return false;
328
+ }
329
+ }
330
+
331
+ private isInCrashLoop(): boolean {
332
+ const { maxRestarts, windowMs } = this.options.crashLoop;
333
+ const now = Date.now();
334
+ const cutoff = now - windowMs;
335
+
336
+ // Clean up old timestamps
337
+ this.restartTimestamps = this.restartTimestamps.filter((ts) => ts > cutoff);
338
+
339
+ return this.restartTimestamps.length >= maxRestarts;
340
+ }
341
+
342
+ private calculateDelay(): number {
343
+ const { backoff } = this.options;
344
+
345
+ if (backoff.type === "fixed") {
346
+ return backoff.delayMs;
347
+ }
348
+
349
+ // Exponential backoff
350
+ const multiplier = backoff.multiplier ?? 2;
351
+ const delay = backoff.initialDelayMs * Math.pow(multiplier, this.consecutiveFailures);
352
+ return Math.min(delay, backoff.maxDelayMs);
353
+ }
354
+
355
+ private scheduleRestart(): void {
356
+ this._state = "restarting";
357
+ const delay = this.calculateDelay();
358
+
359
+ this.logger.info(`Restarting in ${delay}ms (restart #${this._restartCount})`);
360
+
361
+ this.pendingDelayTimeout = setTimeout(() => {
362
+ this.pendingDelayTimeout = null;
363
+ if (this.stopRequested) {
364
+ this._state = "stopped";
365
+ return;
366
+ }
367
+ this.startProcess();
368
+ }, delay);
369
+ }
370
+
371
+ private scheduleCrashLoopRecovery(): void {
372
+ const { backoffMs } = this.options.crashLoop;
373
+
374
+ this.pendingDelayTimeout = setTimeout(() => {
375
+ this.pendingDelayTimeout = null;
376
+ if (this.stopRequested) {
377
+ this._state = "stopped";
378
+ return;
379
+ }
380
+
381
+ // Reset crash loop timestamps after backoff
382
+ this.restartTimestamps = [];
383
+ this._restartCount++;
384
+ this.logger.info(`Crash loop backoff complete, restarting (restart #${this._restartCount})`);
385
+ this.startProcess();
386
+ }, backoffMs);
387
+ }
388
+
389
+ private delay(ms: number): Promise<void> {
390
+ return new Promise((resolve) => {
391
+ this.pendingDelayTimeout = setTimeout(() => {
392
+ this.pendingDelayTimeout = null;
393
+ resolve();
394
+ }, ms);
395
+ });
396
+ }
397
+ }
@@ -0,0 +1,236 @@
1
+ import * as v from "valibot";
2
+ import { LazyProcess, ProcessDefinitionSchema } from "./lazy-process.ts";
3
+ import type { Logger } from "./logger.ts";
4
+
5
+ // Per-task state
6
+ export const TaskStateSchema = v.picklist(["pending", "running", "completed", "failed", "skipped"]);
7
+
8
+ export type TaskState = v.InferOutput<typeof TaskStateSchema>;
9
+
10
+ // Schema for named process definition
11
+ export const NamedProcessDefinitionSchema = v.object({
12
+ name: v.string(),
13
+ process: ProcessDefinitionSchema,
14
+ });
15
+
16
+ export type NamedProcessDefinition = v.InferOutput<typeof NamedProcessDefinitionSchema>;
17
+
18
+ // A task entry (single or parallel processes) with its state
19
+ export interface TaskEntry {
20
+ id: string; // Unique task ID
21
+ processes: NamedProcessDefinition[]; // Array (length 1 = sequential, >1 = parallel)
22
+ state: TaskState;
23
+ }
24
+
25
+ // Simple TaskList state (just running or not)
26
+ export type TaskListState = "idle" | "running" | "stopped";
27
+
28
+ export class TaskList {
29
+ readonly name: string;
30
+ private _tasks: TaskEntry[] = [];
31
+ private _state: TaskListState = "idle";
32
+ private logger: Logger;
33
+ private logFileResolver?: (processName: string) => string | undefined;
34
+ private taskIdCounter: number = 0;
35
+ private runningProcesses: LazyProcess[] = [];
36
+ private stopRequested: boolean = false;
37
+ private runLoopPromise: Promise<void> | null = null;
38
+
39
+ constructor(
40
+ name: string,
41
+ logger: Logger,
42
+ initialTasks?: (NamedProcessDefinition | NamedProcessDefinition[])[],
43
+ logFileResolver?: (processName: string) => string | undefined,
44
+ ) {
45
+ this.name = name;
46
+ this.logger = logger;
47
+ this.logFileResolver = logFileResolver;
48
+
49
+ // Add initial tasks if provided
50
+ if (initialTasks) {
51
+ for (const task of initialTasks) {
52
+ this.addTask(task);
53
+ }
54
+ }
55
+ }
56
+
57
+ get state(): TaskListState {
58
+ return this._state;
59
+ }
60
+
61
+ get tasks(): ReadonlyArray<TaskEntry> {
62
+ return this._tasks;
63
+ }
64
+
65
+ removeTaskByTarget(target: string | number): TaskEntry {
66
+ const index =
67
+ typeof target === "number" ? target : this._tasks.findIndex((t) => t.id === target);
68
+ if (index < 0 || index >= this._tasks.length) {
69
+ throw new Error(`Task not found: ${target}`);
70
+ }
71
+
72
+ const task = this._tasks[index];
73
+ if (task.state === "running") {
74
+ throw new Error(`Cannot remove running task: ${task.id}`);
75
+ }
76
+
77
+ this._tasks.splice(index, 1);
78
+ this.logger.info(`Task "${task.id}" removed`);
79
+ return task;
80
+ }
81
+
82
+ /**
83
+ * Add a single process or parallel processes as a new task
84
+ * @returns The unique task ID
85
+ */
86
+ addTask(task: NamedProcessDefinition | NamedProcessDefinition[]): string {
87
+ const id = `task-${++this.taskIdCounter}`;
88
+ const processes = Array.isArray(task) ? task : [task];
89
+
90
+ const entry: TaskEntry = {
91
+ id,
92
+ processes,
93
+ state: "pending",
94
+ };
95
+
96
+ this._tasks.push(entry);
97
+ this.logger.info(`Task "${id}" added with ${processes.length} process(es)`);
98
+
99
+ return id;
100
+ }
101
+
102
+ /**
103
+ * Begin executing pending tasks
104
+ */
105
+ start(): void {
106
+ if (this._state === "running") {
107
+ throw new Error(`TaskList "${this.name}" is already running`);
108
+ }
109
+
110
+ this.stopRequested = false;
111
+ this._state = "running";
112
+ this.logger.info(`TaskList started`);
113
+
114
+ // Start the run loop (non-blocking)
115
+ this.runLoopPromise = this.runLoop();
116
+ }
117
+
118
+ /**
119
+ * Wait until the TaskList becomes idle (all pending tasks completed)
120
+ */
121
+ async waitUntilIdle(): Promise<void> {
122
+ if (this._state === "idle" || this._state === "stopped") {
123
+ return;
124
+ }
125
+
126
+ // Wait for the run loop to complete
127
+ if (this.runLoopPromise) {
128
+ await this.runLoopPromise;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Stop execution and mark remaining tasks as skipped
134
+ */
135
+ async stop(timeout?: number): Promise<void> {
136
+ if (this._state === "idle" || this._state === "stopped") {
137
+ this._state = "stopped";
138
+ return;
139
+ }
140
+
141
+ this.stopRequested = true;
142
+ this.logger.info(`Stopping TaskList...`);
143
+
144
+ // Stop all currently running processes
145
+ const stopPromises = this.runningProcesses.map((p) => p.stop(timeout));
146
+ await Promise.all(stopPromises);
147
+ this.runningProcesses = [];
148
+
149
+ // Mark all pending tasks as skipped
150
+ for (const task of this._tasks) {
151
+ if (task.state === "pending") {
152
+ task.state = "skipped";
153
+ }
154
+ }
155
+
156
+ // Wait for run loop to finish
157
+ if (this.runLoopPromise) {
158
+ await this.runLoopPromise;
159
+ this.runLoopPromise = null;
160
+ }
161
+
162
+ this._state = "stopped";
163
+ this.logger.info(`TaskList stopped`);
164
+ }
165
+
166
+ private async runLoop(): Promise<void> {
167
+ while (this._state === "running" && !this.stopRequested) {
168
+ // Find the next pending task
169
+ const nextTask = this._tasks.find((t) => t.state === "pending");
170
+
171
+ if (!nextTask) {
172
+ // No more pending tasks, go back to idle
173
+ this._state = "idle";
174
+ this.logger.info(`All tasks completed, TaskList is idle`);
175
+ break;
176
+ }
177
+
178
+ await this.executeTask(nextTask);
179
+ }
180
+ }
181
+
182
+ private async executeTask(task: TaskEntry): Promise<void> {
183
+ if (this.stopRequested) {
184
+ task.state = "skipped";
185
+ return;
186
+ }
187
+
188
+ task.state = "running";
189
+ const taskNames = task.processes.map((p) => p.name).join(", ");
190
+ this.logger.info(`Executing task "${task.id}": [${taskNames}]`);
191
+
192
+ // Create LazyProcess instances for each process in the task
193
+ const lazyProcesses: LazyProcess[] = task.processes.map((p) => {
194
+ const logFile = this.logFileResolver?.(p.name);
195
+ const childLogger = logFile
196
+ ? this.logger.child(p.name, { logFile })
197
+ : this.logger.child(p.name);
198
+ return new LazyProcess(p.name, p.process, childLogger);
199
+ });
200
+
201
+ this.runningProcesses = lazyProcesses;
202
+
203
+ try {
204
+ // Start all processes (parallel if multiple)
205
+ for (const lp of lazyProcesses) {
206
+ lp.start();
207
+ }
208
+
209
+ // Wait for all processes to complete
210
+ const results = await Promise.all(lazyProcesses.map((lp) => this.waitForProcess(lp)));
211
+
212
+ // Check if any failed
213
+ const anyFailed = results.some((r) => r === "error");
214
+
215
+ if (this.stopRequested) {
216
+ task.state = "skipped";
217
+ } else if (anyFailed) {
218
+ task.state = "failed";
219
+ this.logger.warn(`Task "${task.id}" failed`);
220
+ } else {
221
+ task.state = "completed";
222
+ this.logger.info(`Task "${task.id}" completed`);
223
+ }
224
+ } catch (err) {
225
+ task.state = "failed";
226
+ this.logger.error(`Task "${task.id}" error:`, err);
227
+ } finally {
228
+ this.runningProcesses = [];
229
+ }
230
+ }
231
+
232
+ private async waitForProcess(lp: LazyProcess): Promise<"stopped" | "error"> {
233
+ const state = await lp.waitForExit();
234
+ return state === "error" ? "error" : "stopped";
235
+ }
236
+ }