dsh-mizar 0.8.5 → 0.8.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/README.md CHANGED
@@ -121,6 +121,28 @@ DSH 自己的 Web 接口暴露出去(那是 DSH 的安全边界,插件不碰
121
121
  通信**:手机即使和电脑在同一个 Wi-Fi 也走中继,局域网解锁(附件、全量细节)不生效,其余
122
122
  功能不变。
123
123
 
124
+ ### 中继隧道自愈:断线会自己重连
125
+
126
+ 中继方向有两个只有长期运行才会暴露的故障,0.8.6 起都做了兜底:
127
+
128
+ - **半开隧道。** 电脑换网络(Wi-Fi 切换、VPN 断掉、休眠唤醒)时,本地 socket 可能既收不到
129
+ 中继的 close、也不再有任何流量,而系统仍报 `ESTABLISHED`。中继那边过了 90 秒没收到
130
+ pong 就把房间摘掉;插件这边却一直显示「已连接」,于是再也不重拨 —— 手机无论直连还是中继
131
+ 都连不上,只能重启 DSH。现在插件自己有**静默看门狗**:超过 `relay.watchdogMs`(默认
132
+ 120 秒,即中继 30 秒一次 ping 的四个周期)没收到中继任何一帧,就强制丢弃这条 socket 并
133
+ 重拨。可用 `relay.watchdogMs: 0` 关掉。
134
+ - **端口漂移。** DSH 可能重复挂载/重载插件;以前每次挂载都绑一个新监听,端口从 19390 一路
135
+ 漂到 19391、19392,手机配对里保存的直连地址就永远失效了。现在一个进程只保留**一个**挂载:
136
+ 新挂载要等旧挂载完全释放监听之后才绑端口;网络变化导致监听地址改变时,隧道也会立刻用新
137
+ 地址重拨,并把监听端口的变化同步给隧道(否则隧道健康、手机请求却全部 502)。
138
+ - **0.8.7 修了 0.8.6 单实例的漏洞。** 0.8.6 的注册表是模块级的,而 DSH 会在同一进程里多次
139
+ 加载插件模块,每份副本各有一张空注册表——实测一个进程因此挂了 8 个监听(19390–19397)。
140
+ 0.8.7 把注册表放到 `globalThis`(`Symbol.for('dsh-mizar.mount-registry')`),所有模块副本
141
+ 共用同一个槽位,一个进程只剩一个监听。
142
+
143
+ 日志里对应 `relay: the tunnel has been silent for …s — redialling it`、
144
+ `relay: the network changed — redialling the tunnel` 和 `relay: the listener moved to port …`。
145
+
124
146
  ### 对话只传总结
125
147
 
126
148
  一页历史里只保留这些:你的提问、每一轮的 `turn/start` 和 `turn/end`、每个 `step/start`
package/lib/config.d.ts CHANGED
@@ -97,6 +97,8 @@ export interface Config {
97
97
  preferLoopback: boolean;
98
98
  reconnectBaseMs: number;
99
99
  reconnectMaxMs: number;
100
+ /** How long the relay may stay silent before the tunnel is redialled. */
101
+ watchdogMs: number;
100
102
  };
101
103
  pairing: {
102
104
  codeTtlSeconds: number;
package/lib/config.js CHANGED
@@ -38,6 +38,15 @@ export const Config = z.object({
38
38
  preferLoopback: z.boolean().default(false),
39
39
  reconnectBaseMs: z.natural().default(1_000),
40
40
  reconnectMaxMs: z.natural().default(60_000),
41
+ /**
42
+ * How long the relay may stay silent before the tunnel is redialled, in ms.
43
+ *
44
+ * The relay pings every tunnel every `tunnelIdleMs / 3` (30 s with the
45
+ * relay's default 90 s idle window). Four missed pings is longer than any
46
+ * healthy relay's silence and short enough that a blackholed network cannot
47
+ * leave a dead room behind for hours. `0` disables the watchdog.
48
+ */
49
+ watchdogMs: z.natural().default(120_000),
41
50
  }),
42
51
  pairing: z.object({
43
52
  codeTtlSeconds: z.natural().default(300),
package/lib/index.js CHANGED
@@ -27,6 +27,7 @@ import { awaitRequestedPlan, bindKey, isRequested, needsRebind } from './net/wat
27
27
  import { PROTOCOL_VERSION } from './protocol/frames.js';
28
28
  import { MizarListener } from './server/listener.js';
29
29
  import { RelayClient, phoneBaseURL } from './relay/client.js';
30
+ import { sharedMountRegistry } from './mount.js';
30
31
  import { loadOrCreateTunnelSecret } from './relay/identity.js';
31
32
  import { runSpike } from './spike.js';
32
33
  /** Cordis plugin name. */
@@ -54,7 +55,7 @@ const systemLocale = (() => {
54
55
  * code in place and every symptom unchanged. One line at boot turns that from a
55
56
  * guess into a fact.
56
57
  */
57
- const MIZAR_VERSION = '0.8.5';
58
+ const MIZAR_VERSION = '0.8.7';
58
59
  /**
59
60
  * Services this plugin must observe before it activates.
60
61
  *
@@ -78,7 +79,7 @@ export const inject = ['typert', 'typertGateway', 'sessionProjections', 'session
78
79
  * @param initial - the plan the listener is bound to now.
79
80
  * @param signal - aborted when the plugin unloads.
80
81
  */
81
- function startBindWatcher(config, readPlan, holder, initial, signal) {
82
+ function startBindWatcher(config, readPlan, holder, initial, signal, onRebound) {
82
83
  let current = bindKey(initial);
83
84
  const timer = setInterval(() => {
84
85
  void (async () => {
@@ -92,6 +93,11 @@ function startBindWatcher(config, readPlan, holder, initial, signal) {
92
93
  try {
93
94
  await listener.rebind(next);
94
95
  current = bindKey(next);
96
+ // A rebind that lands on a different port has just moved the tunnel's
97
+ // replay target, and a network change is exactly when the outbound
98
+ // tunnel should be redialled from the new address rather than left on a
99
+ // path the operating system no longer has.
100
+ onRebound?.(listener);
95
101
  }
96
102
  catch (error) {
97
103
  report(`rebind failed, staying on [${current}] — ${describe(error)}`);
@@ -112,7 +118,7 @@ function describe(error) {
112
118
  return String(error);
113
119
  }
114
120
  /**
115
- * Start the listener for one plugin mount and return its disposer.
121
+ * Start the listener for one plugin mount and return it with its disposer.
116
122
  *
117
123
  * @param ctx - the plugin context, used for the lifecycle effect.
118
124
  * @param config - validated plugin configuration.
@@ -298,6 +304,7 @@ async function startListener(ctx, config, signal) {
298
304
  log: report,
299
305
  reconnectBaseMs: config.relay.reconnectBaseMs,
300
306
  reconnectMaxMs: config.relay.reconnectMaxMs,
307
+ watchdogMs: config.relay.watchdogMs,
301
308
  onState: (state) => {
302
309
  const wasConnected = relayState.connected;
303
310
  relayState = state;
@@ -435,28 +442,56 @@ async function startListener(ctx, config, signal) {
435
442
  // entirely.
436
443
  notifier.noteActivity(session.id, event.type);
437
444
  });
438
- startBindWatcher(config, readPlan, holder, plan, signal);
445
+ startBindWatcher(config, readPlan, holder, plan, signal, (rebound) => {
446
+ relay?.setLocalPort(rebound.port);
447
+ relay?.reconnectNow('the network changed');
448
+ });
449
+ // One shutdown path, idempotent and awaitable. The tunnel goes first: it
450
+ // exists to carry traffic to the listener, so leaving it up while the listener
451
+ // closes would produce 502s instead of clean disconnects for whatever is
452
+ // still connected.
453
+ let shutdown = null;
454
+ const dispose = () => {
455
+ shutdown ??= (async () => {
456
+ relay?.stop();
457
+ // A pending notification is about a session that has stopped; on unload
458
+ // there is nothing left to announce it with.
459
+ notifier.dispose();
460
+ // Release anything still waiting before the transport disappears, so no
461
+ // agent is left holding a turn open for a question nobody can answer.
462
+ broker.dispose();
463
+ try {
464
+ await listener.stop();
465
+ }
466
+ catch (error) {
467
+ report(`listener shutdown failed — ${describe(error)}`);
468
+ }
469
+ })();
470
+ return shutdown;
471
+ };
472
+ // An unload must not wait for the mount registry's queue to notice: DSH
473
+ // expects an effect's cleanup to stop the work it started.
439
474
  signal.addEventListener('abort', () => {
440
- // The tunnel goes first: it exists to carry traffic to the listener, so
441
- // leaving it up while the listener closes would produce 502s instead of
442
- // clean disconnects for whatever is still connected.
443
- relay?.stop();
444
- // A pending notification is about a session that has stopped; on unload
445
- // there is nothing left to announce it with.
446
- notifier.dispose();
447
- // Release anything still waiting before the transport disappears, so no
448
- // agent is left holding a turn open for a question nobody can answer.
449
- broker.dispose();
450
- void listener.stop().catch((error) => {
451
- report(`listener shutdown failed — ${describe(error)}`);
452
- });
475
+ void dispose();
453
476
  });
454
477
  if (config.pairing.issueOnStart) {
455
478
  const issued = await listener.issuePairingCode();
456
479
  report(`pairing code: ${issued.display} (valid for ${config.pairing.codeTtlSeconds}s)`);
457
480
  }
458
- return listener;
481
+ return { listener, dispose };
459
482
  }
483
+ /**
484
+ * The one mount this process runs at a time.
485
+ *
486
+ * DSH may compose or re-apply a bundle more than once, and each apply used to
487
+ * bind its own listener: three mounts in one process left three ports listening
488
+ * (19390/19391/19392) and the port in a phone's pairing was from the wrong
489
+ * generation of them. The plugin cannot order DSH's lifecycle, so it keeps the
490
+ * invariant it owns: a new mount starts only after the previous one has
491
+ * finished shutting down, so the port is free and the address a phone saved is
492
+ * still the address the computer listens on.
493
+ */
494
+ const mounts = sharedMountRegistry();
460
495
  /**
461
496
  * Mount the Mizar host half.
462
497
  * @param ctx - the plugin context.
@@ -481,20 +516,35 @@ export function apply(ctx, config) {
481
516
  }
482
517
  ctx.effect(() => {
483
518
  const controller = new AbortController();
484
- void (async () => {
519
+ let mounted = null;
520
+ void mounts.start(async () => {
521
+ if (controller.signal.aborted)
522
+ return null;
523
+ let next;
485
524
  try {
486
- const listener = await startListener(ctx, config, controller.signal);
487
- if (controller.signal.aborted) {
488
- await listener.stop();
489
- return;
490
- }
491
- report(`ready — pair with the code shown in the DSH Web panel, or reach ${listener.authorities.join(' / ')}`);
525
+ next = await startListener(ctx, config, controller.signal);
492
526
  }
493
527
  catch (error) {
494
528
  report(`listener failed to start — ${describe(error)}`);
529
+ return null;
495
530
  }
496
- })();
497
- return () => controller.abort();
531
+ if (controller.signal.aborted) {
532
+ // Stopped while starting: do not leave a listener behind for the next
533
+ // mount to find, and do not publish it as the active one.
534
+ await next.dispose();
535
+ return null;
536
+ }
537
+ mounted = next;
538
+ report(`ready — pair with the code shown in the DSH Web panel, or reach ${next.listener.authorities.join(' / ')}`);
539
+ return next;
540
+ });
541
+ return () => {
542
+ // Abort for a fast stop, then queue the awaited disposal: the mount that
543
+ // replaces this one must bind only after the port is really free.
544
+ controller.abort();
545
+ void mounts.stop(mounted);
546
+ mounted = null;
547
+ };
498
548
  }, 'mizar:listener');
499
549
  if (!config.spike)
500
550
  return;
package/lib/mount.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The one registry every copy of this plugin in a process shares.
3
+ *
4
+ * Module-level state is not enough: the plugin can be loaded more than once in
5
+ * one process, and each load gets its own copy of this module -- which is how a
6
+ * single DSH process ended up with eight Mizar listeners (19390 through 19397)
7
+ * after 0.8.6 shipped a module-level registry. Each copy saw an empty slot and
8
+ * bound its own port, so the port a phone had saved was always one generation
9
+ * behind.
10
+ *
11
+ * `globalThis` is shared by every copy in the process, and `Symbol.for` gives
12
+ * the key a stable identity across them. The registry therefore outlives a
13
+ * module copy exactly as long as the process does, which is the lifetime that
14
+ * matters for a listening port.
15
+ *
16
+ * @returns the process-wide registry.
17
+ */
18
+ export declare function sharedMountRegistry(): MountRegistry;
19
+ /** Something that can be shut down once, asynchronously. */
20
+ export interface Mounted {
21
+ /** Release the listener, the tunnel and anything else this mount owns. */
22
+ dispose(): Promise<void>;
23
+ }
24
+ /**
25
+ * The serialized, single-instance mount slot.
26
+ *
27
+ * `start` disposes the running mount before creating the next; `stop` disposes
28
+ * one mount without disturbing a newer one. Both run on one chain, so the order
29
+ * calls were made in is the order they act in -- which is what keeps a
30
+ * replacement from binding while the old listener still owns the port.
31
+ */
32
+ export declare class MountRegistry {
33
+ #private;
34
+ /**
35
+ * Start a mount after stopping the one already running.
36
+ *
37
+ * A creation that fails (or that decides it is unwanted and returns `null`)
38
+ * leaves the slot empty rather than with a half-mounted object.
39
+ *
40
+ * @param create - makes the mount; may return `null` to mount nothing.
41
+ * @returns the created mount, or `null`.
42
+ */
43
+ start<T extends Mounted>(create: () => Promise<T | null>): Promise<T | null>;
44
+ /**
45
+ * Stop the running mount.
46
+ *
47
+ * `null` is a no-op, and so is a mount a newer `start` has already replaced:
48
+ * that replacement disposed it before creating its own, and disposing it a
49
+ * second time would only run a shutdown that is already complete.
50
+ *
51
+ * @param mounted - the mount to dispose, when it is the one still running.
52
+ */
53
+ stop(mounted: Mounted | null): Promise<void>;
54
+ /** The mount this process is running, when it has one. */
55
+ get active(): Mounted | null;
56
+ }
package/lib/mount.js ADDED
@@ -0,0 +1,110 @@
1
+ //
2
+ // mount.ts
3
+ // MizarKit
4
+ //
5
+ // One set of Mizar resources per process, mounted in order.
6
+ //
7
+ // The reported failure: a phone could not reach its computer at all, and the
8
+ // machine had three live Mizar listeners in one DSH process — ports 19390,
9
+ // 19391 and 19392, each belonging to a different plugin mount. The first one
10
+ // held the pairing's address, the current one held the relay replay target, and
11
+ // the port a phone had saved in its pairing was from neither of the two that
12
+ // still answered. A port that moves is a pairing that rots.
13
+ //
14
+ // DSH may compose, reload or re-apply a bundle more than once, and each mount
15
+ // used to bind its own listener without the previous one necessarily being
16
+ // released first. The plugin cannot order DSH's lifecycle, so it enforces the
17
+ // invariant it owns instead: **at most one mount runs at a time, and a new one
18
+ // starts only after the previous one has finished shutting down.** Every start
19
+ // and stop goes through one promise chain, so concurrent applies queue rather
20
+ // than race.
21
+ //
22
+ // Pure, so the ordering is tested without a plugin host or an open port.
23
+ //
24
+ /**
25
+ * The one registry every copy of this plugin in a process shares.
26
+ *
27
+ * Module-level state is not enough: the plugin can be loaded more than once in
28
+ * one process, and each load gets its own copy of this module -- which is how a
29
+ * single DSH process ended up with eight Mizar listeners (19390 through 19397)
30
+ * after 0.8.6 shipped a module-level registry. Each copy saw an empty slot and
31
+ * bound its own port, so the port a phone had saved was always one generation
32
+ * behind.
33
+ *
34
+ * `globalThis` is shared by every copy in the process, and `Symbol.for` gives
35
+ * the key a stable identity across them. The registry therefore outlives a
36
+ * module copy exactly as long as the process does, which is the lifetime that
37
+ * matters for a listening port.
38
+ *
39
+ * @returns the process-wide registry.
40
+ */
41
+ export function sharedMountRegistry() {
42
+ const key = Symbol.for('dsh-mizar.mount-registry');
43
+ const bag = globalThis;
44
+ let registry = bag[key];
45
+ if (registry === undefined) {
46
+ registry = new MountRegistry();
47
+ bag[key] = registry;
48
+ }
49
+ return registry;
50
+ }
51
+ /**
52
+ * The serialized, single-instance mount slot.
53
+ *
54
+ * `start` disposes the running mount before creating the next; `stop` disposes
55
+ * one mount without disturbing a newer one. Both run on one chain, so the order
56
+ * calls were made in is the order they act in -- which is what keeps a
57
+ * replacement from binding while the old listener still owns the port.
58
+ */
59
+ export class MountRegistry {
60
+ /** Serializes every start and stop. Never rejects; failures are per task. */
61
+ #tail = Promise.resolve();
62
+ #active = null;
63
+ /** Run one task after everything queued before it. */
64
+ #enqueue(task) {
65
+ const run = this.#tail.then(task, task);
66
+ this.#tail = run.then(() => undefined, () => undefined);
67
+ return run;
68
+ }
69
+ /**
70
+ * Start a mount after stopping the one already running.
71
+ *
72
+ * A creation that fails (or that decides it is unwanted and returns `null`)
73
+ * leaves the slot empty rather than with a half-mounted object.
74
+ *
75
+ * @param create - makes the mount; may return `null` to mount nothing.
76
+ * @returns the created mount, or `null`.
77
+ */
78
+ start(create) {
79
+ return this.#enqueue(async () => {
80
+ const previous = this.#active;
81
+ this.#active = null;
82
+ if (previous !== null)
83
+ await previous.dispose();
84
+ const created = await create();
85
+ this.#active = created;
86
+ return created;
87
+ });
88
+ }
89
+ /**
90
+ * Stop the running mount.
91
+ *
92
+ * `null` is a no-op, and so is a mount a newer `start` has already replaced:
93
+ * that replacement disposed it before creating its own, and disposing it a
94
+ * second time would only run a shutdown that is already complete.
95
+ *
96
+ * @param mounted - the mount to dispose, when it is the one still running.
97
+ */
98
+ stop(mounted) {
99
+ return this.#enqueue(async () => {
100
+ if (mounted === null || this.#active !== mounted)
101
+ return;
102
+ this.#active = null;
103
+ await mounted.dispose();
104
+ });
105
+ }
106
+ /** The mount this process is running, when it has one. */
107
+ get active() {
108
+ return this.#active;
109
+ }
110
+ }
@@ -24,6 +24,19 @@ export interface RelayClientOptions {
24
24
  readonly reconnectBaseMs?: number;
25
25
  /** Longest reconnect delay, in ms. */
26
26
  readonly reconnectMaxMs?: number;
27
+ /**
28
+ * How long the relay may stay silent before the tunnel is redialled, in ms.
29
+ *
30
+ * The relay pings every tunnel (`tunnelIdleMs / 3` in its own config, 30 s by
31
+ * default) precisely so that a half-open tunnel becomes a `close` it can act
32
+ * on. The computer has the mirror problem: a network change can blackhole the
33
+ * socket without the operating system ever reporting a close, and then the
34
+ * tunnel is gone at the relay while this side still says "connected" -- which
35
+ * is what left a phone's relay room unreachable for hours. Silence for longer
36
+ * than this window is treated as a dead tunnel and redialled. `0` disables the
37
+ * watchdog.
38
+ */
39
+ readonly watchdogMs?: number;
27
40
  }
28
41
  /** What the outside world can see about the tunnel. */
29
42
  export interface RelayClientState {
@@ -45,6 +58,12 @@ export interface RelayClientState {
45
58
  export interface RelaySocket {
46
59
  send(text: string): void;
47
60
  close(code?: number, reason?: string): void;
61
+ /**
62
+ * Drop the socket without a closing handshake, when the implementation has
63
+ * one. A half-open socket cannot complete a close handshake, so the watchdog
64
+ * needs this; a test stand-in may omit it and be closed with `close`.
65
+ */
66
+ terminate?(): void;
48
67
  on(event: 'message', listener: (data: Buffer, isBinary: boolean) => void): unknown;
49
68
  on(event: 'close', listener: (code: number, reason: Buffer) => void): unknown;
50
69
  on(event: 'error', listener: (error: Error) => void): unknown;
@@ -121,4 +140,26 @@ export declare class RelayClient {
121
140
  start(): void;
122
141
  /** Stop reconnecting and close the tunnel. */
123
142
  stop(): void;
143
+ /**
144
+ * Redial the tunnel now, keeping the reconnect loop.
145
+ *
146
+ * Used when the reason to redial is known here rather than discovered by the
147
+ * transport: the host's network changed, or the listener moved to a different
148
+ * port. The attempt counter is reset so the next socket is tried at the base
149
+ * delay rather than after a backoff earned by an unrelated failure.
150
+ *
151
+ * @param reason - why, for the operator's log.
152
+ */
153
+ reconnectNow(reason: string): void;
154
+ /**
155
+ * Point the replay target at a listener bound to a different port.
156
+ *
157
+ * Rebinding can land on a different port when the configured one was taken
158
+ * while the listener was moving. The tunnel was constructed with the port the
159
+ * listener had at startup; replaying to the old one would answer every phone
160
+ * request with a connection error over a tunnel that is otherwise healthy.
161
+ *
162
+ * @param port - the listener's current port.
163
+ */
164
+ setLocalPort(port: number): void;
124
165
  }
@@ -168,11 +168,18 @@ function forwardable(headers) {
168
168
  /** The tunnel client. */
169
169
  export class RelayClient {
170
170
  #options;
171
+ #localHost;
172
+ #localPort;
171
173
  #localOrigin;
172
174
  #socket = null;
173
175
  #stopped = false;
174
176
  #attempt = 0;
175
177
  #timer;
178
+ /** How long silence from the relay is tolerated before a redial. */
179
+ #watchdogMs;
180
+ #watchdog;
181
+ /** When the last frame from the relay arrived, on any clock change. */
182
+ #lastFrameAt = 0;
176
183
  /** Phone WebSockets currently bridged through this tunnel, by tunnel id. */
177
184
  #streams = new Map();
178
185
  #state = { connected: false, room: null, lastError: null };
@@ -181,8 +188,10 @@ export class RelayClient {
181
188
  */
182
189
  constructor(options) {
183
190
  this.#options = options;
184
- const host = options.localHost ?? '127.0.0.1';
185
- this.#localOrigin = `http://${host}:${options.localPort}`;
191
+ this.#localHost = options.localHost ?? '127.0.0.1';
192
+ this.#localPort = options.localPort;
193
+ this.#localOrigin = `http://${this.#localHost}:${this.#localPort}`;
194
+ this.#watchdogMs = options.watchdogMs ?? 120_000;
186
195
  }
187
196
  /** The relay connection's current state. */
188
197
  get state() {
@@ -195,6 +204,8 @@ export class RelayClient {
195
204
  /** Connect, and keep reconnecting until {@link stop}. */
196
205
  start() {
197
206
  this.#stopped = false;
207
+ this.#lastFrameAt = Date.now();
208
+ this.#armWatchdog();
198
209
  this.#connect();
199
210
  }
200
211
  /** Stop reconnecting and close the tunnel. */
@@ -203,11 +214,54 @@ export class RelayClient {
203
214
  if (this.#timer !== undefined)
204
215
  clearTimeout(this.#timer);
205
216
  this.#timer = undefined;
217
+ if (this.#watchdog !== undefined)
218
+ clearInterval(this.#watchdog);
219
+ this.#watchdog = undefined;
206
220
  this.#closeStreams('the tunnel is shutting down');
207
221
  this.#socket?.close(1000, 'shutting down');
208
222
  this.#socket = null;
209
223
  this.#setState({ connected: false });
210
224
  }
225
+ /**
226
+ * Redial the tunnel now, keeping the reconnect loop.
227
+ *
228
+ * Used when the reason to redial is known here rather than discovered by the
229
+ * transport: the host's network changed, or the listener moved to a different
230
+ * port. The attempt counter is reset so the next socket is tried at the base
231
+ * delay rather than after a backoff earned by an unrelated failure.
232
+ *
233
+ * @param reason - why, for the operator's log.
234
+ */
235
+ reconnectNow(reason) {
236
+ if (this.#stopped)
237
+ return;
238
+ this.#attempt = 0;
239
+ if (this.#socket === null) {
240
+ this.#options.log(`relay: ${reason} — redialling the tunnel`);
241
+ this.#scheduleReconnect(0);
242
+ return;
243
+ }
244
+ this.#options.log(`relay: ${reason} — redialling the tunnel`);
245
+ this.#drop(reason);
246
+ }
247
+ /**
248
+ * Point the replay target at a listener bound to a different port.
249
+ *
250
+ * Rebinding can land on a different port when the configured one was taken
251
+ * while the listener was moving. The tunnel was constructed with the port the
252
+ * listener had at startup; replaying to the old one would answer every phone
253
+ * request with a connection error over a tunnel that is otherwise healthy.
254
+ *
255
+ * @param port - the listener's current port.
256
+ */
257
+ setLocalPort(port) {
258
+ if (!Number.isFinite(port) || port <= 0 || port === this.#localPort)
259
+ return;
260
+ this.#localPort = port;
261
+ this.#localOrigin = `http://${this.#localHost}:${port}`;
262
+ this.#options.log(`relay: the listener moved to port ${port}`);
263
+ this.reconnectNow('the listener moved to a different port');
264
+ }
211
265
  /** Open a connection attempt. */
212
266
  #connect() {
213
267
  if (this.#stopped)
@@ -222,14 +276,24 @@ export class RelayClient {
222
276
  headers: { authorization: `Bearer ${this.#options.token}` },
223
277
  });
224
278
  this.#socket = socket;
279
+ this.#lastFrameAt = Date.now();
225
280
  socket.on('open', () => {
281
+ this.#lastFrameAt = Date.now();
226
282
  this.#options.log(`relay: connected to ${url}`);
227
283
  });
228
- socket.on('message', (data) => this.#receive(data.toString('utf8')));
284
+ socket.on('message', (data) => {
285
+ this.#lastFrameAt = Date.now();
286
+ this.#receive(data.toString('utf8'));
287
+ });
229
288
  socket.on('error', (error) => {
230
289
  this.#options.log(`relay: ${error.message}`);
231
290
  });
232
291
  socket.on('close', () => {
292
+ // A socket this client has already replaced is not news: the watchdog and
293
+ // `reconnectNow` both drop the old socket before scheduling the next, and
294
+ // acting on its late close would reset the backoff they just earned.
295
+ if (this.#socket !== socket)
296
+ return;
233
297
  this.#socket = null;
234
298
  this.#closeStreams('the tunnel closed');
235
299
  if (this.#stopped)
@@ -237,10 +301,75 @@ export class RelayClient {
237
301
  const delay = this.#backoff();
238
302
  this.#setState({ connected: false, lastError: 'the relay connection closed' });
239
303
  this.#options.log(`relay: reconnecting in ${Math.round(delay / 1000)}s`);
240
- this.#timer = setTimeout(() => this.#connect(), delay);
241
- this.#timer.unref?.();
304
+ this.#scheduleReconnect(delay);
242
305
  });
243
306
  }
307
+ /** Time one connection attempt, clearing any already pending. */
308
+ #scheduleReconnect(delay) {
309
+ if (this.#timer !== undefined)
310
+ clearTimeout(this.#timer);
311
+ this.#timer = setTimeout(() => {
312
+ this.#timer = undefined;
313
+ this.#connect();
314
+ }, delay);
315
+ this.#timer.unref?.();
316
+ }
317
+ /**
318
+ * Start the silence watchdog.
319
+ *
320
+ * The relay pings every tunnel so a computer that vanished is dropped; this is
321
+ * the same question asked from the other side. Without it a half-open socket
322
+ * -- a laptop that walked to another network -- stays "connected" here while
323
+ * the relay has already forgotten the room, and nothing ever redials.
324
+ */
325
+ #armWatchdog() {
326
+ if (this.#watchdog !== undefined || this.#watchdogMs <= 0)
327
+ return;
328
+ const every = Math.max(20, Math.floor(this.#watchdogMs / 4));
329
+ this.#watchdog = setInterval(() => this.#checkWatchdog(), every);
330
+ this.#watchdog.unref?.();
331
+ }
332
+ /** Redial a tunnel the relay has stopped talking to. */
333
+ #checkWatchdog() {
334
+ if (this.#stopped || this.#socket === null)
335
+ return;
336
+ const silent = Date.now() - this.#lastFrameAt;
337
+ if (silent <= this.#watchdogMs)
338
+ return;
339
+ this.#options.log(`relay: the tunnel has been silent for ${Math.round(silent / 1000)}s — redialling it`);
340
+ this.#drop('no traffic from the relay inside the watchdog window');
341
+ }
342
+ /**
343
+ * Forget the current socket and start over.
344
+ *
345
+ * `terminate` rather than `close` where the transport has it: a half-open
346
+ * socket cannot complete a closing handshake, and waiting for one is how the
347
+ * "connected" state outlives the connection. A test socket without `terminate`
348
+ * gets the ordinary close.
349
+ */
350
+ #drop(reason) {
351
+ const socket = this.#socket;
352
+ this.#socket = null;
353
+ this.#closeStreams(reason);
354
+ if (socket !== null) {
355
+ try {
356
+ if (typeof socket.terminate === 'function')
357
+ socket.terminate();
358
+ else
359
+ socket.close(4001, reason);
360
+ }
361
+ catch {
362
+ // The close/reconnect below is the recovery path; a throwing transport is
363
+ // already gone.
364
+ }
365
+ }
366
+ if (this.#stopped)
367
+ return;
368
+ const delay = this.#backoff();
369
+ this.#setState({ connected: false, lastError: reason });
370
+ this.#options.log(`relay: ${reason}; reconnecting in ${Math.round(delay / 1000)}s`);
371
+ this.#scheduleReconnect(delay);
372
+ }
244
373
  /** Exponential backoff with jitter, so many computers do not return in lockstep. */
245
374
  #backoff() {
246
375
  const base = this.#options.reconnectBaseMs ?? 1_000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mizar",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "description": "Remote monitoring and guidance for DSH agents — host plugin backing the DSH-Mizar iOS app.",
5
5
  "license": "GPL-3.0-only",
6
6
  "type": "module",