Просмотр исходного кода

fix: clean stale pid files when daemon startup fails

Ma 7 месяцев назад
Родитель
Сommit
424cf4c8e6
2 измененных файлов с 107 добавлено и 5 удалено
  1. 91 0
      packages/cli/src/__tests__/daemon.test.ts
  2. 16 5
      packages/cli/src/commands/daemon.ts

+ 91 - 0
packages/cli/src/__tests__/daemon.test.ts

@@ -0,0 +1,91 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const readFileMock = vi.fn();
+const writeFileMock = vi.fn();
+const unlinkMock = vi.fn();
+const endMock = vi.fn();
+const createWriteStreamMock = vi.fn(() => ({ end: endMock }));
+const schedulerStartMock = vi.fn();
+const schedulerStopMock = vi.fn();
+const logMock = vi.fn();
+const logErrorMock = vi.fn();
+
+vi.mock("node:fs/promises", () => ({
+  readFile: readFileMock,
+  writeFile: writeFileMock,
+  unlink: unlinkMock,
+}));
+
+vi.mock("node:fs", () => ({
+  createWriteStream: createWriteStreamMock,
+}));
+
+vi.mock("@actalk/inkos-core", () => ({
+  Scheduler: class {
+    start = schedulerStartMock;
+    stop = schedulerStopMock;
+  },
+}));
+
+vi.mock("../utils.js", () => ({
+  loadConfig: vi.fn(async () => ({
+    daemon: {
+      schedule: {
+        radarCron: "0 */6 * * *",
+        writeCron: "*/15 * * * *",
+      },
+      maxConcurrentBooks: 1,
+      chaptersPerCycle: 1,
+      retryDelayMs: 0,
+      cooldownAfterChapterMs: 0,
+      maxChaptersPerDay: 10,
+    },
+  })),
+  findProjectRoot: vi.fn(() => "/project"),
+  buildPipelineConfig: vi.fn(() => ({
+    client: {
+      provider: "openai",
+      apiFormat: "chat",
+      stream: false,
+      defaults: {
+        temperature: 0.7,
+        maxTokens: 1024,
+        thinkingBudget: 0,
+      },
+    },
+    model: "test-model",
+    projectRoot: "/project",
+  })),
+  log: logMock,
+  logError: logErrorMock,
+}));
+
+describe("daemon command", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.resetModules();
+  });
+
+  it("removes the pid file when startup fails after writing it", async () => {
+    readFileMock.mockRejectedValueOnce(new Error("missing pid"));
+    writeFileMock.mockResolvedValue(undefined);
+    unlinkMock.mockResolvedValue(undefined);
+    schedulerStartMock.mockRejectedValueOnce(new Error("scheduler boot failed"));
+
+    const exitError = new Error("process.exit");
+    const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
+      throw Object.assign(exitError, { code });
+    }) as never);
+
+    const { upCommand } = await import("../commands/daemon.js");
+
+    await expect(
+      upCommand.parseAsync(["node", "up", "--quiet"]),
+    ).rejects.toMatchObject({ code: 1 });
+
+    expect(writeFileMock).toHaveBeenCalledWith("/project/inkos.pid", expect.any(String), "utf-8");
+    expect(unlinkMock).toHaveBeenCalledWith("/project/inkos.pid");
+
+    exitSpy.mockRestore();
+  });
+});

+ 16 - 5
packages/cli/src/commands/daemon.ts

@@ -12,12 +12,13 @@ export const upCommand = new Command("up")
   .option("-q, --quiet", "Suppress console output")
   .action(async (opts) => {
     let logStream: WriteStream | undefined;
+    let pidPath: string | undefined;
     try {
       const config = await loadConfig();
       const root = findProjectRoot();
 
       // Check if already running
-      const pidPath = join(root, PID_FILE);
+      pidPath = join(root, PID_FILE);
       try {
         const existingPid = await readFile(pidPath, "utf-8");
         logError(`Daemon already running (PID: ${existingPid.trim()}). Run 'inkos down' first.`);
@@ -62,10 +63,13 @@ export const upCommand = new Command("up")
         log("\nShutting down daemon...");
         scheduler.stop();
         logStream?.end();
-        try {
-          await unlink(pidPath);
-        } catch {
-          // ignore
+        const currentPidPath = pidPath;
+        if (currentPidPath !== undefined) {
+          try {
+            await unlink(currentPidPath);
+          } catch {
+            // ignore
+          }
         }
         process.exit(0);
       };
@@ -80,6 +84,13 @@ export const upCommand = new Command("up")
       await new Promise(() => {});
     } catch (e) {
       logStream?.end();
+      if (pidPath !== undefined) {
+        try {
+          await unlink(pidPath);
+        } catch {
+          // ignore
+        }
+      }
       logError(`Failed to start daemon: ${e}`);
       process.exit(1);
     }