feinai 0.6.6 → 0.7.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,180 @@
1
+ import { existsSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import type { DbInstance } from "./db";
4
+
5
+ export interface ServerState {
6
+ port: number;
7
+ pid: number;
8
+ started_at: string;
9
+ }
10
+
11
+ /**
12
+ * Read the single server_state row from the DB.
13
+ * Returns null if no row exists.
14
+ */
15
+ export function readServerState(db: DbInstance): ServerState | null {
16
+ const row = db
17
+ .prepare("SELECT port, pid, started_at FROM server_state WHERE id = 1")
18
+ .get() as { port: number; pid: number; started_at: string } | undefined;
19
+ return row ?? null;
20
+ }
21
+
22
+ /**
23
+ * Write (insert or replace) the server_state row.
24
+ */
25
+ export function writeServerState(db: DbInstance, port: number, pid: number): void {
26
+ db.run(
27
+ `INSERT OR REPLACE INTO server_state (id, port, pid, started_at, updated_at)
28
+ VALUES (1, ?, ?, datetime('now'), datetime('now'))`,
29
+ port,
30
+ pid,
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Delete the server_state row.
36
+ */
37
+ export function clearServerState(db: DbInstance): void {
38
+ db.run("DELETE FROM server_state WHERE id = 1");
39
+ }
40
+
41
+ /**
42
+ * Check whether the given port is in use by any process.
43
+ * Uses lsof on Linux/macOS.
44
+ */
45
+ export function isPortInUse(port: number): boolean {
46
+ try {
47
+ const result = spawnSync("lsof", ["-ti", `tcp:${port}`]);
48
+ return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Scan upward from startPort until a free port is found.
56
+ * Returns the first free port.
57
+ */
58
+ export function findFreePort(startPort: number): number {
59
+ let port = startPort;
60
+ while (isPortInUse(port)) {
61
+ port++;
62
+ if (port > startPort + 100) {
63
+ throw new Error(
64
+ `No free port found after scanning from ${startPort} to ${port}`,
65
+ );
66
+ }
67
+ }
68
+ return port;
69
+ }
70
+
71
+ /**
72
+ * Return the pid(s) listening on the given port.
73
+ * Uses lsof -ti tcp:${port} and parses the output.
74
+ * Returns an array of pids, empty if none.
75
+ */
76
+ function pidsOnPort(port: number): number[] {
77
+ try {
78
+ const result = spawnSync("lsof", ["-ti", `tcp:${port}`]);
79
+ if (result.exitCode !== 0) return [];
80
+ const out = result.stdout.toString().trim();
81
+ if (!out) return [];
82
+ return out
83
+ .split("\n")
84
+ .map((s) => parseInt(s.trim(), 10))
85
+ .filter((n) => !isNaN(n));
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Check if a specific pid is running.
93
+ * Uses kill(pid, 0) via /proc on Linux or kill command.
94
+ */
95
+ function isPidRunning(pid: number): boolean {
96
+ try {
97
+ // kill(pid, 0) checks if the process exists without sending a signal
98
+ return process.kill(pid, 0);
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Get the port that a specific pid is listening on.
106
+ * Returns the port number or null if the pid has no tcp listeners.
107
+ */
108
+ function portOfPid(pid: number): number | null {
109
+ try {
110
+ const result = spawnSync("lsof", ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-p", String(pid)]);
111
+ if (result.exitCode !== 0) return null;
112
+ // Parse lsof output: find "TCP *:{port}" pattern
113
+ const out = result.stdout.toString();
114
+ const match = out.match(/TCP\s+\*:(\d+)/);
115
+ if (match) return parseInt(match[1]!, 10);
116
+ // Alternate format: "LISTEN" followed by port
117
+ const altMatch = out.match(/:(\d+)\s.*LISTEN/);
118
+ if (altMatch) return parseInt(altMatch[1]!, 10);
119
+ return null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Validate the server_state row for consistency.
127
+ *
128
+ * A row is considered valid only when:
129
+ * - The stored pid is running AND
130
+ * - That pid is listening on the stored port
131
+ *
132
+ * If the row is stale (in any way), it is deleted and false is returned.
133
+ *
134
+ * Returns { valid: boolean, record: ServerState | null }
135
+ */
136
+ export function validateServerState(db: DbInstance): {
137
+ valid: boolean;
138
+ record: ServerState | null;
139
+ } {
140
+ const record = readServerState(db);
141
+ if (!record) return { valid: false, record: null };
142
+
143
+ // Check if stored pid is running
144
+ if (!isPidRunning(record.pid)) {
145
+ clearServerState(db);
146
+ return { valid: false, record: null };
147
+ }
148
+
149
+ // Check what pids are listening on the stored port
150
+ const pids = pidsOnPort(record.port);
151
+ if (pids.length === 0) {
152
+ // Port has no listener at all
153
+ clearServerState(db);
154
+ return { valid: false, record: null };
155
+ }
156
+ if (!pids.includes(record.pid)) {
157
+ // Port is busy but by a different pid
158
+ clearServerState(db);
159
+ return { valid: false, record: null };
160
+ }
161
+
162
+ // Check that the stored pid is listening on the stored port specifically
163
+ const actualPort = portOfPid(record.pid);
164
+ if (actualPort !== null && actualPort !== record.port) {
165
+ // The pid is running but listening on a different port
166
+ clearServerState(db);
167
+ return { valid: false, record: null };
168
+ }
169
+
170
+ return { valid: true, record };
171
+ }
172
+
173
+ /**
174
+ * Repair the server_state row: validate consistency, delete if stale.
175
+ * Returns the valid record or null if stale/missing.
176
+ */
177
+ export function repairServerState(db: DbInstance): ServerState | null {
178
+ const { valid, record } = validateServerState(db);
179
+ return valid ? record : null;
180
+ }
package/src/server.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { dirname, basename, resolve, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { openDb, findDbPath, type DbInstance } from "./db";
5
+ import { clearServerState } from "./server-state";
5
6
  import {
6
7
  listTasks,
7
8
  getTask,
@@ -630,7 +631,18 @@ export function startServer(opts: ServerOptions): { url: string; stop: () => voi
630
631
 
631
632
  return {
632
633
  url: `http://${server.hostname}:${server.port}`,
633
- stop: () => server.stop(),
634
+ stop: () => {
635
+ // Clear the server_state row on graceful shutdown
636
+ try {
637
+ const db = openDb();
638
+ clearServerState(db);
639
+ db.close();
640
+ } catch {
641
+ // DB might not exist or be inaccessible; ignore to avoid masking
642
+ // the actual server stop.
643
+ }
644
+ server.stop();
645
+ },
634
646
  };
635
647
  }
636
648