cabletidy 0.1.0 → 0.2.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,129 @@
1
+ import callbackFs from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import lockfile from "proper-lockfile";
7
+ import { readRuntimeInfo } from "./config.mjs";
8
+ import { inspectProcess, processStartTime } from "./process-identity.mjs";
9
+
10
+ const STALE_MS = 10_000;
11
+ const ownerName = /^owner-[\da-f-]{36}\.json$/;
12
+
13
+ export async function acquireInstanceLock(paths) {
14
+ const identity = { pid: process.pid, startTime: await processStartTime(process.pid), hostname: os.hostname() };
15
+ const marker = `owner-${randomUUID()}.json`;
16
+ const filesystem = {
17
+ ...callbackFs,
18
+ mkdir(directory, callback) {
19
+ publishOwner(directory, marker, identity).then(() => callback(), callback);
20
+ },
21
+ rmdir(directory, callback) {
22
+ removeOwner(directory, marker).then(() => callback(), callback);
23
+ },
24
+ rmdirSync(directory) {
25
+ try {
26
+ callbackFs.unlinkSync(path.join(directory, marker));
27
+ callbackFs.rmdirSync(directory);
28
+ } catch (error) {
29
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
30
+ }
31
+ },
32
+ };
33
+ while (true) {
34
+ try {
35
+ const release = await lockfile.lock(paths.home, {
36
+ lockfilePath: paths.lock,
37
+ // Age alone must never evict a paused process. Reclaim is handled below.
38
+ stale: Number.MAX_SAFE_INTEGER,
39
+ update: 2000,
40
+ fs: filesystem,
41
+ });
42
+ return { release, identity };
43
+ } catch (error) {
44
+ if (error.code !== "ELOCKED") throw error;
45
+ const inspection = await inspectLock(paths);
46
+ if (!inspection) continue;
47
+ if (inspection.state === "alive" || !inspection.stale) {
48
+ throw Object.assign(new Error(
49
+ `此数据目录已有 CableTidy 实例运行,或异常退出后的锁尚未过期 (ELOCKED);异常退出后等待 10 秒再重试: ${paths.home}`,
50
+ ), { code: "ELOCKED" });
51
+ }
52
+ if (inspection.state === "unknown" || !inspection.marker) {
53
+ throw Object.assign(new Error(
54
+ `无法确认旧实例锁的进程身份 (ELOCKUNKNOWN${inspection.pid ? `, PID ${inspection.pid}` : ""})。请先检查并停止使用此数据目录的 CableTidy;确认实例已退出后,手动删除锁目录并重新启动: ${paths.lock}`,
55
+ ), { code: "ELOCKUNKNOWN" });
56
+ }
57
+ await removeOwner(paths.lock, inspection.marker);
58
+ }
59
+ }
60
+ }
61
+
62
+ async function publishOwner(directory, marker, identity) {
63
+ // Publish a populated directory atomically, so reclaimers never see a new
64
+ // owner without its identity and cannot rmdir a newly acquired lock.
65
+ try {
66
+ await fs.lstat(directory);
67
+ throw Object.assign(new Error("Lock exists"), { code: "EEXIST" });
68
+ } catch (error) {
69
+ if (error.code !== "ENOENT") throw error;
70
+ }
71
+ const staged = await fs.mkdtemp(path.join(path.dirname(directory), ".daemon-lock-"));
72
+ try {
73
+ await fs.writeFile(path.join(staged, marker), JSON.stringify(identity), { mode: 0o600, flag: "wx" });
74
+ try {
75
+ await fs.rename(staged, directory);
76
+ } catch (error) {
77
+ // Windows reports EPERM/EACCES for an existing destination directory.
78
+ if (["ENOTEMPTY", "EEXIST", "EPERM", "EACCES"].includes(error.code)) {
79
+ await fs.lstat(directory);
80
+ throw Object.assign(error, { code: "EEXIST" });
81
+ }
82
+ throw error;
83
+ }
84
+ } finally {
85
+ await fs.rm(staged, { recursive: true, force: true });
86
+ }
87
+ }
88
+
89
+ async function removeOwner(directory, marker) {
90
+ try {
91
+ // Only remove the inspected generation. A losing reclaimer cannot remove
92
+ // the new owner's unique marker, and rmdir refuses a populated directory.
93
+ await fs.unlink(path.join(directory, marker));
94
+ await fs.rmdir(directory);
95
+ } catch (error) {
96
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
97
+ }
98
+ }
99
+
100
+ async function inspectLock(paths) {
101
+ try {
102
+ const entries = await fs.readdir(paths.lock);
103
+ const markers = entries.filter(name => ownerName.test(name) || name === "owner.json");
104
+ const marker = entries.length === 1 && markers.length === 1 ? markers[0] : null;
105
+ let identity;
106
+ if (marker) {
107
+ try { identity = JSON.parse(await fs.readFile(path.join(paths.lock, marker), "utf8")); }
108
+ catch (error) {
109
+ if (error.code === "ENOENT") return null;
110
+ if (error.name !== "SyntaxError") throw error;
111
+ }
112
+ } else if (!entries.length) {
113
+ // Legacy empty locks have no generation token, so even a dead runtime
114
+ // PID cannot make their removal safe against concurrent reclaimers.
115
+ try {
116
+ const runtime = await readRuntimeInfo(paths);
117
+ identity = runtime && { pid: runtime.pid, startTime: runtime.pidStartTime };
118
+ } catch { /* Missing or malformed legacy metadata requires manual recovery. */ }
119
+ }
120
+ const state = await inspectProcess(identity);
121
+ // Another reclaimer may have removed its marker while we read the entries.
122
+ // Read freshness last so that transition is not mistaken for an old empty lock.
123
+ const stat = await fs.stat(paths.lock);
124
+ return { marker, pid: identity?.pid, state, stale: Date.now() - stat.mtimeMs >= STALE_MS };
125
+ } catch (error) {
126
+ if (error.code === "ENOENT") return null;
127
+ throw error;
128
+ }
129
+ }
@@ -11,7 +11,7 @@ export function findModelProfile(config, clientModelId, allowedModels) {
11
11
  if (!clientModelId) return null;
12
12
  const allowed = Array.isArray(allowedModels) ? new Set(allowedModels) : null;
13
13
  if (config.models?.[clientModelId] && (!allowed || allowed.has(clientModelId))) {
14
- return { profileId: clientModelId, profile: config.models[clientModelId], matchedBy: "id" };
14
+ return { profileId: clientModelId, profile: config.models[clientModelId] };
15
15
  }
16
16
  for (const [profileId, profile] of Object.entries(config.models || {})) {
17
17
  if (allowed && !allowed.has(profileId)) continue;
@@ -19,7 +19,7 @@ export function findModelProfile(config, clientModelId, allowedModels) {
19
19
  profile.clientModelId === clientModelId ||
20
20
  (profile.aliases || []).includes(clientModelId)
21
21
  ) {
22
- return { profileId, profile, matchedBy: "alias" };
22
+ return { profileId, profile };
23
23
  }
24
24
  }
25
25
  return null;
@@ -63,14 +63,13 @@ export function resolveModelProfile(config, virtualProvider, requestedModel) {
63
63
  // allowedModels scopes optional overrides, not the models a client may request.
64
64
  const match = findModelProfile(config, clientModelId, virtualProvider?.allowedModels || []);
65
65
  if (!match) {
66
- return { clientModelId, profileId: null, profile: null, matchedBy: "passthrough" };
66
+ return { clientModelId, profileId: null, profile: null };
67
67
  }
68
68
 
69
69
  return {
70
70
  clientModelId: requestedModel || clientModelIdForProfile(match.profileId, match.profile),
71
71
  profileId: match.profileId,
72
72
  profile: match.profile,
73
- matchedBy: match.matchedBy,
74
73
  };
75
74
  }
76
75
 
@@ -139,7 +138,9 @@ export function selectBackend(config, virtualProvider, modelResolution, request
139
138
  reason = "model_binding_missing";
140
139
  } else if (virtualProvider.ingressProtocol !== upstream.protocol) {
141
140
  reason = "protocol_transform_missing";
142
- } else if (profile && !hasCapabilities(capabilities, required)) {
141
+ } else if (profile &&
142
+ (virtualProvider.ingressProtocol !== "anthropic.messages" || profile.capabilities !== undefined || modelBinding?.capabilityOverrides?.length) &&
143
+ !hasCapabilities(capabilities, required)) {
143
144
  reason = "capability_missing";
144
145
  }
145
146
  if (reason) {
@@ -155,12 +156,9 @@ export function selectBackend(config, virtualProvider, modelResolution, request
155
156
  }
156
157
  return {
157
158
  routeId: virtualProvider.route,
158
- backend,
159
- backendIndex: 0,
160
159
  upstream,
161
160
  upstreamModelId: modelBinding?.upstreamModelId || modelResolution.clientModelId,
162
161
  capabilities: [...capabilities],
163
- rejected: [],
164
162
  };
165
163
  }
166
164
 
@@ -0,0 +1,72 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+
7
+ const execute = promisify(execFile);
8
+ export const powershell = path.win32.join(
9
+ process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe",
10
+ );
11
+
12
+ export async function processStartTime(pid, {
13
+ platform = process.platform, readFile = fs.readFile, run = execute,
14
+ } = {}) {
15
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
16
+ try {
17
+ if (platform === "linux") {
18
+ const [stat, boot] = await Promise.all([
19
+ readFile(`/proc/${pid}/stat`, "utf8"),
20
+ readFile("/proc/sys/kernel/random/boot_id", "utf8"),
21
+ ]);
22
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/);
23
+ if (!/^\d+$/.test(fields[19]) || !/^[\da-f-]{36}$/i.test(boot.trim())) return null;
24
+ return `linux:${boot.trim()}:${fields[19]}`;
25
+ }
26
+ const options = { timeout: 5000, maxBuffer: 4096, windowsHide: true };
27
+ if (platform === "darwin") {
28
+ const { stdout } = await run("/bin/ps", ["-p", String(pid), "-o", "lstart="], {
29
+ ...options, env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
30
+ });
31
+ const value = stdout.trim().replace(/\s+/g, " ");
32
+ return /^[A-Z][a-z]{2} [A-Z][a-z]{2} \d{1,2} \d{2}:\d{2}:\d{2} \d{4}$/.test(value)
33
+ ? `darwin:${value}` : null;
34
+ }
35
+ if (platform === "win32") {
36
+ const { stdout } = await run(powershell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
37
+ `$ErrorActionPreference = 'Stop'; (Get-Process -Id ${pid}).StartTime.ToUniversalTime().Ticks.ToString()`,
38
+ ], options);
39
+ return /^\d{17,}$/.test(stdout.trim()) ? `win32:${stdout.trim()}` : null;
40
+ }
41
+ } catch {
42
+ // Query failures do not prove that a PID is dead or still has its old owner.
43
+ }
44
+ return null;
45
+ }
46
+
47
+ export async function inspectProcess(identity, {
48
+ kill = process.kill, readStartTime = processStartTime, hostname = os.hostname(),
49
+ } = {}) {
50
+ const pid = identity?.pid;
51
+ if (!Number.isSafeInteger(pid) || pid <= 0 || (identity.hostname && identity.hostname !== hostname)) {
52
+ return "unknown";
53
+ }
54
+ try {
55
+ kill(pid, 0);
56
+ } catch (error) {
57
+ if (error.code === "ESRCH") return "dead";
58
+ if (error.code !== "EPERM") return "unknown";
59
+ }
60
+ if (typeof identity.startTime !== "string" || !identity.startTime) return "unknown";
61
+ const current = await readStartTime(pid);
62
+ if (!current) return "unknown";
63
+ if (/^(linux|darwin|win32):/.test(identity.startTime)) {
64
+ if (identity.startTime.split(":")[0] !== current.split(":")[0]) return "unknown";
65
+ return current === identity.startTime ? "alive" : "dead";
66
+ }
67
+ // Old Linux ticks can disprove ownership, but without a boot ID cannot prove it.
68
+ if (/^\d+$/.test(identity.startTime) && current.startsWith("linux:") && current.split(":").at(-1) !== identity.startTime) {
69
+ return "dead";
70
+ }
71
+ return "unknown";
72
+ }