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 +22 -0
- package/lib/config.d.ts +2 -0
- package/lib/config.js +9 -0
- package/lib/index.js +77 -27
- package/lib/mount.d.ts +56 -0
- package/lib/mount.js +110 -0
- package/lib/relay/client.d.ts +41 -0
- package/lib/relay/client.js +134 -5
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
519
|
+
let mounted = null;
|
|
520
|
+
void mounts.start(async () => {
|
|
521
|
+
if (controller.signal.aborted)
|
|
522
|
+
return null;
|
|
523
|
+
let next;
|
|
485
524
|
try {
|
|
486
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/relay/client.d.ts
CHANGED
|
@@ -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
|
}
|
package/lib/relay/client.js
CHANGED
|
@@ -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
|
-
|
|
185
|
-
this.#
|
|
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) =>
|
|
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.#
|
|
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;
|