dsh-builtin-browser 0.4.6 → 0.4.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/CHANGELOG.md CHANGED
@@ -4,6 +4,54 @@
4
4
 
5
5
  ---
6
6
 
7
+ # 第四十三轮(2026-10-12,三个"看起来无关"的缺陷:载体被写死、宿主死了不换、设置读了不生效)
8
+
9
+ 这一轮的共同点不是症状,而是**判断被做了一次就再也不做了**:载体在启动时选一次、连接端口在建立时记一次、`browser.channel` 在 apply 时读一次。三次都发生在"当时是对的"的情况下,三次的后果都是"之后永远不对"。
10
+
11
+ ## 一、标签级三件套 + 裸根拦截(issue #26,@dengyun2018-commits)
12
+
13
+ 他带着一份本地 patch 来,已在 0.4.0 → 0.4.5 四个版本上实跑、每次 `apply --check` 零偏移。四条全部收下,两条判据换了实现:
14
+
15
+ **标签 URL 跟踪**:每个标签记住"最后一次被接受的 URL"。`listTabs` 不再探测它已知为空的标签——未提交的视图上那次往返只能回答"没有";而"从未导航"与"导航过但读不出来"终于可区分(此前两者都塌进同一个空字符串)。读失败时保留上次的 URL,而不是把标签报成空的。
16
+
17
+ **空白标签短路**:`snapshot` / `a11y` 在未导航的标签上立即返回空页,而不是在一个没有文档可提交的视图里跑采集器、先耗尽整个 30 秒预算。
18
+
19
+ **超时自愈**:超时的读操作**原位替换**卡死的视图(标签对调用方保持同一个 id),并抛出 `BROWSER_SNAPSHOT_TIMEOUT` / `BROWSER_A11Y_TIMEOUT`。此前视图留在会话里,于是**之后的每一次读都要再付一遍 30 秒**——`terminatePage` 只中断正在跑的脚本,并不会让视图变得可读。它还有个每视图 3 次的待终止上限,所以反复读会累积挂起的 evaluate。
20
+
21
+ **裸根拦截**:`browser_open` 拒绝不带 token 的 DSH web 根地址——它返回 401 页面,读起来像一个真站点。**判据用的是 `DSH_WEB_URL`(harness 自己导出的环境变量),而不是端口号**:这台机器的 GUI 在 3080 和 19387 上都跑过,硬编码端口会在最需要它的那次不匹配。识别陷阱靠的是形状——同源 + 根路径 + 无 token——不是数字。
22
+
23
+ ## 二、桌面壳退出后,浏览器面板跟着一起死
24
+
25
+ 用户报的是"浏览器不能正常使用了,只能重启 DSH"。
26
+
27
+ 链条是两处叠加:**载体只在启动时选一次,而且只能向上走**——自托管先注册,发现桌面 bridge 后把它换成 provider,紧接着 `selfHosted.dispose()`。**没有回退目标。**启动时那四次重试只覆盖"bridge 出现得晚",而代码注释自己写着这个交换是单向的。
28
+
29
+ **`refreshEndpoint` 也救不了,原因很精确**:它比较 port 和 token,而死掉的 shell 留下的文件**内容原封不动**——文件看着是新的,指向的是尸体。
30
+
31
+ **判据来自文件里本来就有的字段**:活的 shell 每 15 秒重写 `updatedAt`,所以**四次没刷新就不是"慢",是"没了"**。超过一分钟的端点现在被判定为墓碑,一次性上报,经由载体接口上新增的 `onLost`;entry 收到后用自托管把 provider 重新注册回来。**缺失 `updatedAt` 刻意不算过期**,否则所有比这个字段更老的 shell 都会在首次使用时就判死。
32
+
33
+ 失败信息也不再是裸的 `ECONNREFUSED`——它点名原因,因为读它的人正是决定要不要回退的那个。
34
+
35
+ ## 三、`browser.channel` 只在 apply 时读一次(issue #27,@sbtxdy)
36
+
37
+ 他给了一组对照测量:`Electron/44.5.1` 对 `Edg/155.0.0.0`,以及"改完不重启、进程里没有任何带插件自有 `--user-data-dir` 的 msedge.exe"。三条建议全收,第一条选了**热切换**而不是退让到 `restartRequired`。
38
+
39
+ **载体实时重选**:选择逻辑改成一个 `chooseCarrier()`,设置路由在写入改变 channel 时重跑它。这里有一处必须一起动:`adopt` 原有的一次性 guard 会把晚到的载体直接 `dispose` 掉——**防两个载体抢入是对的,对"用户改设置"恰好是错的**,因为那正是"写成功了、然后新载体被安静丢掉"的来源。现在换掉旧载体的是新载体,且在装好之后才释放。
40
+
41
+ **不生效的写会说出来**:响应区分"已应用"和"只存了"(`carrierRechosen`);载体由 composition 提供(桌面 shell)而 channel 无法生效时打 WARN。**对一次没人会读的写回 `ok`,是调用方唯一分不出"成功"和"什么都没做"的地方**。
42
+
43
+ **拼错的值不再静默**:`resolveChannel` 把未知值退回默认——它的注释写着这是有意为之("手改的错别字不该静默选中别的浏览器"),**但结果让错别字和"设置没生效"长得一模一样**。退回是对的,静默退回不是:现在会报出原值并列出接受集合。**缺席保持安静**,否则警告会变成噪音。
44
+
45
+ ## 四、自我更正
46
+
47
+ **第二节的光标是我的**:`BridgeConnection.refreshEndpoint` 的存在恰恰证明我知道"端口会变"这件事,但判据选成了"与当前不同",而墓碑的特征是"与当前相同但已经死了"。同一个字段的两端,我只写了会变的那一端。
48
+
49
+ **测试**:237 → 250(本轮新增 13 条:标签 URL 与空白短路 3 条、墓碑与回退 4 条、channel 重选 6 条)。`tsc` 干净。其中超时自愈那条是**形状断言**而不是行为断言——真跑 30 秒太慢——这一点写进了 commit 和给报告者的回复里,免得以后有人把它读强。
50
+
51
+ **发布**:0.4.7。`npm` 与仓库同步。
52
+
53
+ ---
54
+
7
55
  # 第四十二轮(2026-10-08,三个"能看见的"缺陷:跨会话读错、空白页堆积、升级登出)
