@oh-my-pi/pi-coding-agent 5.5.0 → 5.6.70
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.
- package/CHANGELOG.md +105 -0
- package/docs/python-repl.md +77 -0
- package/examples/hooks/snake.ts +7 -7
- package/package.json +5 -5
- package/src/bun-imports.d.ts +6 -0
- package/src/cli/args.ts +7 -0
- package/src/cli/setup-cli.ts +231 -0
- package/src/cli.ts +2 -0
- package/src/core/agent-session.ts +118 -15
- package/src/core/bash-executor.ts +3 -84
- package/src/core/compaction/compaction.ts +10 -5
- package/src/core/extensions/index.ts +2 -0
- package/src/core/extensions/loader.ts +13 -1
- package/src/core/extensions/runner.ts +50 -2
- package/src/core/extensions/types.ts +67 -2
- package/src/core/keybindings.ts +51 -1
- package/src/core/prompt-templates.ts +15 -0
- package/src/core/python-executor-display.test.ts +42 -0
- package/src/core/python-executor-lifecycle.test.ts +99 -0
- package/src/core/python-executor-mapping.test.ts +41 -0
- package/src/core/python-executor-per-call.test.ts +49 -0
- package/src/core/python-executor-session.test.ts +103 -0
- package/src/core/python-executor-streaming.test.ts +77 -0
- package/src/core/python-executor-timeout.test.ts +35 -0
- package/src/core/python-executor.lifecycle.test.ts +139 -0
- package/src/core/python-executor.result.test.ts +49 -0
- package/src/core/python-executor.test.ts +180 -0
- package/src/core/python-executor.ts +313 -0
- package/src/core/python-gateway-coordinator.ts +832 -0
- package/src/core/python-kernel-display.test.ts +54 -0
- package/src/core/python-kernel-env.test.ts +138 -0
- package/src/core/python-kernel-session.test.ts +87 -0
- package/src/core/python-kernel-ws.test.ts +104 -0
- package/src/core/python-kernel.lifecycle.test.ts +249 -0
- package/src/core/python-kernel.test.ts +461 -0
- package/src/core/python-kernel.ts +1182 -0
- package/src/core/python-modules.test.ts +102 -0
- package/src/core/python-modules.ts +110 -0
- package/src/core/python-prelude.py +889 -0
- package/src/core/python-prelude.test.ts +140 -0
- package/src/core/python-prelude.ts +3 -0
- package/src/core/sdk.ts +24 -6
- package/src/core/session-manager.ts +174 -82
- package/src/core/settings-manager-python.test.ts +23 -0
- package/src/core/settings-manager.ts +202 -0
- package/src/core/streaming-output.test.ts +26 -0
- package/src/core/streaming-output.ts +100 -0
- package/src/core/system-prompt.python.test.ts +17 -0
- package/src/core/system-prompt.ts +3 -1
- package/src/core/timings.ts +1 -1
- package/src/core/tools/bash.ts +13 -2
- package/src/core/tools/edit-diff.ts +9 -1
- package/src/core/tools/index.test.ts +50 -23
- package/src/core/tools/index.ts +83 -1
- package/src/core/tools/python-execution.test.ts +68 -0
- package/src/core/tools/python-fallback.test.ts +72 -0
- package/src/core/tools/python-renderer.test.ts +36 -0
- package/src/core/tools/python-tool-mode.test.ts +43 -0
- package/src/core/tools/python.test.ts +121 -0
- package/src/core/tools/python.ts +760 -0
- package/src/core/tools/renderers.ts +2 -0
- package/src/core/tools/schema-validation.test.ts +1 -0
- package/src/core/tools/task/executor.ts +146 -3
- package/src/core/tools/task/worker-protocol.ts +32 -2
- package/src/core/tools/task/worker.ts +182 -15
- package/src/index.ts +6 -0
- package/src/main.ts +136 -40
- package/src/modes/interactive/components/custom-editor.ts +16 -31
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
- package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
- package/src/modes/interactive/components/history-search.ts +5 -8
- package/src/modes/interactive/components/hook-editor.ts +3 -4
- package/src/modes/interactive/components/hook-input.ts +3 -3
- package/src/modes/interactive/components/hook-selector.ts +5 -15
- package/src/modes/interactive/components/index.ts +1 -0
- package/src/modes/interactive/components/keybinding-hints.ts +66 -0
- package/src/modes/interactive/components/model-selector.ts +53 -66
- package/src/modes/interactive/components/oauth-selector.ts +5 -5
- package/src/modes/interactive/components/session-selector.ts +29 -23
- package/src/modes/interactive/components/settings-defs.ts +404 -196
- package/src/modes/interactive/components/settings-selector.ts +14 -10
- package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
- package/src/modes/interactive/components/tool-execution.ts +8 -0
- package/src/modes/interactive/components/tree-selector.ts +29 -23
- package/src/modes/interactive/components/user-message-selector.ts +6 -17
- package/src/modes/interactive/controllers/command-controller.ts +86 -37
- package/src/modes/interactive/controllers/event-controller.ts +8 -0
- package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
- package/src/modes/interactive/controllers/input-controller.ts +42 -6
- package/src/modes/interactive/interactive-mode.ts +56 -30
- package/src/modes/interactive/theme/theme-schema.json +2 -2
- package/src/modes/interactive/types.ts +6 -1
- package/src/modes/interactive/utils/ui-helpers.ts +2 -1
- package/src/modes/print-mode.ts +23 -0
- package/src/modes/rpc/rpc-mode.ts +21 -0
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/system/system-prompt.md +32 -1
- package/src/prompts/tools/python.md +91 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
statSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
utimesSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { createServer } from "node:net";
|
|
15
|
+
import { delimiter, join } from "node:path";
|
|
16
|
+
import type { Subprocess } from "bun";
|
|
17
|
+
import { getAgentDir } from "../config";
|
|
18
|
+
import { getShellConfig, killProcessTree } from "../utils/shell";
|
|
19
|
+
import { getOrCreateSnapshot } from "../utils/shell-snapshot";
|
|
20
|
+
import { logger } from "./logger";
|
|
21
|
+
|
|
22
|
+
const GATEWAY_DIR_NAME = "python-gateway";
|
|
23
|
+
const GATEWAY_INFO_FILE = "gateway.json";
|
|
24
|
+
const GATEWAY_LOCK_FILE = "gateway.lock";
|
|
25
|
+
const GATEWAY_CLIENT_PREFIX = "client-";
|
|
26
|
+
const GATEWAY_STARTUP_TIMEOUT_MS = 30000;
|
|
27
|
+
const GATEWAY_IDLE_TIMEOUT_MS = 30000;
|
|
28
|
+
const GATEWAY_LOCK_TIMEOUT_MS = GATEWAY_STARTUP_TIMEOUT_MS + 5000;
|
|
29
|
+
const GATEWAY_LOCK_RETRY_MS = 50;
|
|
30
|
+
const GATEWAY_LOCK_STALE_MS = GATEWAY_STARTUP_TIMEOUT_MS * 2;
|
|
31
|
+
const GATEWAY_LOCK_HEARTBEAT_MS = 5000;
|
|
32
|
+
const HEALTH_CHECK_TIMEOUT_MS = 3000;
|
|
33
|
+
|
|
34
|
+
const DEFAULT_ENV_ALLOWLIST = new Set([
|
|
35
|
+
"PATH",
|
|
36
|
+
"HOME",
|
|
37
|
+
"USER",
|
|
38
|
+
"LOGNAME",
|
|
39
|
+
"SHELL",
|
|
40
|
+
"LANG",
|
|
41
|
+
"LC_ALL",
|
|
42
|
+
"LC_CTYPE",
|
|
43
|
+
"LC_MESSAGES",
|
|
44
|
+
"TERM",
|
|
45
|
+
"TERM_PROGRAM",
|
|
46
|
+
"TERM_PROGRAM_VERSION",
|
|
47
|
+
"TMPDIR",
|
|
48
|
+
"TEMP",
|
|
49
|
+
"TMP",
|
|
50
|
+
"XDG_CACHE_HOME",
|
|
51
|
+
"XDG_CONFIG_HOME",
|
|
52
|
+
"XDG_DATA_HOME",
|
|
53
|
+
"XDG_RUNTIME_DIR",
|
|
54
|
+
"SSH_AUTH_SOCK",
|
|
55
|
+
"SSH_AGENT_PID",
|
|
56
|
+
"CONDA_PREFIX",
|
|
57
|
+
"CONDA_DEFAULT_ENV",
|
|
58
|
+
"VIRTUAL_ENV",
|
|
59
|
+
"PYTHONPATH",
|
|
60
|
+
"SYSTEMROOT",
|
|
61
|
+
"COMSPEC",
|
|
62
|
+
"WINDIR",
|
|
63
|
+
"USERPROFILE",
|
|
64
|
+
"LOCALAPPDATA",
|
|
65
|
+
"APPDATA",
|
|
66
|
+
"PROGRAMDATA",
|
|
67
|
+
"PATHEXT",
|
|
68
|
+
"USERNAME",
|
|
69
|
+
"HOMEDRIVE",
|
|
70
|
+
"HOMEPATH",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
const WINDOWS_ENV_ALLOWLIST = new Set([
|
|
74
|
+
"APPDATA",
|
|
75
|
+
"COMPUTERNAME",
|
|
76
|
+
"COMSPEC",
|
|
77
|
+
"HOMEDRIVE",
|
|
78
|
+
"HOMEPATH",
|
|
79
|
+
"LOCALAPPDATA",
|
|
80
|
+
"NUMBER_OF_PROCESSORS",
|
|
81
|
+
"OS",
|
|
82
|
+
"PATH",
|
|
83
|
+
"PATHEXT",
|
|
84
|
+
"PROCESSOR_ARCHITECTURE",
|
|
85
|
+
"PROCESSOR_IDENTIFIER",
|
|
86
|
+
"PROGRAMDATA",
|
|
87
|
+
"PROGRAMFILES",
|
|
88
|
+
"PROGRAMFILES(X86)",
|
|
89
|
+
"PROGRAMW6432",
|
|
90
|
+
"SESSIONNAME",
|
|
91
|
+
"SYSTEMDRIVE",
|
|
92
|
+
"SYSTEMROOT",
|
|
93
|
+
"TEMP",
|
|
94
|
+
"TMP",
|
|
95
|
+
"USERDOMAIN",
|
|
96
|
+
"USERDOMAIN_ROAMINGPROFILE",
|
|
97
|
+
"USERPROFILE",
|
|
98
|
+
"USERNAME",
|
|
99
|
+
"WINDIR",
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
const DEFAULT_ENV_ALLOW_PREFIXES = ["LC_", "XDG_", "OMP_"];
|
|
103
|
+
|
|
104
|
+
const DEFAULT_ENV_DENYLIST = new Set([
|
|
105
|
+
"OPENAI_API_KEY",
|
|
106
|
+
"ANTHROPIC_API_KEY",
|
|
107
|
+
"GOOGLE_API_KEY",
|
|
108
|
+
"GEMINI_API_KEY",
|
|
109
|
+
"OPENROUTER_API_KEY",
|
|
110
|
+
"PERPLEXITY_API_KEY",
|
|
111
|
+
"EXA_API_KEY",
|
|
112
|
+
"AZURE_OPENAI_API_KEY",
|
|
113
|
+
"MISTRAL_API_KEY",
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const CASE_INSENSITIVE_ENV = process.platform === "win32";
|
|
117
|
+
const ACTIVE_ENV_ALLOWLIST = CASE_INSENSITIVE_ENV ? WINDOWS_ENV_ALLOWLIST : DEFAULT_ENV_ALLOWLIST;
|
|
118
|
+
|
|
119
|
+
const NORMALIZED_ALLOWLIST = new Set(
|
|
120
|
+
Array.from(ACTIVE_ENV_ALLOWLIST, (key) => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)),
|
|
121
|
+
);
|
|
122
|
+
const NORMALIZED_DENYLIST = new Set(
|
|
123
|
+
Array.from(DEFAULT_ENV_DENYLIST, (key) => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)),
|
|
124
|
+
);
|
|
125
|
+
const NORMALIZED_ALLOW_PREFIXES = CASE_INSENSITIVE_ENV
|
|
126
|
+
? DEFAULT_ENV_ALLOW_PREFIXES.map((prefix) => prefix.toUpperCase())
|
|
127
|
+
: DEFAULT_ENV_ALLOW_PREFIXES;
|
|
128
|
+
|
|
129
|
+
function normalizeEnvKey(key: string): string {
|
|
130
|
+
return CASE_INSENSITIVE_ENV ? key.toUpperCase() : key;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolvePathKey(env: Record<string, string | undefined>): string {
|
|
134
|
+
if (!CASE_INSENSITIVE_ENV) return "PATH";
|
|
135
|
+
const match = Object.keys(env).find((candidate) => candidate.toLowerCase() === "path");
|
|
136
|
+
return match ?? "PATH";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface GatewayInfo {
|
|
140
|
+
url: string;
|
|
141
|
+
pid: number;
|
|
142
|
+
startedAt: number;
|
|
143
|
+
refCount: number;
|
|
144
|
+
cwd: string;
|
|
145
|
+
pythonPath?: string;
|
|
146
|
+
venvPath?: string | null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface GatewayLockInfo {
|
|
150
|
+
pid: number;
|
|
151
|
+
startedAt: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface AcquireResult {
|
|
155
|
+
url: string;
|
|
156
|
+
isShared: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let localGatewayProcess: Subprocess | null = null;
|
|
160
|
+
let localGatewayUrl: string | null = null;
|
|
161
|
+
let idleShutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
|
162
|
+
let isCoordinatorInitialized = false;
|
|
163
|
+
let localClientFile: string | null = null;
|
|
164
|
+
|
|
165
|
+
function filterEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
|
166
|
+
const filtered: Record<string, string | undefined> = {};
|
|
167
|
+
for (const [key, value] of Object.entries(env)) {
|
|
168
|
+
if (value === undefined) continue;
|
|
169
|
+
const normalizedKey = normalizeEnvKey(key);
|
|
170
|
+
if (NORMALIZED_DENYLIST.has(normalizedKey)) continue;
|
|
171
|
+
if (NORMALIZED_ALLOWLIST.has(normalizedKey)) {
|
|
172
|
+
filtered[key] = value;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (NORMALIZED_ALLOW_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix))) {
|
|
176
|
+
filtered[key] = value;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return filtered;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function resolveVenvPath(cwd: string): Promise<string | null> {
|
|
183
|
+
if (process.env.VIRTUAL_ENV) return process.env.VIRTUAL_ENV;
|
|
184
|
+
const candidates = [join(cwd, ".venv"), join(cwd, "venv")];
|
|
185
|
+
for (const candidate of candidates) {
|
|
186
|
+
if (await Bun.file(candidate).exists()) {
|
|
187
|
+
return candidate;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>) {
|
|
194
|
+
const env = { ...baseEnv };
|
|
195
|
+
const venvPath = env.VIRTUAL_ENV ?? (await resolveVenvPath(cwd));
|
|
196
|
+
if (venvPath) {
|
|
197
|
+
env.VIRTUAL_ENV = venvPath;
|
|
198
|
+
const binDir = process.platform === "win32" ? join(venvPath, "Scripts") : join(venvPath, "bin");
|
|
199
|
+
const pythonCandidate = join(binDir, process.platform === "win32" ? "python.exe" : "python");
|
|
200
|
+
if (await Bun.file(pythonCandidate).exists()) {
|
|
201
|
+
const pathKey = resolvePathKey(env);
|
|
202
|
+
const currentPath = env[pathKey];
|
|
203
|
+
env[pathKey] = currentPath ? `${binDir}${delimiter}${currentPath}` : binDir;
|
|
204
|
+
return { pythonPath: pythonCandidate, env, venvPath };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const pythonPath = Bun.which("python") ?? Bun.which("python3");
|
|
209
|
+
if (!pythonPath) {
|
|
210
|
+
throw new Error("Python executable not found on PATH");
|
|
211
|
+
}
|
|
212
|
+
return { pythonPath, env, venvPath: null };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function allocatePort(): Promise<number> {
|
|
216
|
+
return await new Promise((resolve, reject) => {
|
|
217
|
+
const server = createServer();
|
|
218
|
+
server.unref();
|
|
219
|
+
server.on("error", reject);
|
|
220
|
+
server.listen(0, "127.0.0.1", () => {
|
|
221
|
+
const address = server.address();
|
|
222
|
+
if (address && typeof address === "object") {
|
|
223
|
+
const port = address.port;
|
|
224
|
+
server.close((err: Error | null | undefined) => {
|
|
225
|
+
if (err) {
|
|
226
|
+
reject(err);
|
|
227
|
+
} else {
|
|
228
|
+
resolve(port);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
} else {
|
|
232
|
+
server.close();
|
|
233
|
+
reject(new Error("Failed to allocate port"));
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function getGatewayDir(): string {
|
|
240
|
+
return join(getAgentDir(), GATEWAY_DIR_NAME);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function getGatewayInfoPath(): string {
|
|
244
|
+
return join(getGatewayDir(), GATEWAY_INFO_FILE);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function getGatewayLockPath(): string {
|
|
248
|
+
return join(getGatewayDir(), GATEWAY_LOCK_FILE);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function writeLockInfo(lockPath: string, fd: number): void {
|
|
252
|
+
const payload: GatewayLockInfo = { pid: process.pid, startedAt: Date.now() };
|
|
253
|
+
try {
|
|
254
|
+
writeFileSync(fd, JSON.stringify(payload));
|
|
255
|
+
} catch {
|
|
256
|
+
try {
|
|
257
|
+
writeFileSync(lockPath, JSON.stringify(payload));
|
|
258
|
+
} catch {
|
|
259
|
+
// Ignore lock write failures
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function readLockInfo(lockPath: string): GatewayLockInfo | null {
|
|
265
|
+
try {
|
|
266
|
+
const raw = readFileSync(lockPath, "utf-8");
|
|
267
|
+
const parsed = JSON.parse(raw) as Partial<GatewayLockInfo>;
|
|
268
|
+
if (typeof parsed.pid === "number" && Number.isFinite(parsed.pid)) {
|
|
269
|
+
return { pid: parsed.pid, startedAt: typeof parsed.startedAt === "number" ? parsed.startedAt : 0 };
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
// Ignore parse errors
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function ensureGatewayDir(): void {
|
|
278
|
+
const dir = getGatewayDir();
|
|
279
|
+
if (!existsSync(dir)) {
|
|
280
|
+
mkdirSync(dir, { recursive: true });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function withGatewayLock<T>(handler: () => Promise<T>): Promise<T> {
|
|
285
|
+
ensureGatewayDir();
|
|
286
|
+
const lockPath = getGatewayLockPath();
|
|
287
|
+
const start = Date.now();
|
|
288
|
+
while (true) {
|
|
289
|
+
try {
|
|
290
|
+
const fd = openSync(lockPath, "wx");
|
|
291
|
+
const heartbeat = setInterval(() => {
|
|
292
|
+
try {
|
|
293
|
+
const now = new Date();
|
|
294
|
+
utimesSync(lockPath, now, now);
|
|
295
|
+
} catch {
|
|
296
|
+
// Ignore heartbeat errors
|
|
297
|
+
}
|
|
298
|
+
}, GATEWAY_LOCK_HEARTBEAT_MS);
|
|
299
|
+
try {
|
|
300
|
+
writeLockInfo(lockPath, fd);
|
|
301
|
+
return await handler();
|
|
302
|
+
} finally {
|
|
303
|
+
clearInterval(heartbeat);
|
|
304
|
+
try {
|
|
305
|
+
closeSync(fd);
|
|
306
|
+
unlinkSync(lockPath);
|
|
307
|
+
} catch {
|
|
308
|
+
// Ignore lock cleanup errors
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
} catch (err) {
|
|
312
|
+
const error = err as NodeJS.ErrnoException;
|
|
313
|
+
if (error.code === "EEXIST") {
|
|
314
|
+
let removedStale = false;
|
|
315
|
+
try {
|
|
316
|
+
const stat = statSync(lockPath);
|
|
317
|
+
const lockInfo = readLockInfo(lockPath);
|
|
318
|
+
const lockPid = lockInfo?.pid;
|
|
319
|
+
const lockAgeMs = lockInfo?.startedAt ? Date.now() - lockInfo.startedAt : Date.now() - stat.mtimeMs;
|
|
320
|
+
const staleByTime = lockAgeMs > GATEWAY_LOCK_STALE_MS;
|
|
321
|
+
const staleByPid = lockPid !== undefined && !isPidRunning(lockPid);
|
|
322
|
+
const staleByMissingPid = lockPid === undefined && staleByTime;
|
|
323
|
+
if (staleByPid || staleByMissingPid) {
|
|
324
|
+
unlinkSync(lockPath);
|
|
325
|
+
removedStale = true;
|
|
326
|
+
logger.warn("Removed stale shared gateway lock", { path: lockPath, pid: lockPid });
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
// Ignore stat errors; keep waiting
|
|
330
|
+
}
|
|
331
|
+
if (!removedStale) {
|
|
332
|
+
if (Date.now() - start > GATEWAY_LOCK_TIMEOUT_MS) {
|
|
333
|
+
throw new Error("Timed out waiting for shared gateway lock");
|
|
334
|
+
}
|
|
335
|
+
await Bun.sleep(GATEWAY_LOCK_RETRY_MS);
|
|
336
|
+
}
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
throw err;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function readGatewayInfo(): GatewayInfo | null {
|
|
345
|
+
const infoPath = getGatewayInfoPath();
|
|
346
|
+
if (!existsSync(infoPath)) return null;
|
|
347
|
+
try {
|
|
348
|
+
const content = readFileSync(infoPath, "utf-8");
|
|
349
|
+
const parsed = JSON.parse(content) as Partial<GatewayInfo>;
|
|
350
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
351
|
+
if (typeof parsed.url !== "string" || typeof parsed.pid !== "number" || typeof parsed.startedAt !== "number") {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
if (typeof parsed.cwd !== "string") return null;
|
|
355
|
+
const clients = pruneStaleClientInfos(listClientInfos());
|
|
356
|
+
const totalRefCount = clients.reduce((sum, client) => sum + client.info.refCount, 0);
|
|
357
|
+
const recoveredRefCount = clients.length > 0 ? totalRefCount : 0;
|
|
358
|
+
return {
|
|
359
|
+
url: parsed.url,
|
|
360
|
+
pid: parsed.pid,
|
|
361
|
+
startedAt: parsed.startedAt,
|
|
362
|
+
refCount: recoveredRefCount,
|
|
363
|
+
cwd: parsed.cwd,
|
|
364
|
+
pythonPath: typeof parsed.pythonPath === "string" ? parsed.pythonPath : undefined,
|
|
365
|
+
venvPath: typeof parsed.venvPath === "string" || parsed.venvPath === null ? parsed.venvPath : undefined,
|
|
366
|
+
};
|
|
367
|
+
} catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function writeGatewayInfo(info: GatewayInfo): void {
|
|
373
|
+
const infoPath = getGatewayInfoPath();
|
|
374
|
+
const tempPath = `${infoPath}.tmp`;
|
|
375
|
+
writeFileSync(tempPath, JSON.stringify(info, null, 2));
|
|
376
|
+
renameSync(tempPath, infoPath);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function clearGatewayInfo(): void {
|
|
380
|
+
const infoPath = getGatewayInfoPath();
|
|
381
|
+
if (existsSync(infoPath)) {
|
|
382
|
+
try {
|
|
383
|
+
unlinkSync(infoPath);
|
|
384
|
+
} catch {
|
|
385
|
+
// Ignore errors on cleanup
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function isPidRunning(pid: number): boolean {
|
|
391
|
+
try {
|
|
392
|
+
process.kill(pid, 0);
|
|
393
|
+
return true;
|
|
394
|
+
} catch {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
interface GatewayClientInfo {
|
|
400
|
+
pid: number;
|
|
401
|
+
refCount: number;
|
|
402
|
+
updatedAt?: number;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function getClientFilePath(pid: number): string {
|
|
406
|
+
return join(getGatewayDir(), `${GATEWAY_CLIENT_PREFIX}${pid}.json`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function readClientInfo(path: string): GatewayClientInfo | null {
|
|
410
|
+
try {
|
|
411
|
+
const raw = readFileSync(path, "utf-8");
|
|
412
|
+
const parsed = JSON.parse(raw) as GatewayClientInfo;
|
|
413
|
+
if (typeof parsed.pid !== "number" || typeof parsed.refCount !== "number") return null;
|
|
414
|
+
return parsed;
|
|
415
|
+
} catch {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function listClientInfos(): Array<{ path: string; info: GatewayClientInfo }> {
|
|
421
|
+
const dir = getGatewayDir();
|
|
422
|
+
if (!existsSync(dir)) return [];
|
|
423
|
+
const entries = readdirSync(dir);
|
|
424
|
+
const results: Array<{ path: string; info: GatewayClientInfo }> = [];
|
|
425
|
+
for (const entry of entries) {
|
|
426
|
+
if (!entry.startsWith(GATEWAY_CLIENT_PREFIX)) continue;
|
|
427
|
+
const path = join(dir, entry);
|
|
428
|
+
const info = readClientInfo(path);
|
|
429
|
+
if (!info) continue;
|
|
430
|
+
results.push({ path, info });
|
|
431
|
+
}
|
|
432
|
+
return results;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function pruneStaleClientInfos(
|
|
436
|
+
clients: Array<{ path: string; info: GatewayClientInfo }>,
|
|
437
|
+
): Array<{ path: string; info: GatewayClientInfo }> {
|
|
438
|
+
const active: Array<{ path: string; info: GatewayClientInfo }> = [];
|
|
439
|
+
for (const client of clients) {
|
|
440
|
+
if (!isPidRunning(client.info.pid)) {
|
|
441
|
+
try {
|
|
442
|
+
unlinkSync(client.path);
|
|
443
|
+
} catch {
|
|
444
|
+
// Ignore cleanup errors
|
|
445
|
+
}
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
active.push(client);
|
|
449
|
+
}
|
|
450
|
+
return active;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function updateLocalClientRefCount(delta: number): { totalRefCount: number; localRefCount: number } {
|
|
454
|
+
ensureGatewayDir();
|
|
455
|
+
const clients = pruneStaleClientInfos(listClientInfos());
|
|
456
|
+
const localPath = localClientFile ?? getClientFilePath(process.pid);
|
|
457
|
+
const localEntry = clients.find((client) => client.info.pid === process.pid);
|
|
458
|
+
const baseCount = localEntry?.info.refCount ?? 0;
|
|
459
|
+
const nextCount = Math.max(0, baseCount + delta);
|
|
460
|
+
const otherClients = clients.filter((client) => client.info.pid !== process.pid);
|
|
461
|
+
|
|
462
|
+
if (nextCount <= 0) {
|
|
463
|
+
if (localEntry) {
|
|
464
|
+
try {
|
|
465
|
+
unlinkSync(localEntry.path);
|
|
466
|
+
} catch {
|
|
467
|
+
// Ignore cleanup errors
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (localClientFile === localPath) {
|
|
471
|
+
localClientFile = null;
|
|
472
|
+
}
|
|
473
|
+
} else {
|
|
474
|
+
const payload: GatewayClientInfo = { pid: process.pid, refCount: nextCount, updatedAt: Date.now() };
|
|
475
|
+
writeFileSync(localPath, JSON.stringify(payload, null, 2));
|
|
476
|
+
localClientFile = localPath;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const totalRefCount =
|
|
480
|
+
otherClients.reduce((sum, client) => sum + client.info.refCount, 0) + (nextCount > 0 ? nextCount : 0);
|
|
481
|
+
return { totalRefCount, localRefCount: nextCount };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function clearClientFiles(): void {
|
|
485
|
+
const clients = listClientInfos();
|
|
486
|
+
for (const client of clients) {
|
|
487
|
+
try {
|
|
488
|
+
unlinkSync(client.path);
|
|
489
|
+
} catch {
|
|
490
|
+
// Ignore cleanup errors
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
localClientFile = null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function isGatewayHealthy(url: string): Promise<boolean> {
|
|
497
|
+
try {
|
|
498
|
+
const controller = new AbortController();
|
|
499
|
+
const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS);
|
|
500
|
+
const response = await fetch(`${url}/api/kernelspecs`, {
|
|
501
|
+
signal: controller.signal,
|
|
502
|
+
});
|
|
503
|
+
clearTimeout(timeout);
|
|
504
|
+
return response.ok;
|
|
505
|
+
} catch {
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function isGatewayAlive(info: GatewayInfo): Promise<boolean> {
|
|
511
|
+
if (!isPidRunning(info.pid)) return false;
|
|
512
|
+
return await isGatewayHealthy(info.url);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function startGatewayProcess(
|
|
516
|
+
cwd: string,
|
|
517
|
+
): Promise<{ url: string; pid: number; pythonPath: string; venvPath: string | null }> {
|
|
518
|
+
const { shell, env } = await getShellConfig();
|
|
519
|
+
const filteredEnv = filterEnv(env);
|
|
520
|
+
const runtime = await resolvePythonRuntime(cwd, filteredEnv);
|
|
521
|
+
const snapshotPath = await getOrCreateSnapshot(shell, env).catch((err: unknown) => {
|
|
522
|
+
logger.warn("Failed to resolve shell snapshot for shared Python gateway", {
|
|
523
|
+
error: err instanceof Error ? err.message : String(err),
|
|
524
|
+
});
|
|
525
|
+
return null;
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const kernelEnv: Record<string, string | undefined> = {
|
|
529
|
+
...runtime.env,
|
|
530
|
+
PYTHONUNBUFFERED: "1",
|
|
531
|
+
OMP_SHELL_SNAPSHOT: snapshotPath ?? undefined,
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const pythonPathParts = [cwd, kernelEnv.PYTHONPATH].filter(Boolean).join(delimiter);
|
|
535
|
+
if (pythonPathParts) {
|
|
536
|
+
kernelEnv.PYTHONPATH = pythonPathParts;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const gatewayPort = await allocatePort();
|
|
540
|
+
const gatewayUrl = `http://127.0.0.1:${gatewayPort}`;
|
|
541
|
+
|
|
542
|
+
const gatewayProcess = Bun.spawn(
|
|
543
|
+
[
|
|
544
|
+
runtime.pythonPath,
|
|
545
|
+
"-m",
|
|
546
|
+
"kernel_gateway",
|
|
547
|
+
"--KernelGatewayApp.ip=127.0.0.1",
|
|
548
|
+
`--KernelGatewayApp.port=${gatewayPort}`,
|
|
549
|
+
"--KernelGatewayApp.port_retries=0",
|
|
550
|
+
"--KernelGatewayApp.allow_origin=*",
|
|
551
|
+
"--JupyterApp.answer_yes=true",
|
|
552
|
+
],
|
|
553
|
+
{
|
|
554
|
+
cwd,
|
|
555
|
+
stdin: "ignore",
|
|
556
|
+
stdout: "pipe",
|
|
557
|
+
stderr: "pipe",
|
|
558
|
+
env: kernelEnv,
|
|
559
|
+
},
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
let exited = false;
|
|
563
|
+
gatewayProcess.exited
|
|
564
|
+
.then(() => {
|
|
565
|
+
exited = true;
|
|
566
|
+
})
|
|
567
|
+
.catch(() => {
|
|
568
|
+
exited = true;
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// Wait for gateway to become healthy
|
|
572
|
+
const startTime = Date.now();
|
|
573
|
+
while (Date.now() - startTime < GATEWAY_STARTUP_TIMEOUT_MS) {
|
|
574
|
+
if (exited) {
|
|
575
|
+
throw new Error("Gateway process exited during startup");
|
|
576
|
+
}
|
|
577
|
+
if (await isGatewayHealthy(gatewayUrl)) {
|
|
578
|
+
localGatewayProcess = gatewayProcess;
|
|
579
|
+
localGatewayUrl = gatewayUrl;
|
|
580
|
+
return {
|
|
581
|
+
url: gatewayUrl,
|
|
582
|
+
pid: gatewayProcess.pid,
|
|
583
|
+
pythonPath: runtime.pythonPath,
|
|
584
|
+
venvPath: runtime.venvPath ?? null,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
await Bun.sleep(100);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
killProcessTree(gatewayProcess.pid);
|
|
591
|
+
throw new Error("Gateway startup timeout");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function scheduleIdleShutdown(): void {
|
|
595
|
+
if (idleShutdownTimer) {
|
|
596
|
+
clearTimeout(idleShutdownTimer);
|
|
597
|
+
}
|
|
598
|
+
idleShutdownTimer = setTimeout(async () => {
|
|
599
|
+
try {
|
|
600
|
+
await withGatewayLock(async () => {
|
|
601
|
+
const info = readGatewayInfo();
|
|
602
|
+
if (!info) {
|
|
603
|
+
clearClientFiles();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const clients = pruneStaleClientInfos(listClientInfos());
|
|
607
|
+
const totalRefCount = clients.reduce((sum, client) => sum + client.info.refCount, 0);
|
|
608
|
+
if (totalRefCount > 0) {
|
|
609
|
+
if (info.refCount !== totalRefCount) {
|
|
610
|
+
writeGatewayInfo({ ...info, refCount: totalRefCount });
|
|
611
|
+
}
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
logger.debug("Shutting down idle shared gateway", { pid: info.pid });
|
|
615
|
+
if (localGatewayProcess) {
|
|
616
|
+
shutdownLocalGateway();
|
|
617
|
+
} else if (isPidRunning(info.pid)) {
|
|
618
|
+
try {
|
|
619
|
+
killProcessTree(info.pid);
|
|
620
|
+
} catch (err) {
|
|
621
|
+
logger.warn("Failed to kill idle shared gateway", {
|
|
622
|
+
error: err instanceof Error ? err.message : String(err),
|
|
623
|
+
pid: info.pid,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
clearGatewayInfo();
|
|
628
|
+
clearClientFiles();
|
|
629
|
+
});
|
|
630
|
+
} catch (err) {
|
|
631
|
+
logger.warn("Failed to shutdown idle shared gateway", {
|
|
632
|
+
error: err instanceof Error ? err.message : String(err),
|
|
633
|
+
});
|
|
634
|
+
} finally {
|
|
635
|
+
idleShutdownTimer = null;
|
|
636
|
+
}
|
|
637
|
+
}, GATEWAY_IDLE_TIMEOUT_MS);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function cancelIdleShutdown(): void {
|
|
641
|
+
if (idleShutdownTimer) {
|
|
642
|
+
clearTimeout(idleShutdownTimer);
|
|
643
|
+
idleShutdownTimer = null;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function shutdownLocalGateway(): void {
|
|
648
|
+
if (localGatewayProcess) {
|
|
649
|
+
try {
|
|
650
|
+
killProcessTree(localGatewayProcess.pid);
|
|
651
|
+
} catch (err) {
|
|
652
|
+
logger.warn("Failed to kill shared gateway process", {
|
|
653
|
+
error: err instanceof Error ? err.message : String(err),
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
localGatewayProcess = null;
|
|
657
|
+
localGatewayUrl = null;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export async function acquireSharedGateway(cwd: string): Promise<AcquireResult | null> {
|
|
662
|
+
if (process.env.BUN_ENV === "test" || process.env.NODE_ENV === "test") {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
try {
|
|
667
|
+
return await withGatewayLock(async () => {
|
|
668
|
+
const existingInfo = readGatewayInfo();
|
|
669
|
+
if (existingInfo && (await isGatewayAlive(existingInfo))) {
|
|
670
|
+
const { env } = await getShellConfig();
|
|
671
|
+
const filteredEnv = filterEnv(env);
|
|
672
|
+
const runtime = await resolvePythonRuntime(cwd, filteredEnv);
|
|
673
|
+
const existingVenv = existingInfo.venvPath ?? null;
|
|
674
|
+
const runtimeVenv = runtime.venvPath ?? null;
|
|
675
|
+
if (
|
|
676
|
+
existingInfo.cwd !== cwd ||
|
|
677
|
+
!existingInfo.pythonPath ||
|
|
678
|
+
existingInfo.pythonPath !== runtime.pythonPath ||
|
|
679
|
+
existingVenv !== runtimeVenv
|
|
680
|
+
) {
|
|
681
|
+
logger.debug("Shared gateway metadata mismatch", {
|
|
682
|
+
existingCwd: existingInfo.cwd,
|
|
683
|
+
requestedCwd: cwd,
|
|
684
|
+
existingPython: existingInfo.pythonPath,
|
|
685
|
+
runtimePython: runtime.pythonPath,
|
|
686
|
+
existingVenv,
|
|
687
|
+
runtimeVenv,
|
|
688
|
+
});
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
const { totalRefCount } = updateLocalClientRefCount(1);
|
|
692
|
+
const updatedInfo = { ...existingInfo, refCount: totalRefCount };
|
|
693
|
+
writeGatewayInfo(updatedInfo);
|
|
694
|
+
cancelIdleShutdown();
|
|
695
|
+
logger.debug("Reusing shared gateway", { url: existingInfo.url, refCount: updatedInfo.refCount });
|
|
696
|
+
isCoordinatorInitialized = true;
|
|
697
|
+
return { url: existingInfo.url, isShared: true };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (existingInfo) {
|
|
701
|
+
logger.debug("Cleaning up stale gateway info", { pid: existingInfo.pid });
|
|
702
|
+
if (isPidRunning(existingInfo.pid)) {
|
|
703
|
+
try {
|
|
704
|
+
killProcessTree(existingInfo.pid);
|
|
705
|
+
} catch (err) {
|
|
706
|
+
logger.warn("Failed to kill stale shared gateway process", {
|
|
707
|
+
error: err instanceof Error ? err.message : String(err),
|
|
708
|
+
pid: existingInfo.pid,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
clearGatewayInfo();
|
|
713
|
+
clearClientFiles();
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const { url, pid, pythonPath, venvPath } = await startGatewayProcess(cwd);
|
|
717
|
+
const { totalRefCount } = updateLocalClientRefCount(1);
|
|
718
|
+
const info: GatewayInfo = {
|
|
719
|
+
url,
|
|
720
|
+
pid,
|
|
721
|
+
startedAt: Date.now(),
|
|
722
|
+
refCount: totalRefCount,
|
|
723
|
+
cwd,
|
|
724
|
+
pythonPath,
|
|
725
|
+
venvPath,
|
|
726
|
+
};
|
|
727
|
+
writeGatewayInfo(info);
|
|
728
|
+
isCoordinatorInitialized = true;
|
|
729
|
+
logger.debug("Started shared gateway", { url, pid });
|
|
730
|
+
return { url, isShared: true };
|
|
731
|
+
});
|
|
732
|
+
} catch (err) {
|
|
733
|
+
logger.warn("Failed to acquire shared gateway, falling back to local", {
|
|
734
|
+
error: err instanceof Error ? err.message : String(err),
|
|
735
|
+
});
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export async function releaseSharedGateway(): Promise<void> {
|
|
741
|
+
if (!isCoordinatorInitialized) return;
|
|
742
|
+
|
|
743
|
+
try {
|
|
744
|
+
await withGatewayLock(async () => {
|
|
745
|
+
const { totalRefCount } = updateLocalClientRefCount(-1);
|
|
746
|
+
const info = readGatewayInfo();
|
|
747
|
+
if (!info) return;
|
|
748
|
+
|
|
749
|
+
const newRefCount = Math.max(0, totalRefCount);
|
|
750
|
+
if (newRefCount === 0) {
|
|
751
|
+
const updatedInfo = { ...info, refCount: 0 };
|
|
752
|
+
writeGatewayInfo(updatedInfo);
|
|
753
|
+
scheduleIdleShutdown();
|
|
754
|
+
logger.debug("Scheduled idle shutdown for shared gateway", { pid: info.pid });
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const updatedInfo = { ...info, refCount: newRefCount };
|
|
758
|
+
writeGatewayInfo(updatedInfo);
|
|
759
|
+
logger.debug("Released shared gateway reference", { url: info.url, refCount: newRefCount });
|
|
760
|
+
});
|
|
761
|
+
} catch (err) {
|
|
762
|
+
logger.warn("Failed to release shared gateway", {
|
|
763
|
+
error: err instanceof Error ? err.message : String(err),
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
export function getSharedGatewayUrl(): string | null {
|
|
769
|
+
return localGatewayUrl;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export function isSharedGatewayActive(): boolean {
|
|
773
|
+
return localGatewayProcess !== null && localGatewayUrl !== null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export interface GatewayStatus {
|
|
777
|
+
active: boolean;
|
|
778
|
+
shared: boolean;
|
|
779
|
+
url: string | null;
|
|
780
|
+
pid: number | null;
|
|
781
|
+
refCount: number;
|
|
782
|
+
cwd: string | null;
|
|
783
|
+
uptime: number | null;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export function getGatewayStatus(): GatewayStatus {
|
|
787
|
+
const info = readGatewayInfo();
|
|
788
|
+
if (!info) {
|
|
789
|
+
return {
|
|
790
|
+
active: false,
|
|
791
|
+
shared: false,
|
|
792
|
+
url: null,
|
|
793
|
+
pid: null,
|
|
794
|
+
refCount: 0,
|
|
795
|
+
cwd: null,
|
|
796
|
+
uptime: null,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
const active = isPidRunning(info.pid);
|
|
800
|
+
const clients = pruneStaleClientInfos(listClientInfos());
|
|
801
|
+
const clientRefCount = clients.reduce((sum, client) => sum + client.info.refCount, 0);
|
|
802
|
+
const refCount = clientRefCount > 0 ? clientRefCount : info.refCount;
|
|
803
|
+
return {
|
|
804
|
+
active,
|
|
805
|
+
shared: active && refCount > 1,
|
|
806
|
+
url: info.url,
|
|
807
|
+
pid: info.pid,
|
|
808
|
+
refCount,
|
|
809
|
+
cwd: info.cwd,
|
|
810
|
+
uptime: Date.now() - info.startedAt,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export async function shutdownSharedGateway(): Promise<void> {
|
|
815
|
+
cancelIdleShutdown();
|
|
816
|
+
try {
|
|
817
|
+
await withGatewayLock(async () => {
|
|
818
|
+
const info = readGatewayInfo();
|
|
819
|
+
if (info) {
|
|
820
|
+
clearGatewayInfo();
|
|
821
|
+
}
|
|
822
|
+
clearClientFiles();
|
|
823
|
+
});
|
|
824
|
+
} catch (err) {
|
|
825
|
+
logger.warn("Failed to shutdown shared gateway", {
|
|
826
|
+
error: err instanceof Error ? err.message : String(err),
|
|
827
|
+
});
|
|
828
|
+
} finally {
|
|
829
|
+
shutdownLocalGateway();
|
|
830
|
+
isCoordinatorInitialized = false;
|
|
831
|
+
}
|
|
832
|
+
}
|