@dsh-plus/web-search-services 0.1.5 → 0.1.7

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/lib/index.d.ts CHANGED
@@ -49,6 +49,24 @@ type WebSearchServicesConfigFields = Schemastery.TypeT<typeof Config>;
49
49
  /** 平面配置形态(消费面的读取形态,由活动引用解包得到)。 */
50
50
  type WebSearchServicesConfig = UnwrapVolatile<WebSearchServicesConfigFields>;
51
51
  //#endregion
52
+ //#region src/python.d.ts
53
+ /** 注入的文件系统视图(测试在 Linux 上覆盖 win32 形态)。 */
54
+ interface PythonFs {
55
+ exists: (path: string) => boolean;
56
+ /** 列目录一层;目录不存在/不可读返回空表。 */
57
+ list: (path: string) => readonly {
58
+ name: string;
59
+ dir: boolean;
60
+ }[];
61
+ }
62
+ interface PythonDiscoveryEnv {
63
+ platform: NodeJS.Platform;
64
+ pathValue: string | undefined;
65
+ /** 桌面端捆绑运行时根;undefined = 无(跳过 BFS)。 */
66
+ bundledRoot: string | undefined;
67
+ fs: PythonFs;
68
+ }
69
+ //#endregion
52
70
  //#region src/runner.d.ts
53
71
  type SpawnFn = typeof spawn;
54
72
  //#endregion