8
56
 
9
57
  0.4.5 发布后,用户连续报了三个现象。**三个都在同一段代码的新实现里**,而它们看着毫不相干——一个是"别的会话读到我这边",一个是"一次开一堆空白页",一个是"升级后所有站点被登出"。前两个实际上是**同一个会话在两种状态下的两种表现**。
@@ -23,6 +23,7 @@ export interface BridgeEndpoint {
23
23
  export declare class BridgeConnection {
24
24
  private endpoint;
25
25
  private readonly endpointPath?;
26
+ private readonly onLost?;
26
27
  private socket;
27
28
  private buffer;
28
29
  /** Outstanding requests by id. */
@@ -34,11 +35,14 @@ export declare class BridgeConnection {
34
35
  private idLessBridge;
35
36
  /** True once this connection may only have one request in flight at a time. */
36
37
  private serialisable;
38
+ /** True once the shell behind this connection has been reported gone. */
39
+ private lostNotified;
37
40
  /**
38
41
  * @param endpoint - the shell's published bridge endpoint.
39
42
  * @param endpointPath - where that endpoint is republished, so a moved port can be followed.
43
+ * @param onLost - called once when this connection concludes the shell behind it is gone.
40
44
  */
41
- constructor(endpoint: BridgeEndpoint, endpointPath?: string | undefined);
45
+ constructor(endpoint: BridgeEndpoint, endpointPath?: string | undefined, onLost?: (() => void) | undefined);
42
46
  /**
43
47
  * Re-read the published endpoint, and adopt it if the shell has moved.
44
48
  *
@@ -53,6 +57,8 @@ export declare class BridgeConnection {
53
57
  * @returns true when a different endpoint was adopted.
54
58
  */
55
59
  private refreshEndpoint;
60
+ /** Report once that the shell is gone, so the surface can fall back instead of dialling it. */
61
+ private notifyLost;
56
62
  /**
57
63
  * Send one request, reusing the socket if it is still healthy.
58
64
  *
@@ -33,6 +33,15 @@ const MAX_BUFFER_BYTES = 128 * 1024 * 1024;
33
33
  * client then falls back to arrival order, which is what the protocol guaranteed before.
34
34
  */
35
35
  const REQUEST_ID_FIELD = 'bridgeRequestId';
36
+ /**
37
+ * How long the published endpoint may go un-refreshed before its shell is presumed gone.
38
+ *
39
+ * A live shell rewrites the file every fifteen seconds (its keep-alive), so four missed refreshes
40
+ * is not a slow shell — it is a shell that exited and left its last write behind. The distinction
41
+ * matters because the file is the ONLY thing that says where the bridge is: a tombstone names a
42
+ * port nobody owns, and a client that trusts it dials that port for the rest of its life.
43
+ */
44
+ const ENDPOINT_TTL_MS = 60_000;
36
45
  /**
37
46
  * A reusable connection to the bridge.
38
47
  *
@@ -51,6 +60,7 @@ const REQUEST_ID_FIELD = 'bridgeRequestId';
51
60
  export class BridgeConnection {
52
61
  endpoint;
53
62
  endpointPath;
63
+ onLost;
54
64
  socket;
55
65
  buffer = '';
56
66
  /** Outstanding requests by id. */
@@ -62,13 +72,17 @@ export class BridgeConnection {
62
72
  idLessBridge = false;
63
73
  /** True once this connection may only have one request in flight at a time. */
64
74
  serialisable = false;
75
+ /** True once the shell behind this connection has been reported gone. */
76
+ lostNotified = false;
65
77
  /**
66
78
  * @param endpoint - the shell's published bridge endpoint.
67
79
  * @param endpointPath - where that endpoint is republished, so a moved port can be followed.
80
+ * @param onLost - called once when this connection concludes the shell behind it is gone.
68
81
  */
69
- constructor(endpoint, endpointPath) {
82
+ constructor(endpoint, endpointPath, onLost) {
70
83
  this.endpoint = endpoint;
71
84
  this.endpointPath = endpointPath;
85
+ this.onLost = onLost;
72
86
  }
73
87
  /**
74
88
  * Re-read the published endpoint, and adopt it if the shell has moved.
@@ -90,6 +104,13 @@ export class BridgeConnection {
90
104
  const raw = JSON.parse(readFileSync(this.endpointPath, 'utf8'));
91
105
  if (typeof raw.port !== 'number' || typeof raw.token !== 'string')
92
106
  return false;
107
+ // A shell that exited stops refreshing the file, and what it left names a port nobody owns.
108
+ // Trusting it is how a host ends up dialling a dead port for the rest of the process's life
109
+ // — reported as "the browser stopped working and only restarting DSH brought it back".
110
+ if (endpointExpired(raw.updatedAt)) {
111
+ this.notifyLost();
112
+ return false;
113
+ }
93
114
  if (raw.port === this.endpoint.port && raw.token === this.endpoint.token)
94
115
  return false;
95
116
  this.endpoint = {
@@ -105,6 +126,18 @@ export class BridgeConnection {
105
126
  return false;
106
127
  }
107
128
  }
129
+ /** Report once that the shell is gone, so the surface can fall back instead of dialling it. */
130
+ notifyLost() {
131
+ if (this.lostNotified)
132
+ return;
133
+ this.lostNotified = true;
134
+ try {
135
+ this.onLost?.();
136
+ }
137
+ catch {
138
+ // A listener must never break the transport: the caller's error is the one that matters.
139
+ }
140
+ }
108
141
  /**
109
142
  * Send one request, reusing the socket if it is still healthy.
110
143
  *
@@ -126,6 +159,14 @@ export class BridgeConnection {
126
159
  // when the condition could not clear, spun until the process ran out of heap.
127
160
  // Follow the shell if its bridge moved to a new port (see refreshEndpoint).
128
161
  this.refreshEndpoint();
162
+ // The shell behind this connection is gone, and the endpoint file says so. Refusing here is
163
+ // what turns a permanent ECONNREFUSED into one named error the surface can act on — by
164
+ // falling back to the browser it hosts itself, instead of dialling a dead port until DSH is
165
+ // restarted.
166
+ if (this.lostNotified) {
167
+ throw new Error('dsh-builtin-browser: the desktop shell that backed this browser has exited '
168
+ + '(its published endpoint stopped being refreshed)');
169
+ }
129
170
  let socket = this.ensureSocket();
130
171
  if (this.serialisable && this.queue.length > 0) {
131
172
  this.reset(new Error('dsh-builtin-browser: bridge connection reopened so replies stay attributable'));
@@ -281,8 +322,22 @@ export class BridgeConnection {
281
322
  entry.reject(error);
282
323
  }
283
324
  }
284
- /** The request id a raw answer line echoes back, when it carries one. */
285
- function echoOf(line) {
325
+ /**
326
+ * Whether a published endpoint has gone un-refreshed long enough to be a tombstone.
327
+ *
328
+ * A missing or unparseable stamp is NOT expired: an older bridge that never wrote one must keep
329
+ * working, and guessing "stale" from absence would break every shell that predates the field.
330
+ * @param updatedAt - the file's own refresh stamp, when it carries one.
331
+ */
332
+ function endpointExpired(updatedAt) {
333
+ if (typeof updatedAt !== 'string')
334
+ return false;
335
+ const at = Date.parse(updatedAt);
336
+ if (!Number.isFinite(at))
337
+ return false;
338
+ return Date.now() - at > ENDPOINT_TTL_MS;
339
+ }
340
+ /** The request id a raw answer line echoes back, when it carries one. */ function echoOf(line) {
286
341
  try {
287
342
  const answer = JSON.parse(line);
288
343
  const value = answer[REQUEST_ID_FIELD];
@@ -94,10 +94,21 @@ export declare class DesktopBridgeViewHost implements ElectronBrowserViewHost {
94
94
  * the desktop sidebar is the carrier — by design, not by omission.
95
95
  */
96
96
  private userActionHandler;
97
+ /** Told when the shell behind this bridge exits; see `onLost`. */
98
+ private lostListener;
97
99
  /**
98
100
  * @param endpoint - the shell's published bridge endpoint.
99
101
  */
100
102
  private constructor();
103
+ /**
104
+ * Called by the transport once the shell behind it is gone.
105
+ *
106
+ * Forwarded rather than acted on here: only the entry point owns the swap back to the self-hosted
107
+ * browser, and a host that tried to replace itself would be reaching outside its own contract.
108
+ */
109
+ private reportLost;
110
+ /** Register the "this carrier is gone" listener (see the interface's `onLost`). */
111
+ onLost(listener: () => void): void;
101
112
  /**
102
113
  * Find a usable desktop bridge, if this surface has one.
103
114
  *
@@ -111,12 +111,32 @@ export class DesktopBridgeViewHost {
111
111
  * the desktop sidebar is the carrier — by design, not by omission.
112
112
  */
113
113
  userActionHandler;
114
+ /** Told when the shell behind this bridge exits; see `onLost`. */
115
+ lostListener;
114
116
  /**
115
117
  * @param endpoint - the shell's published bridge endpoint.
116
118
  */
117
119
  constructor(endpoint, sessionId) {
118
120
  this.owner = sessionId !== undefined && sessionId !== String.fromCharCode(39, 39) ? sessionId : randomUUID();
119
- this.connection = new BridgeConnection(endpoint, bridgeEndpointPath());
121
+ this.connection = new BridgeConnection(endpoint, bridgeEndpointPath(), () => this.reportLost());
122
+ }
123
+ /**
124
+ * Called by the transport once the shell behind it is gone.
125
+ *
126
+ * Forwarded rather than acted on here: only the entry point owns the swap back to the self-hosted
127
+ * browser, and a host that tried to replace itself would be reaching outside its own contract.
128
+ */
129
+ reportLost() {
130
+ try {
131
+ this.lostListener?.();
132
+ }
133
+ catch {
134
+ // The listener's failure is not the transport's problem.
135
+ }
136
+ }
137
+ /** Register the "this carrier is gone" listener (see the interface's `onLost`). */
138
+ onLost(listener) {
139
+ this.lostListener = listener;
120
140
  }
121
141
  /**
122
142
  * Find a usable desktop bridge, if this surface has one.