@1e0zj/dsh-plugin-mall 0.4.3 → 0.4.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -20,13 +20,14 @@ window.__ModuleLoader__.load({
20
20
  ".mkt_title{font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)}",
21
21
  ".mkt_sub{font-size:12px;color:var(--dsw-alias-label-tertiary)}",
22
22
  ".mkt_row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}",
23
- ".mkt_input{flex:1;min-width:200px;background:var(--dsw-alias-bg-secondary,#fff);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:6px 10px;font-size:13px;color:var(--dsw-alias-label-primary)}",
24
- ".mkt_select{background:var(--dsw-alias-bg-secondary,#fff);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:6px 8px;font-size:13px;color:var(--dsw-alias-label-primary)}",
25
- ".mkt_btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-secondary,#fff);color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer}",
23
+ ".mkt_input{flex:1;min-width:200px;background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:6px 10px;font-size:13px;color:var(--dsw-alias-label-primary)}",
24
+ ".mkt_input::placeholder{color:var(--dsw-alias-label-caption)}",
25
+ ".mkt_select{background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:6px 8px;font-size:13px;color:var(--dsw-alias-label-primary)}",
26
+ ".mkt_btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer}",
26
27
  ".mkt_btn:hover:not(:disabled){border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary)}",
27
28
  ".mkt_btn:disabled{opacity:.55;cursor:default}",
28
- ".mkt_btnPrimary{background:var(--dsw-alias-state-business-primary,#2b6cb0);border-color:transparent;color:#fff}",
29
- ".mkt_btnPrimary:hover:not(:disabled){color:#fff;opacity:.92}",
29
+ ".mkt_btnPrimary{background:var(--dsw-alias-button-info-fill);border-color:transparent;color:var(--dsw-alias-label-primary-foreground)}",
30
+ ".mkt_btnPrimary:hover:not(:disabled){background:var(--dsw-alias-button-info-hover);color:var(--dsw-alias-label-primary-foreground)}",
30
31
  ".mkt_btnDanger:hover:not(:disabled){border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}",
31
32
  ".mkt_btnSm{padding:3px 10px;font-size:12px}",
32
33
  ".mkt_error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}",
@@ -34,7 +35,7 @@ window.__ModuleLoader__.load({
34
35
  "@media (max-width:820px){.mkt_list{grid-template-columns:minmax(0,1fr)}}",
35
36
  ".mkt_listHead{grid-column:1/-1;font-size:12px;color:var(--dsw-alias-label-tertiary)}",
36
37
  ".mkt_loadMore{grid-column:1/-1;text-align:center;font-size:12px;color:var(--dsw-alias-label-tertiary);padding:10px 0}",
37
- ".mkt_card{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px;background:var(--dsw-alias-bg-primary,#fff);display:flex;flex-direction:column;gap:6px;min-width:0}",
38
+ ".mkt_card{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-3);display:flex;flex-direction:column;gap:6px;min-width:0}",
38
39
  ".mkt_cardHead{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}",
39
40
  ".mkt_metaRow{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}",
40
41
  ".mkt_name{font-size:13.5px;font-weight:600;color:var(--dsw-alias-label-primary);overflow-wrap:anywhere}",
@@ -45,11 +46,11 @@ window.__ModuleLoader__.load({
45
46
  ".mkt_panelTitle{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0}",
46
47
  ".mkt_panelRow{display:flex;align-items:center;gap:8px}",
47
48
  ".mkt_panelRow .mkt_link{margin-left:auto}",
48
- ".mkt_pre{font-family:Consolas,Monaco,monospace;font-size:11.5px;line-height:16px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-secondary,#f6f7f8);border-radius:6px;padding:8px;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all}",
49
- ".mkt_ok{color:var(--dsw-alias-state-success-primary,#2f855a)}",
49
+ ".mkt_pre{font-family:Consolas,Monaco,monospace;font-size:11.5px;line-height:16px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-markdown-code-block);border-radius:6px;padding:8px;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all}",
50
+ ".mkt_ok{color:var(--dsw-alias-state-success-primary)}",
50
51
  ".mkt_badge{display:inline-block;border-radius:999px;padding:1px 8px;font-size:11px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary)}",
51
- ".mkt_badgeOk{border-color:var(--dsw-alias-state-success-primary,#2f855a);color:var(--dsw-alias-state-success-primary,#2f855a)}",
52
- ".mkt_badgeWarn{border-color:var(--dsw-alias-state-warning-primary,#b7791f);color:var(--dsw-alias-state-warning-primary,#b7791f)}",
52
+ ".mkt_badgeOk{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary)}",
53
+ ".mkt_badgeWarn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-label)}",
53
54
  ".mkt_badgeBad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}",
54
55
  ".mkt_check{display:flex;align-items:center;gap:4px;font-size:12.5px;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap}",
55
56
  ".mkt_link{color:var(--dsw-alias-state-business-primary);font-size:12px;text-decoration:none;cursor:pointer}",
@@ -67,15 +68,15 @@ window.__ModuleLoader__.load({
67
68
  ".mkt_jobDone{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:2px}",
68
69
  ".mkt_logBlock{display:flex;flex-direction:column;gap:4px;align-items:flex-start}",
69
70
  ".mkt_depOff{opacity:.5;text-decoration:line-through}",
70
- ".mkt_switch{position:relative;width:34px;height:18px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-secondary,#e5e7eb);cursor:pointer;padding:0;transition:background .15s,border-color .15s}",
71
+ ".mkt_switch{position:relative;width:34px;height:18px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-module-platform);cursor:pointer;padding:0;transition:background .15s,border-color .15s}",
71
72
  ".mkt_switch:disabled{opacity:.55;cursor:default}",
72
- ".mkt_switchOn{background:var(--dsw-alias-state-business-primary,#2b6cb0);border-color:transparent}",
73
- ".mkt_switchKnob{position:absolute;top:1px;left:1px;width:14px;height:14px;border-radius:50%;background:#fff;transition:transform .15s;box-shadow:0 1px 2px rgba(0,0,0,.25)}",
73
+ ".mkt_switchOn{background:var(--dsw-alias-button-info-fill);border-color:transparent}",
74
+ ".mkt_switchKnob{position:absolute;top:1px;left:1px;width:14px;height:14px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform .15s;box-shadow:var(--dsw-shadow-lv1)}",
74
75
  ".mkt_switchOn .mkt_switchKnob{transform:translateX(16px)}",
75
76
  ".mkt_issueList{list-style:none;display:flex;flex-direction:column;gap:8px;margin:0;padding:0}",
76
- ".mkt_issue{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 10px;background:var(--dsw-alias-bg-secondary,#fff)}",
77
+ ".mkt_issue{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 10px;background:var(--dsw-alias-bg-layer-3)}",
77
78
  ".mkt_issueBlock{border-color:var(--dsw-alias-state-error-primary)}",
78
- ".mkt_issueWarn{border-color:var(--dsw-alias-state-warning-primary,var(--dsw-alias-state-business-primary))}",
79
+ ".mkt_issueWarn{border-color:var(--dsw-alias-state-warn-primary)}",
79
80
  ".mkt_issueTitle{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}",
80
81
  ".mkt_issueDetail{font-size:12px;color:var(--dsw-alias-label-secondary);margin-top:4px;line-height:18px;overflow-wrap:anywhere}",
81
82
  ].join("\n");
package/src/github.js CHANGED
@@ -64,7 +64,29 @@ async function requestJson(path, { apiBase, token, signal }) {
64
64
  }
65
65
  const remaining = response.headers.get("x-ratelimit-remaining");
66
66
  const resetAt = response.headers.get("x-ratelimit-reset");
67
- const body = await response.json().catch(() => undefined);
67
+ // 读体阶段的三种失败必须分开,不能一把 catch undefined
68
+ // 调用方取消(响应头已到、body 读到一半浏览器断开)→ AbortError 上抛;
69
+ // 内部超时打断读体 → 与请求阶段超时同待遇,退避后重试;
70
+ // 响应体根本不是 JSON(镜像的 HTML 错误页等)→ 维持 undefined,交给
71
+ // 下面的状态码分支。
72
+ // 此前这里是无差别 .catch(() => undefined):mid-body 取消被吞掉,200 响应
73
+ // 返回 undefined,search 拿到的是 TypeError 而不是取消;4xx 路径则把一次
74
+ // 取消谎报成 not found。
75
+ let body;
76
+ try {
77
+ body = await response.json();
78
+ } catch (error) {
79
+ if (error?.name === "AbortError" && signal?.aborted) throw error;
80
+ // 读体超时只在成功响应上重试。4xx 的结论在读体之前就已确定
81
+ // (见上方契约:4xx 判定式失败,从不重试)——404 有 404 的报法、
82
+ // 403 限流有专用诊断,读不出 body 不改变任何一件事;重试三次
83
+ // 只会把正确答案换成一句超时。
84
+ if (error?.name === "TimeoutError" && response.ok) {
85
+ lastError = new Error(`GitHub API response body timed out after ${REQUEST_TIMEOUT / 1000}s (attempt ${attempt + 1})`);
86
+ continue;
87
+ }
88
+ body = undefined;
89
+ }
68
90
  if (response.status === 403 && remaining === "0" && resetAt !== null) {
69
91
  const reset = new Date(Number(resetAt) * 1000).toISOString();
70
92
  throw new Error(`GitHub API rate limit exceeded; resets at ${reset} (UTC). Set GITHUB_TOKEN or DSH_MARKET_GITHUB_TOKEN for a higher limit.`);
@@ -618,6 +640,9 @@ export async function repoInfo({ repo, apiBase, token, signal }) {
618
640
  try {
619
641
  meta = await requestJson(`/repos/${trimmed}`, { apiBase, token, signal });
620
642
  } catch (error) {
643
+ // 取消不是「仓库不存在」:包装会把这个信号翻译成业务错误,调用方据此
644
+ // 报告一个不存在的网络故障。取消原样上抛。
645
+ if (error?.name === "AbortError") throw error;
621
646
  throw new Error(`market_info: repository ${trimmed} not found on GitHub (${error.message})`);
622
647
  }
623
648
  let packageJson;
@@ -626,7 +651,8 @@ export async function repoInfo({ repo, apiBase, token, signal }) {
626
651
  if (typeof contents.content === "string") {
627
652
  packageJson = JSON.parse(Buffer.from(contents.content, "base64").toString("utf8"));
628
653
  }
629
- } catch {
654
+ } catch (error) {
655
+ if (error?.name === "AbortError") throw error; // 取消不是「没有 package.json」
630
656
  packageJson = undefined; // no package.json at the repo root
631
657
  }
632
658
  return {
@@ -728,6 +754,79 @@ if (process.argv[1]?.endsWith("github.js") && process.argv.includes("--self-test
728
754
  }
729
755
  if (clampFailed > 0) process.exit(1);
730
756
  }
757
+ // requestJson 读体阶段的三种失败。打桩 fetch——离线、确定性;abort 在
758
+ // json() 内部发生(不是调用前预 abort),逼近「响应头已到、body 读一半
759
+ // 浏览器断开」的真实时序。
760
+ {
761
+ const realFetch = globalThis.fetch;
762
+ const makeError = (name) => { const error = new Error("synthetic"); error.name = name; return error; };
763
+ const fakeResponse = ({ status, headers, json }) => ({
764
+ ok: status < 400,
765
+ status,
766
+ statusText: status === 404 ? "Not Found" : status === 403 ? "Forbidden" : "OK",
767
+ headers: headers ?? new Headers(),
768
+ json,
769
+ });
770
+ let failed = 0;
771
+ try {
772
+ // 1) mid-body 取消:json() 里才 abort。修复前 .catch(()=>undefined)
773
+ // 把它吞掉——200 变 undefined(search 得 TypeError)、4xx 谎报 not found。
774
+ {
775
+ const controller = new AbortController();
776
+ let threw;
777
+ globalThis.fetch = async () => fakeResponse({
778
+ status: 200,
779
+ json: async () => { controller.abort(); throw makeError("AbortError"); },
780
+ });
781
+ try {
782
+ await requestJson("/repos/owner/repo", { signal: controller.signal });
783
+ } catch (error) { threw = error; }
784
+ if (threw?.name === "AbortError") console.log(" PASS 读体期间取消 → AbortError 上抛(不吞成 undefined)");
785
+ else { failed++; console.log(` FAIL 读体期间取消应抛 AbortError,实得 ${threw ? `${threw.name}: ${threw.message}` : "未抛(body 变 undefined)"}`); }
786
+ }
787
+
788
+ // 2) 成功响应的读体超时 → 记一次失败,退避重试(重试轮换成成功)。
789
+ {
790
+ let attempts = 0;
791
+ globalThis.fetch = async () => fakeResponse({
792
+ status: 200,
793
+ json: async () => { attempts++; if (attempts === 1) throw makeError("TimeoutError"); return { ok: true }; },
794
+ });
795
+ const retried = await requestJson("/repos/owner/repo", {});
796
+ if (retried?.ok === true && attempts === 2) console.log(" PASS 读体超时(200)→ 记一次失败并重试成功");
797
+ else { failed++; console.log(` FAIL 读体超时(200)应重试一次,实得 attempts=${attempts} result=${JSON.stringify(retried)}`); }
798
+ }
799
+
800
+ // 3) 4xx 的结论在读体之前就已确定,读体超时绝不重试——重试只会把
801
+ // 正确的 404 换成一句超时。一次调用、报 404 的报法。
802
+ {
803
+ let calls = 0;
804
+ globalThis.fetch = async () => { calls++; return fakeResponse({ status: 404, json: async () => { throw makeError("TimeoutError"); } }); };
805
+ let threw;
806
+ try {
807
+ await requestJson("/repos/owner/absent", {});
808
+ } catch (error) { threw = error; }
809
+ if (calls === 1 && /GitHub API 404/.test(threw?.message ?? "")) console.log(" PASS 读体超时(404)→ 不重试,保留 404 结论");
810
+ else { failed++; console.log(` FAIL 404+读体超时应一次调用报 404,实得 calls=${calls} error=${threw?.message}`); }
811
+ }
812
+
813
+ // 4) 403 限流同理:专用诊断靠响应头(不依赖 body),必须一次到位。
814
+ {
815
+ let calls = 0;
816
+ const limited = new Headers({ "x-ratelimit-remaining": "0", "x-ratelimit-reset": "4102444800" });
817
+ globalThis.fetch = async () => { calls++; return fakeResponse({ status: 403, headers: limited, json: async () => { throw makeError("TimeoutError"); } }); };
818
+ let threw;
819
+ try {
820
+ await requestJson("/search/repositories", {});
821
+ } catch (error) { threw = error; }
822
+ if (calls === 1 && /rate limit exceeded/.test(threw?.message ?? "")) console.log(" PASS 读体超时(403 限流)→ 不重试,保留限流诊断");
823
+ else { failed++; console.log(` FAIL 403+读体超时应一次调用给限流诊断,实得 calls=${calls} error=${threw?.message}`); }
824
+ }
825
+ } finally {
826
+ globalThis.fetch = realFetch;
827
+ }
828
+ if (failed > 0) process.exit(1);
829
+ }
731
830
  if (process.argv.includes("--offline")) process.exit(0);
732
831
  const apiBase = "https://api.github.com";
733
832
  const result = await searchPlugins({ query: "", perPage: 3, apiBase });
package/src/index.js CHANGED
@@ -1771,7 +1771,19 @@ function rpcFail(error) {
1771
1771
  /**
1772
1772
  * Dispatch one /market RPC endpoint.
1773
1773
  */
1774
- async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1774
+ /**
1775
+ * `signal` is the RPC carrier's AbortSignal: the Host aborts it when the
1776
+ * browser side drops the connection mid-request (fetch aborted, tab closed,
1777
+ * network gone) — see docs/dsh-notes.md「issue #7」. Per the official posture
1778
+ * (tools.zh.md: "Async work must observe or forward exec.signal"), every
1779
+ * branch that awaits NETWORK work inside the request lifetime forwards it, so
1780
+ * a closed page stops the Host-side fetches instead of running them to the
1781
+ * end for a response nobody will read. Branches that only start a job and
1782
+ * return its id deliberately do NOT wire it: the job must outlive the request
1783
+ * (same reason the agent producer does not consume exec.signal), its kill
1784
+ * path is the job panel's cancel.
1785
+ */
1786
+ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker, signal) {
1775
1787
  const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, npmRegistry = "", rawSources = [] } = config;
1776
1788
  switch (endpoint) {
1777
1789
  case "search": {
@@ -1784,11 +1796,12 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1784
1796
  minStars: payload?.minStars,
1785
1797
  apiBase,
1786
1798
  token,
1799
+ signal,
1787
1800
  });
1788
1801
  return rpcOk(result);
1789
1802
  }
1790
1803
  case "verify": {
1791
- const result = await verifyPlugins({ repos: payload?.repos, sources: rawSources });
1804
+ const result = await verifyPlugins({ repos: payload?.repos, sources: rawSources, signal });
1792
1805
  return rpcOk(result);
1793
1806
  }
1794
1807
  case "compat": {
@@ -1805,7 +1818,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1805
1818
  const repos = [...new Set((Array.isArray(payload?.repos) ? payload.repos : []).map(String)
1806
1819
  .filter((repo) => /^[^@/\s][^/\s]*\/[^/\s]+$/.test(repo) && !repo.includes("..")))].slice(0, 30);
1807
1820
  if (repos.length === 0) return rpcOk({ results: {} });
1808
- await verifyPlugins({ repos, sources: rawSources }); // populates the manifest cache
1821
+ await verifyPlugins({ repos, sources: rawSources, signal }); // populates the manifest cache
1809
1822
  let fingerprint;
1810
1823
  const results = {};
1811
1824
  await mapLimit(repos, NETWORK_CONCURRENCY, async (repo) => {
@@ -1817,7 +1830,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1817
1830
  try {
1818
1831
  let patchText;
1819
1832
  if (typeof manifest.dsh?.bundle?.patch === "string") {
1820
- patchText = await fetchRawFile(repo, manifest.dsh.bundle.patch, { sources: rawSources });
1833
+ patchText = await fetchRawFile(repo, manifest.dsh.bundle.patch, { sources: rawSources, signal });
1821
1834
  }
1822
1835
  fingerprint ??= computeProfileFingerprint(profileDir);
1823
1836
  const cacheKey = `${fingerprint}::${repo}`;
@@ -1833,7 +1846,10 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1833
1846
  };
1834
1847
  compatCacheSet(cacheKey, entry);
1835
1848
  results[repo] = entry;
1836
- } catch {
1849
+ } catch (error) {
1850
+ // 取消必须先于兜底放行:客户端已经断开,把 AbortError 吞成
1851
+ // "unknown" 会让剩下的仓库继续被逐个扫完——为一份没人读的响应。
1852
+ if (isAbortError(error)) throw error;
1837
1853
  results[repo] = { state: "unknown", summary: "兼容性检查失败" };
1838
1854
  }
1839
1855
  });
@@ -1852,7 +1868,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1852
1868
  const results = {};
1853
1869
  await mapLimit(deps, NETWORK_CONCURRENCY, async (dep) => {
1854
1870
  if (dep.kind === "missing") { results[dep.name] = { latest: null }; return; }
1855
- const info = await npmPackageInfo(dep.name, { registry });
1871
+ const info = await npmPackageInfo(dep.name, { registry, signal });
1856
1872
  results[dep.name] = info === null
1857
1873
  ? { latest: null }
1858
1874
  : { latest: info.latest, hasUpdate: compareVersions(info.latest, dep.version) > 0 };
@@ -1860,7 +1876,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1860
1876
  return rpcOk(results);
1861
1877
  }
1862
1878
  case "info": {
1863
- const result = await repoInfo({ repo: payload?.repo, apiBase, token });
1879
+ const result = await repoInfo({ repo: payload?.repo, apiBase, token, signal });
1864
1880
  return rpcOk(result);
1865
1881
  }
1866
1882
  case "installed": {
@@ -1947,7 +1963,10 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1947
1963
  }
1948
1964
  try {
1949
1965
  const registry = await registryFor(profile, npmRegistry);
1950
- const resolved = await preferNpmSpec({ spec, registry, sources: rawSources });
1966
+ // registryFor 刻意不接 signal(缓存的是 promise,一次取消会污染整条
1967
+ // 缓存——见 createInstallJobProducer 的注释);它之后的联网步骤接。
1968
+ const resolved = await preferNpmSpec({ spec, registry, sources: rawSources, signal });
1969
+ signal?.throwIfAborted(); // 断连发生在解析完成与建 job 之间:一个 job 都不要建
1951
1970
  const jobId = tracker.startCustom({
1952
1971
  kind: "dsh-plugin-preflight",
1953
1972
  label: `preflight ${resolved}`,
@@ -1983,6 +2002,9 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1983
2002
  });
1984
2003
  return rpcOk({ jobId, profile, spec: resolved });
1985
2004
  } catch (error) {
2005
+ // 取消不是业务失败:客户端已断开,rpcFail 的响应没人读,还会在
2006
+ // 存活的重连页面上被当成市场故障渲染出来。
2007
+ if (isAbortError(error)) throw error;
1986
2008
  return rpcFail(error);
1987
2009
  }
1988
2010
  }
@@ -2232,9 +2254,16 @@ function registerRpcChannel(ctx, config, token) {
2232
2254
  const tracker = createJobTracker();
2233
2255
  ctx.inject(["connection"], (connectionCtx) => {
2234
2256
  connectionCtx.connection.rpc.handle("/market", async (endpoint, payload, signal) => {
2257
+ // signal 是 carrier 的取消信号:浏览器断开(关页/断网/主动 abort)时
2258
+ // Host 侧 abort(见 docs/dsh-notes.md「issue #7」)。此前在这里被扔掉,
2259
+ // 查询类 RPC 在页面关掉后照跑到底。
2235
2260
  try {
2236
- return await rpcDispatch(ctx, endpoint, payload ?? {}, config, token, tracker);
2261
+ return await rpcDispatch(ctx, endpoint, payload ?? {}, config, token, tracker, signal);
2237
2262
  } catch (error) {
2263
+ // 取消归类为「输给取消」而非业务错误:不打错误日志(每次关页都会
2264
+ // 触发一次,纯噪音),也不转 rpcFail——响应早已无人读,转了只会在
2265
+ // 存活的重连页面上被渲染成市场故障。
2266
+ if (isAbortError(error)) throw error;
2238
2267
  console.error(`[dsh-plugin-mall] /market/${String(endpoint)} failed:`, error);
2239
2268
  return rpcFail(error);
2240
2269
  }
@@ -3714,6 +3743,39 @@ export async function runSelfTests() {
3714
3743
  `extras=${JSON.stringify(integrationDelta.snapshot?.extras)?.slice(0, 120)}`);
3715
3744
  }
3716
3745
 
3746
+ // ── 12d. 查询类 RPC 响应 carrier 取消(issue #7)────────────────────────
3747
+ //
3748
+ // carrier signal 在 Host 收到浏览器断连时 abort。用预 abort 的 signal 验
3749
+ // 接线:fetch 在发起任何网络请求前立刻拒绝。若某个分支没接(收了没传),
3750
+ // 离线环境里它会真去连网——要么慢超时要么返回正常结果,断言随之下沉。
3751
+ {
3752
+ const cfg = { apiBase: "https://api.github.com", npmRegistry: "https://registry.npmjs.org", rawSources: [] };
3753
+ const assertAborts = async (label, endpoint, rpcPayload) => {
3754
+ try {
3755
+ const value = await rpcDispatch(null, endpoint, rpcPayload, cfg, undefined, createJobTracker(), AbortSignal.abort());
3756
+ check(label, false, `返回了 ${JSON.stringify(value).slice(0, 60)}——signal 没接进 ${endpoint}`);
3757
+ } catch (error) {
3758
+ check(label, isAbortError(error), `抛了 ${error?.name}: ${String(error?.message).slice(0, 60)}`);
3759
+ }
3760
+ };
3761
+ await assertAborts("search 预取消 → AbortError(不发任何请求)", "search", { query: "x" });
3762
+ await assertAborts("verify 预取消 → AbortError", "verify", { repos: ["owner/repo"] });
3763
+ await assertAborts("info 预取消 → AbortError(不被包装成 not found)", "info", { repo: "owner/repo" });
3764
+
3765
+ // preflight:断连发生在解析与建 job 之间——一个 job 都不许建,否则
3766
+ // 面板上会冒出一个注定无人认领的孤儿任务。
3767
+ const abortTracker = createJobTracker();
3768
+ const abortSession = `sess_${"a".repeat(32)}`; // 合法 nonce 形状
3769
+ try {
3770
+ const preflightValue = await rpcDispatch(null, "preflight", { session: abortSession, spec: "github:owner/repo" }, cfg, undefined, abortTracker, AbortSignal.abort());
3771
+ check("preflight 预取消 → AbortError 且不建 job", false, `没有抛,返回 ${JSON.stringify(preflightValue).slice(0, 200)}`);
3772
+ } catch (error) {
3773
+ check("preflight 预取消 → AbortError 且不建 job",
3774
+ isAbortError(error) && abortTracker.list(abortSession).length === 0,
3775
+ `${error?.name} jobs=${abortTracker.list(abortSession).length}`);
3776
+ }
3777
+ }
3778
+
3717
3779
  // ── 13. market_install:整条链跑在 job 里(issue #8)────────────────────
3718
3780
  // 原来 registry 查询 → 防抢注解析 → 隔离预检全在 ctx.jobs.start() 之前 await,
3719
3781
  // 于是几十秒里没有 job id、没有日志、job_kill 够不着,而工具描述写的是