@@ -58,8 +76,16 @@ declare class SearchServicesProvider implements WebSearchProvider {
58
76
  readonly id = "search-services";
59
77
  private readonly current;
60
78
  private readonly spawnFn;
61
- constructor(current: () => WebSearchServicesConfig, spawnFn?: SpawnFn | undefined);
62
- /** 廉价本地检查:脚本存在 + 优先级内至少一个后端有 key;不做网络调用。 */
79
+ private readonly discovery;
80
+ /** 候选链按 `${python}|${bundledRoot}` 记忆:PATH 与捆绑布局在进程内静态。 */
81
+ private readonly candidateCache;
82
+ constructor(current: () => WebSearchServicesConfig, spawnFn?: SpawnFn | undefined, discovery?: (() => PythonDiscoveryEnv) | undefined);
83
+ /** 解释器候选链(有序、记忆化)。 */
84
+ private candidates;
85
+ /**
86
+ * 廉价本地检查:脚本存在 + 至少一个【已验证】解释器候选(配置/捆绑/PATH,
87
+ * 未验证兜底名不算)+ 优先级内至少一个后端有 key;不做网络调用、不 spawn。
88
+ */
63
89
  available(): boolean;
64
90
  search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
65
91
  private childEnv;
package/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { unwrapVolatile } from "@dsh-plus/shared";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
4
5
  import { WebError } from "@deepseek-ai/dsh-web";
5
6
  import { homedir } from "node:os";
6
7
  import { join } from "node:path";
@@ -29,10 +30,10 @@ function buildSearchArgv(cfg, request) {
29
30
  * 文件缺失/不可读返回空表(envFile 是优化来源,不是硬依赖)。
30
31
  * @module web-search-services/env-file
31
32
  */
32
- /** 展开路径开头的 ~(仅 ~ 与 ~/ 两种形态,不借 shell)。 */
33
+ /** 展开路径开头的 ~(支持 `~`、`~/`、`~\` 三种形态,与官方 dsh-home-paths 对齐,不借 shell)。 */
33
34
  function expandHome(path) {
34
35
  if (path === "~") return homedir();
35
- if (path.startsWith("~/")) return join(homedir(), path.slice(2));
36
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
36
37
  return path;
37
38
  }
38
39
  function unquote(value) {
@@ -177,6 +178,182 @@ function normalizeSearchOutput(raw) {
177
178
  }
178
179
  }
179
180
  //#endregion
181
+ //#region src/python.ts
182
+ /**
183
+ * Python 解释器发现:Windows 与桌面端常无 `python3` 命令(桌面端自带的捆绑
184
+ * 运行时落在 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`),按候选链解析——
185
+ * 显式配置(可含参数,如 `py -3`)→ 捆绑运行时(浅层 BFS 找解释器)→
186
+ * PATH 扫描 → 平台兜底名(未验证,交 spawn 时再判)。
187
+ *
188
+ * 全部为同步纯逻辑 + 注入文件系统视图,零 spawn:`available()` 是廉价本地检查,
189
+ * 具体候选 spawn ENOENT 时由调用方逐个换下一个(见 provider.run)。
190
+ * @module web-search-services/python
191
+ */
192
+ const POSIX_SPEC = {
193
+ pathNames: [{
194
+ name: "python3",
195
+ argsPrefix: []
196
+ }, {
197
+ name: "python",
198
+ argsPrefix: []
199
+ }],
200
+ fallbacks: [{
201
+ name: "python3",
202
+ argsPrefix: []
203
+ }, {
204
+ name: "python",
205
+ argsPrefix: []
206
+ }],
207
+ bundledNames: /* @__PURE__ */ new Set(["python3", "python"])
208
+ };
209
+ const WIN32_SPEC = {
210
+ pathNames: [
211
+ {
212
+ name: "python.exe",
213
+ argsPrefix: []
214
+ },
215
+ {
216
+ name: "python3.exe",
217
+ argsPrefix: []
218
+ },
219
+ {
220
+ name: "py.exe",
221
+ argsPrefix: ["-3"]
222
+ }
223
+ ],
224
+ fallbacks: [
225
+ {
226
+ name: "python",
227
+ argsPrefix: []
228
+ },
229
+ {
230
+ name: "py",
231
+ argsPrefix: ["-3"]
232
+ },
233
+ {
234
+ name: "python3",
235
+ argsPrefix: []
236
+ }
237
+ ],
238
+ bundledNames: /* @__PURE__ */ new Set([
239
+ "python.exe",
240
+ "python3.exe",
241
+ "python3",
242
+ "python"
243
+ ])
244
+ };
245
+ /** 捆绑运行时 BFS 的边界:层数与访问目录数双上限,防畸形布局拖慢 available()。 */
246
+ const BUNDLED_MAX_DEPTH = 3;
247
+ const BUNDLED_MAX_VISITS = 512;
248
+ /** 与解释器无关的大目录(Windows CPython 的 Lib/site-packages 等),跳过。 */
249
+ const NOISE_DIRS = /* @__PURE__ */ new Set([
250
+ "Lib",
251
+ "lib",
252
+ "lib64",
253
+ "include",
254
+ "share",
255
+ "site-packages",
256
+ "test",
257
+ "tests",
258
+ "doc",
259
+ "docs"
260
+ ]);
261
+ function specOf(platform) {
262
+ return platform === "win32" ? WIN32_SPEC : POSIX_SPEC;
263
+ }
264
+ /** 显式配置 → 单候选(按空白拆分,支持 `py -3` 形态)。 */
265
+ function configCandidate(configPython) {
266
+ const [command, ...argsPrefix] = configPython.trim().split(/\s+/).filter((part) => part.length > 0);
267
+ if (command === void 0) return void 0;
268
+ return {
269
+ command,
270
+ argsPrefix,
271
+ source: "config"
272
+ };
273
+ }
274
+ /** 在捆绑运行时根下浅层 BFS 找解释器文件(先按层扫文件名,再入队子目录)。 */
275
+ function findBundled(root, env) {
276
+ if (!env.fs.exists(root)) return void 0;
277
+ const spec = specOf(env.platform);
278
+ const queue = [{
279
+ dir: root,
280
+ depth: 0
281
+ }];
282
+ let visits = 0;
283
+ while (queue.length > 0 && visits < BUNDLED_MAX_VISITS) {
284
+ const item = queue.shift();
285
+ if (item === void 0) break;
286
+ visits += 1;
287
+ for (const entry of env.fs.list(item.dir)) {
288
+ if (!entry.dir && spec.bundledNames.has(entry.name)) return join(item.dir, entry.name);
289
+ if (entry.dir && item.depth < BUNDLED_MAX_DEPTH && !entry.name.startsWith(".") && !NOISE_DIRS.has(entry.name) && !spec.bundledNames.has(entry.name)) queue.push({
290
+ dir: join(item.dir, entry.name),
291
+ depth: item.depth + 1
292
+ });
293
+ }
294
+ }
295
+ }
296
+ /** PATH 逐目录扫描(名字在外层:win32 任一处的 python.exe 优先于 python3.exe)。 */
297
+ function scanPath(env, spec) {
298
+ const listSep = env.platform === "win32" ? ";" : ":";
299
+ const dirs = (env.pathValue ?? "").split(listSep).filter((dir) => dir.length > 0);
300
+ const out = [];
301
+ const seen = /* @__PURE__ */ new Set();
302
+ for (const { name, argsPrefix } of spec.pathNames) for (const dir of dirs) {
303
+ const full = join(dir, name);
304
+ if (seen.has(full)) continue;
305
+ seen.add(full);
306
+ if (env.fs.exists(full)) out.push({
307
+ command: full,
308
+ argsPrefix,
309
+ source: "path"
310
+ });
311
+ }
312
+ return out;
313
+ }
314
+ /**
315
+ * 解析解释器候选链(有序):显式配置独占;否则 捆绑运行时 → PATH 命中 → 平台兜底名。
316
+ * 兜底名永远追加在末尾(供 spawn ENOENT 时逐个回退),但 `source: 'fallback'`
317
+ * 不算「已验证」——`available()` 只信任 config/bundled/path。
318
+ */
319
+ function pythonCandidates(env, configPython) {
320
+ const explicit = configCandidate(configPython);
321
+ if (explicit !== void 0) return [explicit];
322
+ const spec = specOf(env.platform);
323
+ const out = [];
324
+ if (env.bundledRoot !== void 0) {
325
+ const bundled = findBundled(env.bundledRoot, env);
326
+ if (bundled !== void 0) out.push({
327
+ command: bundled,
328
+ argsPrefix: [],
329
+ source: "bundled"
330
+ });
331
+ }
332
+ out.push(...scanPath(env, spec));
333
+ for (const { name, argsPrefix } of spec.fallbacks) if (!out.some((candidate) => candidate.command === name)) out.push({
334
+ command: name,
335
+ argsPrefix,
336
+ source: "fallback"
337
+ });
338
+ return out;
339
+ }
340
+ /** 生产文件系统视图(真实 fs;list 对不可读目录返回空表)。 */
341
+ function realPythonFs() {
342
+ return {
343
+ exists: (path) => existsSync(path),
344
+ list: (path) => {
345
+ try {
346
+ return readdirSync(path, { withFileTypes: true }).map((entry) => ({
347
+ name: entry.name,
348
+ dir: entry.isDirectory()
349
+ }));
350
+ } catch {
351
+ return [];
352
+ }
353
+ }
354
+ };
355
+ }
356
+ //#endregion
180
357
  //#region src/runner.ts
181
358
  /**
182
359
  * 子进程执行器:spawn 脚本、收集 stdout、超时/abort 杀进程。
@@ -185,6 +362,23 @@ function normalizeSearchOutput(raw) {
185
362
  */
186
363
  const DEFAULT_MAX_BUFFER = 8388608;
187
364
  const KILL_GRACE_MS = 2e3;
365
+ /**
366
+ * Windows 进程树终止:`taskkill /PID <pid> /T /F`(Windows 无 POSIX 信号语义,
367
+ * 单纯 `child.kill()` 会遗留 npx/playwright 派生的子进程树;上游
368
+ * dsh-subprocess-local 同款做法)。error 监听防 taskkill 缺席成为未捕获异常。
369
+ */
370
+ const killWindowsTree = (pid) => {
371
+ const killer = spawn("taskkill", [
372
+ "/PID",
373
+ String(pid),
374
+ "/T",
375
+ "/F"
376
+ ], { stdio: "ignore" });
377
+ killer.on("error", (error) => {
378
+ console.warn(`[web-search-services] taskkill 失败(pid ${pid}): ${error.message}`);
379
+ });
380
+ killer.unref();
381
+ };
188
382
  function abortError() {
189
383
  const err = /* @__PURE__ */ new Error("search aborted");
190
384
  err.name = "AbortError";
@@ -200,6 +394,8 @@ function tail(text, max = 500) {
200
394
  /** 运行子进程并返回完整 stdout;任何失败以 Error 拒绝(消息含 stderr/退出码上下文)。 */
201
395
  function runProcess(opts) {
202
396
  const spawnFn = opts.spawnFn ?? spawn;
397
+ const platform = opts.platform ?? process.platform;
398
+ const killTree = opts.killTree ?? killWindowsTree;
203
399
  return new Promise((resolve, reject) => {
204
400
  if (opts.signal?.aborted === true) {
205
401
  reject(abortError());
@@ -231,6 +427,11 @@ function runProcess(opts) {
231
427
  fn();
232
428
  };
233
429
  const kill = () => {
430
+ if (platform === "win32") {
431
+ if (child.pid !== void 0) killTree(child.pid);
432
+ else child.kill();
433
+ return;
434
+ }
234
435
  child.kill("SIGTERM");
235
436
  graceTimer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
236
437
  graceTimer.unref();
@@ -305,6 +506,14 @@ function keyOverridesEnv(keys) {
305
506
  function messageOf(err) {
306
507
  return err instanceof Error ? err.message : String(err);
307
508
  }
509
+ /**
510
+ * 判定「解释器缺失」类失败(spawn ENOENT,runProcess 包装时保留 cause):
511
+ * 候选链据此换下一个解释器;其余错误照常上抛。
512
+ */
513
+ function isMissingInterpreter(err) {
514
+ if (!(err instanceof Error)) return false;
515
+ return err.cause?.code === "ENOENT";
516
+ }
308
517
  function toWebError(err) {
309
518
  if (err instanceof WebError) return err;
310
519
  if (isAbortError(err)) return new WebError("搜索已取消", "WEB_ABORTED", { cause: err });
@@ -314,14 +523,38 @@ var SearchServicesProvider = class {
314
523
  id = PROVIDER_ID;
315
524
  current;
316
525
  spawnFn;
317
- constructor(current, spawnFn) {
526
+ discovery;
527
+ /** 候选链按 `${python}|${bundledRoot}` 记忆:PATH 与捆绑布局在进程内静态。 */
528
+ candidateCache = /* @__PURE__ */ new Map();
529
+ constructor(current, spawnFn, discovery) {
318
530
  this.current = current;
319
531
  this.spawnFn = spawnFn;
532
+ this.discovery = discovery ?? (() => ({
533
+ platform: process.platform,
534
+ pathValue: process.env.PATH ?? process.env.Path,
535
+ bundledRoot: dshHomePath("dsh-runtimes", "dsh-primary-runtime"),
536
+ fs: realPythonFs()
537
+ }));
538
+ }
539
+ /** 解释器候选链(有序、记忆化)。 */
540
+ candidates() {
541
+ const cfg = this.current();
542
+ const env = this.discovery();
543
+ const key = `${cfg.python}|${env.bundledRoot ?? ""}`;
544
+ const cached = this.candidateCache.get(key);
545
+ if (cached !== void 0) return cached;
546
+ const resolved = pythonCandidates(env, cfg.python);
547
+ this.candidateCache.set(key, resolved);
548
+ return resolved;
320
549
  }
321
- /** 廉价本地检查:脚本存在 + 优先级内至少一个后端有 key;不做网络调用。 */
550
+ /**
551
+ * 廉价本地检查:脚本存在 + 至少一个【已验证】解释器候选(配置/捆绑/PATH,
552
+ * 未验证兜底名不算)+ 优先级内至少一个后端有 key;不做网络调用、不 spawn。
553
+ */
322
554
  available() {
323
555
  const cfg = this.current();
324
556
  if (!existsSync(resolveScriptPath(cfg.scriptPath))) return false;
557
+ if (!this.candidates().some((candidate) => candidate.source !== "fallback")) return false;
325
558
  const env = this.childEnv(cfg);
326
559
  return cfg.priority.some((backend) => BACKEND_KEY_VARS[backend].some((name) => (env[name]?.trim() ?? "") !== ""));
327
560
  }
@@ -340,18 +573,27 @@ var SearchServicesProvider = class {
340
573
  };
341
574
  }
342
575
  async run(cfg, argv, signal) {
343
- try {
576
+ const script = resolveScriptPath(cfg.scriptPath);
577
+ const env = this.childEnv(cfg);
578
+ let lastMissing;
579
+ for (const candidate of this.candidates()) try {
344
580
  return await runProcess({
345
- command: cfg.python,
346
- args: [resolveScriptPath(cfg.scriptPath), ...argv],
347
- env: this.childEnv(cfg),
581
+ command: candidate.command,
582
+ args: [
583
+ ...candidate.argsPrefix,
584
+ script,
585
+ ...argv
586
+ ],
587
+ env,
348
588
  timeoutMs: cfg.timeoutMs,
349
589
  ...signal !== void 0 ? { signal } : {},
350
590
  ...this.spawnFn !== void 0 ? { spawnFn: this.spawnFn } : {}
351
591
  });
352
592
  } catch (err) {
353
- throw toWebError(err);
593
+ if (!isMissingInterpreter(err)) throw toWebError(err);
594
+ lastMissing = err;
354
595
  }
596
+ throw toWebError(lastMissing ?? /* @__PURE__ */ new Error("未找到可用的 python 解释器(配置 python 或安装 python3)"));
355
597
  }
356
598
  normalize(stdout) {
357
599
  let raw;
@@ -402,7 +644,7 @@ const KeysSchema = z.object({
402
644
  const Config = z.object({
403
645
  scriptPath: z.string().description("search.py 路径(支持 ~ 展开);空串 = 使用随包内置脚本副本").default("").volatile(),
404
646
  envFile: z.string().description("skill 密钥 env 文件路径(支持 ~ 展开);空串表示不读 envFile").default(DEFAULT_ENV_FILE).volatile(),
405
- python: z.string().description("python 可执行文件").default("python3").volatile(),
647
+ python: z.string().description("python 解释器(可含参数,如 `py -3`);空串 = 自动发现(配置外按 桌面捆绑运行时 → PATH → 平台兜底名 解析)").default("").volatile(),
406
648
  priority: z.array(PRIORITY_ENTRY).description("后端优先级:靠前的先尝试,失败自动回退到下一个").default([...DEFAULT_PRIORITY]).volatile(),
407
649
  timeoutMs: z.natural().min(1e3).description("单次搜索子进程超时(毫秒),应低于 dsh-tool-web 的 searchTimeoutMs").default(DEFAULT_TIMEOUT_MS).volatile(),
408
650
  keys: KeysSchema.default({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/web-search-services",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "dsh-plus service plugin: web_search 免费后端聚合——经官方 ctx.web provider 扩展点桥接 search-services skill(Tavily/Exa/OpenAI Chat 免费额度,优先级+自动回退+key 配置),官方 web_search 工具与输出格式零修改",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -19,18 +19,20 @@
19
19
  "scripts"
20
20
  ],
21
21
  "dependencies": {
22
- "@dsh-plus/shared": "0.1.23"
22
+ "@dsh-plus/shared": "0.1.27"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "@deepseek-ai/cordis": "^4.0.4",
26
- "@deepseek-ai/dsh-settings": "^0.1.7-rc.1",
27
- "@deepseek-ai/dsh-web": "^0.1.7-rc.1",
26
+ "@deepseek-ai/dsh-home-paths": "^0.2.0-rc.1",
27
+ "@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
28
+ "@deepseek-ai/dsh-web": "^0.2.0-rc.1",
28
29
  "@deepseek-ai/schemastery": "^3.18.4"
29
30
  },
30
31
  "devDependencies": {
31
32
  "@deepseek-ai/cordis": "4.0.4",
32
- "@deepseek-ai/dsh-settings": "0.1.7-rc.1",
33
- "@deepseek-ai/dsh-web": "0.1.7-rc.1",
33
+ "@deepseek-ai/dsh-home-paths": "0.2.0-rc.1",
34
+ "@deepseek-ai/dsh-settings": "0.2.0-rc.1",
35
+ "@deepseek-ai/dsh-web": "0.2.0-rc.1",
34
36
  "@deepseek-ai/schemastery": "3.18.4"
35
37
  },
36
38
  "publishConfig": {
@@ -31,6 +31,7 @@ import fetch as local_fetch # noqa: E402
31
31
 
32
32
 
33
33
  DEFAULT_SELECTOR = "main, article, [role='main'], .content, #content, .post, .entry-content"
34
+ _IS_WINDOWS = os.name == "nt"
34
35
  _ACTIVE_PGIDS: set[int] = set()
35
36
  _ACTIVE_BROWSER_PIDS: set[int] = set()
36
37
  _TEMP_DIRS: set[str] = set()
@@ -52,46 +53,91 @@ class PlaywrightError(RuntimeError):
52
53
  self.details = details
53
54
 
54
55
 
56
+ def _popen(cmd: list[str], **kwargs: Any) -> subprocess.Popen:
57
+ """启动子进程;Windows 上 .cmd/.bat 必须经 cmd.exe(CreateProcess 不直接执行批处理)。"""
58
+ if _IS_WINDOWS and cmd and cmd[0].lower().endswith((".cmd", ".bat")):
59
+ cmd = ["cmd", "/c", *cmd]
60
+ return subprocess.Popen(cmd, **kwargs)
61
+
62
+
63
+ def _taskkill_tree(pid: int) -> None:
64
+ """Windows 进程树终止:taskkill /T /F(无 POSIX 信号语义,单杀父进程会留孤儿)。"""
65
+ try:
66
+ subprocess.run(
67
+ ["taskkill", "/PID", str(pid), "/T", "/F"],
68
+ stdout=subprocess.DEVNULL,
69
+ stderr=subprocess.DEVNULL,
70
+ check=False,
71
+ timeout=15,
72
+ )
73
+ except (OSError, subprocess.SubprocessError):
74
+ pass
75
+
76
+
77
+ def _signal_group(pgid: int, *, force: bool) -> None:
78
+ """向进程组发信号(仅 POSIX;Windows 无 killpg/SIGKILL,直接返回)。"""
79
+ if _IS_WINDOWS:
80
+ return
81
+ try:
82
+ os.killpg(pgid, signal.SIGKILL if force else signal.SIGTERM)
83
+ except OSError:
84
+ pass
85
+
86
+
87
+ def _signal_pid(pid: int, *, force: bool) -> None:
88
+ """终止单个进程:POSIX 走 SIGTERM→SIGKILL,Windows 统一 taskkill /T /F。"""
89
+ if _IS_WINDOWS:
90
+ _taskkill_tree(pid)
91
+ return
92
+ try:
93
+ os.kill(pid, signal.SIGKILL if force else signal.SIGTERM)
94
+ except OSError:
95
+ pass
96
+
97
+
98
+ def _pid_alive(pid: int) -> bool:
99
+ """进程存活探测。Windows 上 os.kill(pid, 0) 会直接杀进程,绝不能用。"""
100
+ if _IS_WINDOWS:
101
+ try:
102
+ out = subprocess.run(
103
+ ["tasklist", "/FI", f"PID eq {pid}"],
104
+ stdout=subprocess.PIPE,
105
+ stderr=subprocess.DEVNULL,
106
+ check=False,
107
+ timeout=15,
108
+ text=True,
109
+ )
110
+ except (OSError, subprocess.SubprocessError):
111
+ return False
112
+ return str(pid) in out.stdout
113
+ try:
114
+ os.kill(pid, 0)
115
+ return True
116
+ except OSError:
117
+ return False
118
+
119
+
55
120
  def _safe_rmtree(path: str) -> None:
56
- """只清理本脚本创建的 /tmp 临时目录,避免误删。"""
121
+ """只清理本脚本创建的临时目录(tempfile.gettempdir() 下 search-services-pw-*),避免误删。"""
57
122
 
123
+ root = os.path.realpath(tempfile.gettempdir())
58
124
  real = os.path.realpath(path)
59
- if not real.startswith("/tmp/search-services-pw-"):
125
+ if not real.startswith(os.path.join(root, "search-services-pw-")):
60
126
  return
61
127
  shutil.rmtree(real, ignore_errors=True)
62
128
 
63
129
 
64
130
  def _cleanup() -> None:
65
131
  for pgid in list(_ACTIVE_PGIDS):
66
- try:
67
- os.killpg(pgid, signal.SIGTERM)
68
- except ProcessLookupError:
69
- pass
70
- except OSError:
71
- pass
132
+ _signal_group(pgid, force=False)
72
133
  time.sleep(0.1)
73
134
  for pgid in list(_ACTIVE_PGIDS):
74
- try:
75
- os.killpg(pgid, signal.SIGKILL)
76
- except ProcessLookupError:
77
- pass
78
- except OSError:
79
- pass
135
+ _signal_group(pgid, force=True)
80
136
  for pid in list(_ACTIVE_BROWSER_PIDS):
81
- try:
82
- os.kill(pid, signal.SIGTERM)
83
- except ProcessLookupError:
84
- pass
85
- except OSError:
86
- pass
137
+ _signal_pid(pid, force=False)
87
138
  time.sleep(0.1)
88
139
  for pid in list(_ACTIVE_BROWSER_PIDS):
89
- try:
90
- os.kill(pid, signal.SIGKILL)
91
- except ProcessLookupError:
92
- pass
93
- except OSError:
94
- pass
140
+ _signal_pid(pid, force=True)
95
141
  for temp_dir in list(_TEMP_DIRS):
96
142
  _safe_rmtree(temp_dir)
97
143
  _TEMP_DIRS.discard(temp_dir)
@@ -124,7 +170,8 @@ def _playwright_command_base() -> list[str]:
124
170
 
125
171
  codex_home = os.getenv("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")
126
172
  wrapper = os.path.join(codex_home, "skills", "playwright-headless", "scripts", "playwright_cli.sh")
127
- if os.path.exists(wrapper):
173
+ # .sh 包装依赖 POSIX shell,Windows 上直接跳过走 npx 兜底。
174
+ if not _IS_WINDOWS and os.path.exists(wrapper):
128
175
  return [wrapper]
129
176
 
130
177
  npx = shutil.which("npx")
@@ -180,37 +227,33 @@ def _run_checked_with_retries(cmd: list[str], *, timeout: float, cwd: str, retri
180
227
 
181
228
 
182
229
  def _run_command(cmd: list[str], *, timeout: float, cwd: str) -> tuple[int, str, str]:
183
- proc = subprocess.Popen(
230
+ proc = _popen(
184
231
  cmd,
185
232
  stdout=subprocess.PIPE,
186
233
  stderr=subprocess.PIPE,
187
234
  text=True,
188
235
  cwd=cwd,
189
- start_new_session=True,
236
+ # Windows 无 setsid;进程树终止由 taskkill /T 承担。
237
+ start_new_session=not _IS_WINDOWS,
190
238
  )
191
- try:
192
- pgid = os.getpgid(proc.pid)
193
- _ACTIVE_PGIDS.add(pgid)
194
- except OSError:
195
- pgid = None
239
+ pgid: Optional[int] = None
240
+ if not _IS_WINDOWS:
241
+ try:
242
+ pgid = os.getpgid(proc.pid)
243
+ _ACTIVE_PGIDS.add(pgid)
244
+ except OSError:
245
+ pgid = None
196
246
  try:
197
247
  stdout, stderr = proc.communicate(timeout=timeout)
198
248
  return proc.returncode, stdout, stderr
199
249
  except subprocess.TimeoutExpired as exc:
200
250
  if pgid is not None:
201
- try:
202
- os.killpg(pgid, signal.SIGTERM)
203
- except ProcessLookupError:
204
- pass
205
- except OSError:
206
- pass
251
+ _signal_group(pgid, force=False)
207
252
  time.sleep(0.2)
208
- try:
209
- os.killpg(pgid, signal.SIGKILL)
210
- except ProcessLookupError:
211
- pass
212
- except OSError:
213
- pass
253
+ _signal_group(pgid, force=True)
254
+ else:
255
+ # Windows(或 POSIX 取组失败):端掉整棵子进程树而非单个进程。
256
+ _signal_pid(proc.pid, force=True)
214
257
  try:
215
258
  proc.wait(timeout=2)
216
259
  except subprocess.TimeoutExpired:
@@ -366,22 +409,12 @@ def fetch_one(
366
409
  except Exception:
367
410
  pass
368
411
  if browser_pid is not None:
369
- try:
370
- os.kill(browser_pid, 0)
371
- except ProcessLookupError:
372
- _ACTIVE_BROWSER_PIDS.discard(browser_pid)
373
- except OSError:
374
- _ACTIVE_BROWSER_PIDS.discard(browser_pid)
375
- else:
376
- try:
377
- os.kill(browser_pid, signal.SIGTERM)
378
- time.sleep(0.2)
379
- os.kill(browser_pid, signal.SIGKILL)
380
- except ProcessLookupError:
381
- pass
382
- except OSError:
383
- pass
384
- _ACTIVE_BROWSER_PIDS.discard(browser_pid)
412
+ # 存活探测必须走 _pid_alive:Windows 上 os.kill(pid, 0) 会直接杀进程。
413
+ if _pid_alive(browser_pid):
414
+ _signal_pid(browser_pid, force=False)
415
+ time.sleep(0.2)
416
+ _signal_pid(browser_pid, force=True)
417
+ _ACTIVE_BROWSER_PIDS.discard(browser_pid)
385
418
  _safe_rmtree(temp_dir)
386
419
  _TEMP_DIRS.discard(temp_dir)
387
420
 
package/src/config.ts CHANGED
@@ -64,7 +64,13 @@ export const Config = z.object({
64
64
  .description('skill 密钥 env 文件路径(支持 ~ 展开);空串表示不读 envFile')
65
65
  .default(DEFAULT_ENV_FILE)
66
66
  .volatile(),
67
- python: z.string().description('python 可执行文件').default('python3').volatile(),
67
+ python: z
68
+ .string()
69
+ .description(
70
+ 'python 解释器(可含参数,如 `py -3`);空串 = 自动发现(配置外按 桌面捆绑运行时 → PATH → 平台兜底名 解析)',
71
+ )
72
+ .default('')
73
+ .volatile(),
68
74
  priority: z
69
75
  .array(PRIORITY_ENTRY)
70
76
  .description('后端优先级:靠前的先尝试,失败自动回退到下一个')
package/src/env-file.ts CHANGED
@@ -8,10 +8,10 @@ import { readFileSync } from 'node:fs'
8
8
  import { homedir } from 'node:os'
9
9
  import { join } from 'node:path'
10
10
 
11
- /** 展开路径开头的 ~(仅 ~ 与 ~/ 两种形态,不借 shell)。 */
11
+ /** 展开路径开头的 ~(支持 `~`、`~/`、`~\` 三种形态,与官方 dsh-home-paths 对齐,不借 shell)。 */
12
12
  export function expandHome(path: string): string {
13
13
  if (path === '~') return homedir()
14
- if (path.startsWith('~/')) return join(homedir(), path.slice(2))
14
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
15
15
  return path
16
16
  }
17
17
 
package/src/provider.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import { existsSync } from 'node:fs'
8
8
  import { fileURLToPath } from 'node:url'
9
9
 
10
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
10
11
  import type { WebSearchProvider, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
11
12
  import { WebError } from '@deepseek-ai/dsh-web'
12
13
 
@@ -14,6 +15,12 @@ import { buildSearchArgv } from './args.ts'
14
15
  import type { SearchBackend, WebSearchServicesConfig } from './config.ts'
15
16
  import { expandHome, loadEnvFile } from './env-file.ts'
16
17
  import { normalizeSearchOutput } from './normalize.ts'
18
+ import {
19
+ type PythonCandidate,
20
+ type PythonDiscoveryEnv,
21
+ pythonCandidates,
22
+ realPythonFs,
23
+ } from './python.ts'
17
24
  import { isAbortError, runProcess, type SpawnFn } from './runner.ts'
18
25
 
19
26
  export const PROVIDER_ID = 'search-services'
@@ -49,6 +56,16 @@ function messageOf(err: unknown): string {
49
56
  return err instanceof Error ? err.message : String(err)
50
57
  }
51
58
 
59
+ /**
60
+ * 判定「解释器缺失」类失败(spawn ENOENT,runProcess 包装时保留 cause):
61
+ * 候选链据此换下一个解释器;其余错误照常上抛。
62
+ */
63
+ function isMissingInterpreter(err: unknown): boolean {
64
+ if (!(err instanceof Error)) return false
65
+ const cause = err.cause as { code?: unknown } | undefined
66
+ return cause?.code === 'ENOENT'
67
+ }
68
+
52
69
  function toWebError(err: unknown): WebError {
53
70
  if (err instanceof WebError) return err
54
71
  if (isAbortError(err)) return new WebError('搜索已取消', 'WEB_ABORTED', { cause: err })
@@ -62,16 +79,47 @@ export class SearchServicesProvider implements WebSearchProvider {
62
79
 
63
80
  private readonly current: () => WebSearchServicesConfig
64
81
  private readonly spawnFn: SpawnFn | undefined
65
-
66
- constructor(current: () => WebSearchServicesConfig, spawnFn?: SpawnFn | undefined) {
82
+ private readonly discovery: () => PythonDiscoveryEnv
83
+ /** 候选链按 `${python}|${bundledRoot}` 记忆:PATH 与捆绑布局在进程内静态。 */
84
+ private readonly candidateCache = new Map<string, PythonCandidate[]>()
85
+
86
+ constructor(
87
+ current: () => WebSearchServicesConfig,
88
+ spawnFn?: SpawnFn | undefined,
89
+ discovery?: (() => PythonDiscoveryEnv) | undefined,
90
+ ) {
67
91
  this.current = current
68
92
  this.spawnFn = spawnFn
93
+ this.discovery =
94
+ discovery ??
95
+ (() => ({
96
+ platform: process.platform,
97
+ pathValue: process.env.PATH ?? process.env.Path,
98
+ bundledRoot: dshHomePath('dsh-runtimes', 'dsh-primary-runtime'),
99
+ fs: realPythonFs(),
100
+ }))
69
101
  }
70
102
 
71
- /** 廉价本地检查:脚本存在 + 优先级内至少一个后端有 key;不做网络调用。 */
103
+ /** 解释器候选链(有序、记忆化)。 */
104
+ private candidates(): PythonCandidate[] {
105
+ const cfg = this.current()
106
+ const env = this.discovery()
107
+ const key = `${cfg.python}|${env.bundledRoot ?? ''}`
108
+ const cached = this.candidateCache.get(key)
109
+ if (cached !== undefined) return cached
110
+ const resolved = pythonCandidates(env, cfg.python)
111
+ this.candidateCache.set(key, resolved)
112
+ return resolved
113
+ }
114
+
115
+ /**
116
+ * 廉价本地检查:脚本存在 + 至少一个【已验证】解释器候选(配置/捆绑/PATH,
117
+ * 未验证兜底名不算)+ 优先级内至少一个后端有 key;不做网络调用、不 spawn。
118
+ */
72
119
  available(): boolean {
73
120
  const cfg = this.current()
74
121
  if (!existsSync(resolveScriptPath(cfg.scriptPath))) return false
122
+ if (!this.candidates().some((candidate) => candidate.source !== 'fallback')) return false
75
123
  const env = this.childEnv(cfg)
76
124
  return cfg.priority.some((backend) =>
77
125
  BACKEND_KEY_VARS[backend].some((name) => (env[name]?.trim() ?? '') !== ''),
@@ -95,18 +143,28 @@ export class SearchServicesProvider implements WebSearchProvider {
95
143
  argv: string[],
96
144
  signal: AbortSignal | undefined,
97
145
  ): Promise<string> {
98
- try {
99
- return await runProcess({
100
- command: cfg.python,
101
- args: [resolveScriptPath(cfg.scriptPath), ...argv],
102
- env: this.childEnv(cfg),
103
- timeoutMs: cfg.timeoutMs,
104
- ...(signal !== undefined ? { signal } : {}),
105
- ...(this.spawnFn !== undefined ? { spawnFn: this.spawnFn } : {}),
106
- })
107
- } catch (err) {
108
- throw toWebError(err)
146
+ const script = resolveScriptPath(cfg.scriptPath)
147
+ const env = this.childEnv(cfg)
148
+ let lastMissing: unknown
149
+ // 候选链逐个尝试:仅解释器缺失(ENOENT)换下一个,其余错误立即上抛。
150
+ for (const candidate of this.candidates()) {
151
+ try {
152
+ return await runProcess({
153
+ command: candidate.command,
154
+ args: [...candidate.argsPrefix, script, ...argv],
155
+ env,
156
+ timeoutMs: cfg.timeoutMs,
157
+ ...(signal !== undefined ? { signal } : {}),
158
+ ...(this.spawnFn !== undefined ? { spawnFn: this.spawnFn } : {}),
159
+ })
160
+ } catch (err) {
161
+ if (!isMissingInterpreter(err)) throw toWebError(err)
162
+ lastMissing = err
163
+ }
109
164
  }
165
+ throw toWebError(
166
+ lastMissing ?? new Error('未找到可用的 python 解释器(配置 python 或安装 python3)'),
167
+ )
110
168
  }
111
169
 
112
170
  private normalize(stdout: string): WebSearchResult {
package/src/python.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Python 解释器发现:Windows 与桌面端常无 `python3` 命令(桌面端自带的捆绑
3
+ * 运行时落在 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`),按候选链解析——
4
+ * 显式配置(可含参数,如 `py -3`)→ 捆绑运行时(浅层 BFS 找解释器)→
5
+ * PATH 扫描 → 平台兜底名(未验证,交 spawn 时再判)。
6
+ *
7
+ * 全部为同步纯逻辑 + 注入文件系统视图,零 spawn:`available()` 是廉价本地检查,
8
+ * 具体候选 spawn ENOENT 时由调用方逐个换下一个(见 provider.run)。
9
+ * @module web-search-services/python
10
+ */
11
+ import { existsSync, readdirSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+
14
+ /** 候选来源:config=用户显式配置;bundled=桌面端捆绑运行时;path=PATH 扫描命中;fallback=未验证兜底名。 */
15
+ export type PythonSource = 'config' | 'bundled' | 'path' | 'fallback'
16
+
17
+ export interface PythonCandidate {
18
+ command: string
19
+ argsPrefix: readonly string[]
20
+ source: PythonSource
21
+ }
22
+
23
+ /** 注入的文件系统视图(测试在 Linux 上覆盖 win32 形态)。 */
24
+ export interface PythonFs {
25
+ exists: (path: string) => boolean
26
+ /** 列目录一层;目录不存在/不可读返回空表。 */
27
+ list: (path: string) => readonly { name: string; dir: boolean }[]
28
+ }
29
+
30
+ export interface PythonDiscoveryEnv {
31
+ platform: NodeJS.Platform
32
+ pathValue: string | undefined
33
+ /** 桌面端捆绑运行时根;undefined = 无(跳过 BFS)。 */
34
+ bundledRoot: string | undefined
35
+ fs: PythonFs
36
+ }
37
+
38
+ interface PlatformCommand {
39
+ name: string
40
+ argsPrefix: readonly string[]
41
+ }
42
+
43
+ interface PlatformSpec {
44
+ /** PATH 扫描尝试的文件名(win32 带 .exe 后缀)。 */
45
+ pathNames: readonly PlatformCommand[]
46
+ /** 未验证兜底(PATH 扫描落空时信任 spawn 的 PATH 解析)。 */
47
+ fallbacks: readonly PlatformCommand[]
48
+ /** 捆绑运行时 BFS 中接受的解释器文件名。 */
49
+ bundledNames: ReadonlySet<string>
50
+ }
51
+
52
+ const POSIX_SPEC: PlatformSpec = {
53
+ pathNames: [
54
+ { name: 'python3', argsPrefix: [] },
55
+ { name: 'python', argsPrefix: [] },
56
+ ],
57
+ fallbacks: [
58
+ { name: 'python3', argsPrefix: [] },
59
+ { name: 'python', argsPrefix: [] },
60
+ ],
61
+ bundledNames: new Set(['python3', 'python']),
62
+ }
63
+
64
+ const WIN32_SPEC: PlatformSpec = {
65
+ pathNames: [
66
+ { name: 'python.exe', argsPrefix: [] },
67
+ { name: 'python3.exe', argsPrefix: [] },
68
+ // Python 启动器:-3 显式锁 Python 3,避免用户配置默认 py2。
69
+ { name: 'py.exe', argsPrefix: ['-3'] },
70
+ ],
71
+ fallbacks: [
72
+ { name: 'python', argsPrefix: [] },
73
+ { name: 'py', argsPrefix: ['-3'] },
74
+ { name: 'python3', argsPrefix: [] },
75
+ ],
76
+ bundledNames: new Set(['python.exe', 'python3.exe', 'python3', 'python']),
77
+ }
78
+
79
+ /** 捆绑运行时 BFS 的边界:层数与访问目录数双上限,防畸形布局拖慢 available()。 */
80
+ const BUNDLED_MAX_DEPTH = 3
81
+ const BUNDLED_MAX_VISITS = 512
82
+ /** 与解释器无关的大目录(Windows CPython 的 Lib/site-packages 等),跳过。 */
83
+ const NOISE_DIRS: ReadonlySet<string> = new Set([
84
+ 'Lib',
85
+ 'lib',
86
+ 'lib64',
87
+ 'include',
88
+ 'share',
89
+ 'site-packages',
90
+ 'test',
91
+ 'tests',
92
+ 'doc',
93
+ 'docs',
94
+ ])
95
+
96
+ function specOf(platform: NodeJS.Platform): PlatformSpec {
97
+ return platform === 'win32' ? WIN32_SPEC : POSIX_SPEC
98
+ }
99
+
100
+ /** 显式配置 → 单候选(按空白拆分,支持 `py -3` 形态)。 */
101
+ function configCandidate(configPython: string): PythonCandidate | undefined {
102
+ const parts = configPython
103
+ .trim()
104
+ .split(/\s+/)
105
+ .filter((part) => part.length > 0)
106
+ const [command, ...argsPrefix] = parts
107
+ if (command === undefined) return undefined
108
+ return { command, argsPrefix, source: 'config' }
109
+ }
110
+
111
+ /** 在捆绑运行时根下浅层 BFS 找解释器文件(先按层扫文件名,再入队子目录)。 */
112
+ function findBundled(root: string, env: PythonDiscoveryEnv): string | undefined {
113
+ if (!env.fs.exists(root)) return undefined
114
+ const spec = specOf(env.platform)
115
+ const queue: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }]
116
+ let visits = 0
117
+ while (queue.length > 0 && visits < BUNDLED_MAX_VISITS) {
118
+ const item = queue.shift()
119
+ if (item === undefined) break
120
+ visits += 1
121
+ for (const entry of env.fs.list(item.dir)) {
122
+ if (!entry.dir && spec.bundledNames.has(entry.name)) return join(item.dir, entry.name)
123
+ if (
124
+ entry.dir &&
125
+ item.depth < BUNDLED_MAX_DEPTH &&
126
+ !entry.name.startsWith('.') &&
127
+ !NOISE_DIRS.has(entry.name) &&
128
+ !spec.bundledNames.has(entry.name)
129
+ ) {
130
+ queue.push({ dir: join(item.dir, entry.name), depth: item.depth + 1 })
131
+ }
132
+ }
133
+ }
134
+ return undefined
135
+ }
136
+
137
+ /** PATH 逐目录扫描(名字在外层:win32 任一处的 python.exe 优先于 python3.exe)。 */
138
+ function scanPath(env: PythonDiscoveryEnv, spec: PlatformSpec): PythonCandidate[] {
139
+ // PATH 分隔符按【被发现的平台】而非宿主平台取:测试可在 Linux 上覆盖 win32 形态。
140
+ const listSep = env.platform === 'win32' ? ';' : ':'
141
+ const dirs = (env.pathValue ?? '').split(listSep).filter((dir) => dir.length > 0)
142
+ const out: PythonCandidate[] = []
143
+ const seen = new Set<string>()
144
+ for (const { name, argsPrefix } of spec.pathNames) {
145
+ for (const dir of dirs) {
146
+ const full = join(dir, name)
147
+ if (seen.has(full)) continue
148
+ seen.add(full)
149
+ if (env.fs.exists(full)) out.push({ command: full, argsPrefix, source: 'path' })
150
+ }
151
+ }
152
+ return out
153
+ }
154
+
155
+ /**
156
+ * 解析解释器候选链(有序):显式配置独占;否则 捆绑运行时 → PATH 命中 → 平台兜底名。
157
+ * 兜底名永远追加在末尾(供 spawn ENOENT 时逐个回退),但 `source: 'fallback'`
158
+ * 不算「已验证」——`available()` 只信任 config/bundled/path。
159
+ */
160
+ export function pythonCandidates(env: PythonDiscoveryEnv, configPython: string): PythonCandidate[] {
161
+ const explicit = configCandidate(configPython)
162
+ if (explicit !== undefined) return [explicit]
163
+ const spec = specOf(env.platform)
164
+ const out: PythonCandidate[] = []
165
+ if (env.bundledRoot !== undefined) {
166
+ const bundled = findBundled(env.bundledRoot, env)
167
+ if (bundled !== undefined) out.push({ command: bundled, argsPrefix: [], source: 'bundled' })
168
+ }
169
+ out.push(...scanPath(env, spec))
170
+ for (const { name, argsPrefix } of spec.fallbacks) {
171
+ if (!out.some((candidate) => candidate.command === name)) {
172
+ out.push({ command: name, argsPrefix, source: 'fallback' })
173
+ }
174
+ }
175
+ return out
176
+ }
177
+
178
+ /** 生产文件系统视图(真实 fs;list 对不可读目录返回空表)。 */
179
+ export function realPythonFs(): PythonFs {
180
+ return {
181
+ exists: (path: string) => existsSync(path),
182
+ list: (path: string) => {
183
+ try {
184
+ return readdirSync(path, { withFileTypes: true }).map((entry) => ({
185
+ name: entry.name,
186
+ dir: entry.isDirectory(),
187
+ }))
188
+ } catch {
189
+ return []
190
+ }
191
+ },
192
+ }
193
+ }
package/src/runner.ts CHANGED
@@ -7,6 +7,9 @@ import { spawn } from 'node:child_process'
7
7
 
8
8
  export type SpawnFn = typeof spawn
9
9
 
10
+ /** 进程树终止器(Windows 用 taskkill 注入面;测试可替换)。 */
11
+ export type KillTreeFn = (pid: number) => void
12
+
10
13
  export interface RunProcessOptions {
11
14
  command: string
12
15
  args: readonly string[]
@@ -16,11 +19,29 @@ export interface RunProcessOptions {
16
19
  signal?: AbortSignal | undefined
17
20
  maxBufferBytes?: number | undefined
18
21
  spawnFn?: SpawnFn | undefined
22
+ /** 平台(缺省 process.platform):win32 的超时/中止走进程树终止而非信号。 */
23
+ platform?: NodeJS.Platform | undefined
24
+ /** win32 进程树终止实现(缺省 taskkill /T /F)。 */
25
+ killTree?: KillTreeFn | undefined
19
26
  }
20
27
 
21
28
  const DEFAULT_MAX_BUFFER = 8 * 1024 * 1024
22
29
  const KILL_GRACE_MS = 2_000
23
30
 
31
+ /**
32
+ * Windows 进程树终止:`taskkill /PID <pid> /T /F`(Windows 无 POSIX 信号语义,
33
+ * 单纯 `child.kill()` 会遗留 npx/playwright 派生的子进程树;上游
34
+ * dsh-subprocess-local 同款做法)。error 监听防 taskkill 缺席成为未捕获异常。
35
+ */
36
+ export const killWindowsTree: KillTreeFn = (pid) => {
37
+ const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
38
+ killer.on('error', (error) => {
39
+ // 无监听者的 'error' 会成为未捕获异常;终止是尽力而为,留上下文日志即可。
40
+ console.warn(`[web-search-services] taskkill 失败(pid ${pid}): ${error.message}`)
41
+ })
42
+ killer.unref()
43
+ }
44
+
24
45
  export function abortError(): Error {
25
46
  const err = new Error('search aborted')
26
47
  err.name = 'AbortError'
@@ -39,6 +60,8 @@ function tail(text: string, max = 500): string {
39
60
  /** 运行子进程并返回完整 stdout;任何失败以 Error 拒绝(消息含 stderr/退出码上下文)。 */
40
61
  export function runProcess(opts: RunProcessOptions): Promise<string> {
41
62
  const spawnFn = opts.spawnFn ?? spawn
63
+ const platform = opts.platform ?? process.platform
64
+ const killTree = opts.killTree ?? killWindowsTree
42
65
  return new Promise<string>((resolve, reject) => {
43
66
  if (opts.signal?.aborted === true) {
44
67
  reject(abortError())
@@ -68,6 +91,12 @@ export function runProcess(opts: RunProcessOptions): Promise<string> {
68
91
  fn()
69
92
  }
70
93
  const kill = (): void => {
94
+ if (platform === 'win32') {
95
+ // Windows 无 SIGTERM/SIGKILL 语义:taskkill /T /F 端掉整棵进程树。
96
+ if (child.pid !== undefined) killTree(child.pid)
97
+ else child.kill()
98
+ return
99
+ }
71
100
  child.kill('SIGTERM')
72
101
  graceTimer = setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS)
73
102
  graceTimer.unref()