@mrrisega/dsh-remote 0.7.6-beta.3 → 0.7.6-beta.4
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.
|
@@ -631,7 +631,10 @@ export function dcSend(dc, obj) {
|
|
|
631
631
|
* · 每轮发送前先排空控制车道,并检查 `ws.bufferedAmount`:超过高水位就停手等它排空。
|
|
632
632
|
* 于是控制帧前面最多只有「高水位 + 一片」的待发字节 —— 默认 64KB + 64KB,手机 1Mbps
|
|
633
633
|
* 下 ≈1s,8Mbps 下 ≈0.13s,远小于 router 的 20s,也满足「大响应传输中 1s 内应答」目标。
|
|
634
|
-
* ·
|
|
634
|
+
* · **数据帧严格 FIFO**(2026-10-24 修):早期实现让小帧(交互/SSE 块)插到大帧下一片之前,
|
|
635
|
+
* 这在同一 WS 流里是**乱序** —— 生产表现为手机远程页「历史加载失败:session event stream
|
|
636
|
+
* emitted an entry before its opening cursor」。现在只有一个数据队列,大帧按片就地推进,
|
|
637
|
+
* 绝不因大小/类型被后来的帧超越;只有控制帧(不承载有序数据)保留插队权。
|
|
635
638
|
* · 阈值 / 分片大小可用环境变量调(见 tunnelBulkOptionsFromEnv),弱网部署可再调低。
|
|
636
639
|
*/
|
|
637
640
|
export const TUNNEL_BULK_HWM_DEFAULT = 64 * 1024; // 发送缓冲高水位(字节)
|
|
@@ -680,13 +683,19 @@ export function makePrioritySender({
|
|
|
680
683
|
let pumping = false;
|
|
681
684
|
let scheduled = false;
|
|
682
685
|
let timerPending = false;
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
686
|
+
// 🔴 2026-10-24 事故修复:数据帧**必须严格 FIFO**。
|
|
687
|
+
// 旧实现分「小帧车道 / 大帧车道」,小帧会插到大帧下一片之前 —— 这违反了同一 WS 流里
|
|
688
|
+
// 消息的顺序语义,生产表现为手机远程页「历史加载失败:session event stream emitted an
|
|
689
|
+
// entry before its opening cursor(gateway/internal)」:事件流请求/响应被乱序送达,
|
|
690
|
+
// 客户端的游标推进到了前面,重连时服务端按该游标回放就触发 cursor 错乱。
|
|
691
|
+
// 现在:控制帧仍可插队(它们是协议控制帧,不承载有序数据);**数据帧只有一个队列**,
|
|
692
|
+
// 大帧按片就地推进,绝不因大小/类型被后来的帧超越。
|
|
693
|
+
const dataQ = []; // { frames: string[], i: number };frames.length===1 = 未分片小帧
|
|
694
|
+
const stats = { controlSent: 0, dataSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0 };
|
|
695
|
+
|
|
696
|
+
function enqueueData(text) {
|
|
688
697
|
if (text.length <= chunkSize) {
|
|
689
|
-
|
|
698
|
+
dataQ.push({ frames: [text], i: 0 });
|
|
690
699
|
return;
|
|
691
700
|
}
|
|
692
701
|
const cid = `c${++chunkSeq}`;
|
|
@@ -696,7 +705,7 @@ export function makePrioritySender({
|
|
|
696
705
|
frames[i] = JSON.stringify({ __chunk: { id: cid, n, i, data: text.slice(i * chunkSize, (i + 1) * chunkSize) } });
|
|
697
706
|
}
|
|
698
707
|
stats.bigFrames += 1;
|
|
699
|
-
|
|
708
|
+
dataQ.push({ frames, i: 0 });
|
|
700
709
|
}
|
|
701
710
|
|
|
702
711
|
function scheduleNext(waitMs) {
|
|
@@ -716,7 +725,7 @@ export function makePrioritySender({
|
|
|
716
725
|
if (pumping || closed) return;
|
|
717
726
|
pumping = true;
|
|
718
727
|
let yieldMode = ""; // ""=没有剩余,"schedule"=让出事件循环,"poll"=等缓冲排空
|
|
719
|
-
let budget =
|
|
728
|
+
let budget = 64; // 一轮最多发多少条未分片小帧(大帧每轮只发一片)
|
|
720
729
|
try {
|
|
721
730
|
for (;;) {
|
|
722
731
|
// ① 控制车道永远先走:哪怕大帧正传着 16MB,控制帧也在**下一轮**第一时间出去。
|
|
@@ -725,31 +734,31 @@ export function makePrioritySender({
|
|
|
725
734
|
try { rawSend(frame); stats.controlSent += 1; } catch { closed = true; }
|
|
726
735
|
}
|
|
727
736
|
if (closed) { yieldMode = ""; break; }
|
|
728
|
-
|
|
729
|
-
if (
|
|
730
|
-
|
|
737
|
+
if (dataQ.length === 0) { yieldMode = ""; break; }
|
|
738
|
+
if (budget <= 0) { yieldMode = "schedule"; break; }
|
|
739
|
+
const head = dataQ[0];
|
|
740
|
+
if (head.frames.length === 1) {
|
|
741
|
+
// 未分片小帧:可连着多发几条,但绝不越过队头(保证 FIFO)
|
|
742
|
+
try { rawSend(head.frames[0]); stats.dataSent += 1; } catch { closed = true; break; }
|
|
743
|
+
dataQ.shift();
|
|
731
744
|
budget -= 1;
|
|
732
745
|
continue; // 回环顶先查控制车道
|
|
733
746
|
}
|
|
734
|
-
|
|
735
|
-
if (bigQ.length === 0) { yieldMode = ""; break; }
|
|
736
|
-
// ③ 背压:已入队字节超过高水位 → 停手等排空,别把控制帧埋进几十秒的队列里。
|
|
747
|
+
// 队头是大帧:每轮只推一片,片间让出事件循环(控制帧/入站帧不被饿死),顺序不变
|
|
737
748
|
let pending = 0;
|
|
738
749
|
try { pending = Number(bufferedAmount()) || 0; } catch { pending = 0; }
|
|
739
750
|
if (pending > bulkHighWater) { stats.waits += 1; yieldMode = "poll"; break; }
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
yieldMode = "schedule"; // 大帧:一片一让,给入站帧 / 控制帧 / 小帧机会
|
|
751
|
+
try { rawSend(head.frames[head.i]); stats.dataSent += 1; stats.bigChunks += 1; } catch { closed = true; break; }
|
|
752
|
+
head.i += 1;
|
|
753
|
+
if (head.i >= head.frames.length) dataQ.shift();
|
|
754
|
+
yieldMode = "schedule";
|
|
745
755
|
break;
|
|
746
756
|
}
|
|
747
757
|
} finally {
|
|
748
758
|
pumping = false;
|
|
749
759
|
if (closed) {
|
|
750
760
|
controlQ.length = 0;
|
|
751
|
-
|
|
752
|
-
bigQ.length = 0;
|
|
761
|
+
dataQ.length = 0;
|
|
753
762
|
} else if (yieldMode) {
|
|
754
763
|
scheduleNext(yieldMode === "poll" ? pollMs : 0);
|
|
755
764
|
}
|
|
@@ -761,7 +770,7 @@ export function makePrioritySender({
|
|
|
761
770
|
try { text = JSON.stringify(obj); } catch { return false; }
|
|
762
771
|
if (closed) { stats.dropped += 1; return false; }
|
|
763
772
|
if (isControlFrame(obj)) controlQ.push(text);
|
|
764
|
-
else
|
|
773
|
+
else enqueueData(text);
|
|
765
774
|
pump();
|
|
766
775
|
return true;
|
|
767
776
|
}
|
|
@@ -770,8 +779,7 @@ export function makePrioritySender({
|
|
|
770
779
|
function close() {
|
|
771
780
|
closed = true;
|
|
772
781
|
controlQ.length = 0;
|
|
773
|
-
|
|
774
|
-
bigQ.length = 0;
|
|
782
|
+
dataQ.length = 0;
|
|
775
783
|
}
|
|
776
784
|
|
|
777
785
|
return {
|
|
@@ -779,11 +787,11 @@ export function makePrioritySender({
|
|
|
779
787
|
close,
|
|
780
788
|
stats: () => ({
|
|
781
789
|
...stats,
|
|
782
|
-
queued:
|
|
790
|
+
queued: dataQ.length,
|
|
783
791
|
controlQueued: controlQ.length,
|
|
784
792
|
closed
|
|
785
793
|
}),
|
|
786
|
-
hasBacklog: () =>
|
|
794
|
+
hasBacklog: () => dataQ.length > 0 || controlQ.length > 0
|
|
787
795
|
};
|
|
788
796
|
}
|
|
789
797
|
|
|
@@ -151,7 +151,7 @@ test("契约②:发送缓冲高水位以上时停手(控制帧不会被埋
|
|
|
151
151
|
assert.equal(pos, beforeControl, "排空后第一时间发控制帧(之前的背压不能把它排到分块后面)");
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
-
test("
|
|
154
|
+
test("契约③:数据帧严格 FIFO(大小不得插队,2026-10-24 历史游标错乱事故回归);close() 丢弃未发内容", () => {
|
|
155
155
|
const sock = fakeSocket();
|
|
156
156
|
const sched = makeScheduler();
|
|
157
157
|
const sender = makePrioritySender({
|
|
@@ -162,15 +162,23 @@ test("契约③:大帧传输中小帧优先(交互不被整帧传输拖住
|
|
|
162
162
|
});
|
|
163
163
|
// 大帧刚发出第一片(之后每轮只发一片)
|
|
164
164
|
sender.send({ id: "big3", type: "http", body: "C".repeat(512 * 1024) });
|
|
165
|
-
// 传输中插入两条小帧(交互消息 / SSE
|
|
165
|
+
// 传输中插入两条小帧(交互消息 / SSE 块):**不得插队**,必须排在大帧之后(FIFO)
|
|
166
166
|
sender.send({ id: "i1", type: "ws-msg", data: "hello" });
|
|
167
167
|
sender.send({ id: "i2", type: "http-chunk", body: "chunk" });
|
|
168
168
|
const ids = sock.frames.map((s) => {
|
|
169
169
|
const o = JSON.parse(s);
|
|
170
170
|
return o.__chunk ? "__chunk" : o.id;
|
|
171
171
|
});
|
|
172
|
-
assert.
|
|
173
|
-
assert.
|
|
172
|
+
assert.equal(ids[0], "__chunk", "大帧第一片先发");
|
|
173
|
+
assert.ok(!ids.slice(0, 3).includes("i1"), "后入队的小帧绝不能插队到大帧分片之前(顺序语义)");
|
|
174
|
+
for (let i = 0; i < 5000 && sched.pending() > 0; i += 1) { sock.drain(10 * 1024 * 1024); sched.tick(); }
|
|
175
|
+
const orderedIds = sock.frames.map((s) => {
|
|
176
|
+
const o = JSON.parse(s);
|
|
177
|
+
return o.__chunk ? "__chunk" : o.id;
|
|
178
|
+
});
|
|
179
|
+
const firstNonChunk = orderedIds.findIndex((id) => id !== "__chunk");
|
|
180
|
+
assert.ok(firstNonChunk >= 8, `大帧的分片必须全部先发完(首个非分片位置 ${firstNonChunk})`);
|
|
181
|
+
assert.deepEqual(orderedIds.slice(firstNonChunk), ["i1", "i2"], "小帧必须在大帧之后、且保持入队顺序");
|
|
174
182
|
|
|
175
183
|
// close 后:未发内容丢弃,后续 send 返回 false(重连后不能把旧分片发到新连接上)
|
|
176
184
|
const sock2 = fakeSocket();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.7.6-beta.
|
|
3
|
+
"version": "0.7.6-beta.4",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.7.6-beta.
|
|
3
|
+
"version": "0.7.6-beta.4",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -631,7 +631,10 @@ export function dcSend(dc, obj) {
|
|
|
631
631
|
* · 每轮发送前先排空控制车道,并检查 `ws.bufferedAmount`:超过高水位就停手等它排空。
|
|
632
632
|
* 于是控制帧前面最多只有「高水位 + 一片」的待发字节 —— 默认 64KB + 64KB,手机 1Mbps
|
|
633
633
|
* 下 ≈1s,8Mbps 下 ≈0.13s,远小于 router 的 20s,也满足「大响应传输中 1s 内应答」目标。
|
|
634
|
-
* ·
|
|
634
|
+
* · **数据帧严格 FIFO**(2026-10-24 修):早期实现让小帧(交互/SSE 块)插到大帧下一片之前,
|
|
635
|
+
* 这在同一 WS 流里是**乱序** —— 生产表现为手机远程页「历史加载失败:session event stream
|
|
636
|
+
* emitted an entry before its opening cursor」。现在只有一个数据队列,大帧按片就地推进,
|
|
637
|
+
* 绝不因大小/类型被后来的帧超越;只有控制帧(不承载有序数据)保留插队权。
|
|
635
638
|
* · 阈值 / 分片大小可用环境变量调(见 tunnelBulkOptionsFromEnv),弱网部署可再调低。
|
|
636
639
|
*/
|
|
637
640
|
export const TUNNEL_BULK_HWM_DEFAULT = 64 * 1024; // 发送缓冲高水位(字节)
|
|
@@ -680,13 +683,19 @@ export function makePrioritySender({
|
|
|
680
683
|
let pumping = false;
|
|
681
684
|
let scheduled = false;
|
|
682
685
|
let timerPending = false;
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
686
|
+
// 🔴 2026-10-24 事故修复:数据帧**必须严格 FIFO**。
|
|
687
|
+
// 旧实现分「小帧车道 / 大帧车道」,小帧会插到大帧下一片之前 —— 这违反了同一 WS 流里
|
|
688
|
+
// 消息的顺序语义,生产表现为手机远程页「历史加载失败:session event stream emitted an
|
|
689
|
+
// entry before its opening cursor(gateway/internal)」:事件流请求/响应被乱序送达,
|
|
690
|
+
// 客户端的游标推进到了前面,重连时服务端按该游标回放就触发 cursor 错乱。
|
|
691
|
+
// 现在:控制帧仍可插队(它们是协议控制帧,不承载有序数据);**数据帧只有一个队列**,
|
|
692
|
+
// 大帧按片就地推进,绝不因大小/类型被后来的帧超越。
|
|
693
|
+
const dataQ = []; // { frames: string[], i: number };frames.length===1 = 未分片小帧
|
|
694
|
+
const stats = { controlSent: 0, dataSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0 };
|
|
695
|
+
|
|
696
|
+
function enqueueData(text) {
|
|
688
697
|
if (text.length <= chunkSize) {
|
|
689
|
-
|
|
698
|
+
dataQ.push({ frames: [text], i: 0 });
|
|
690
699
|
return;
|
|
691
700
|
}
|
|
692
701
|
const cid = `c${++chunkSeq}`;
|
|
@@ -696,7 +705,7 @@ export function makePrioritySender({
|
|
|
696
705
|
frames[i] = JSON.stringify({ __chunk: { id: cid, n, i, data: text.slice(i * chunkSize, (i + 1) * chunkSize) } });
|
|
697
706
|
}
|
|
698
707
|
stats.bigFrames += 1;
|
|
699
|
-
|
|
708
|
+
dataQ.push({ frames, i: 0 });
|
|
700
709
|
}
|
|
701
710
|
|
|
702
711
|
function scheduleNext(waitMs) {
|
|
@@ -716,7 +725,7 @@ export function makePrioritySender({
|
|
|
716
725
|
if (pumping || closed) return;
|
|
717
726
|
pumping = true;
|
|
718
727
|
let yieldMode = ""; // ""=没有剩余,"schedule"=让出事件循环,"poll"=等缓冲排空
|
|
719
|
-
let budget =
|
|
728
|
+
let budget = 64; // 一轮最多发多少条未分片小帧(大帧每轮只发一片)
|
|
720
729
|
try {
|
|
721
730
|
for (;;) {
|
|
722
731
|
// ① 控制车道永远先走:哪怕大帧正传着 16MB,控制帧也在**下一轮**第一时间出去。
|
|
@@ -725,31 +734,31 @@ export function makePrioritySender({
|
|
|
725
734
|
try { rawSend(frame); stats.controlSent += 1; } catch { closed = true; }
|
|
726
735
|
}
|
|
727
736
|
if (closed) { yieldMode = ""; break; }
|
|
728
|
-
|
|
729
|
-
if (
|
|
730
|
-
|
|
737
|
+
if (dataQ.length === 0) { yieldMode = ""; break; }
|
|
738
|
+
if (budget <= 0) { yieldMode = "schedule"; break; }
|
|
739
|
+
const head = dataQ[0];
|
|
740
|
+
if (head.frames.length === 1) {
|
|
741
|
+
// 未分片小帧:可连着多发几条,但绝不越过队头(保证 FIFO)
|
|
742
|
+
try { rawSend(head.frames[0]); stats.dataSent += 1; } catch { closed = true; break; }
|
|
743
|
+
dataQ.shift();
|
|
731
744
|
budget -= 1;
|
|
732
745
|
continue; // 回环顶先查控制车道
|
|
733
746
|
}
|
|
734
|
-
|
|
735
|
-
if (bigQ.length === 0) { yieldMode = ""; break; }
|
|
736
|
-
// ③ 背压:已入队字节超过高水位 → 停手等排空,别把控制帧埋进几十秒的队列里。
|
|
747
|
+
// 队头是大帧:每轮只推一片,片间让出事件循环(控制帧/入站帧不被饿死),顺序不变
|
|
737
748
|
let pending = 0;
|
|
738
749
|
try { pending = Number(bufferedAmount()) || 0; } catch { pending = 0; }
|
|
739
750
|
if (pending > bulkHighWater) { stats.waits += 1; yieldMode = "poll"; break; }
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
yieldMode = "schedule"; // 大帧:一片一让,给入站帧 / 控制帧 / 小帧机会
|
|
751
|
+
try { rawSend(head.frames[head.i]); stats.dataSent += 1; stats.bigChunks += 1; } catch { closed = true; break; }
|
|
752
|
+
head.i += 1;
|
|
753
|
+
if (head.i >= head.frames.length) dataQ.shift();
|
|
754
|
+
yieldMode = "schedule";
|
|
745
755
|
break;
|
|
746
756
|
}
|
|
747
757
|
} finally {
|
|
748
758
|
pumping = false;
|
|
749
759
|
if (closed) {
|
|
750
760
|
controlQ.length = 0;
|
|
751
|
-
|
|
752
|
-
bigQ.length = 0;
|
|
761
|
+
dataQ.length = 0;
|
|
753
762
|
} else if (yieldMode) {
|
|
754
763
|
scheduleNext(yieldMode === "poll" ? pollMs : 0);
|
|
755
764
|
}
|
|
@@ -761,7 +770,7 @@ export function makePrioritySender({
|
|
|
761
770
|
try { text = JSON.stringify(obj); } catch { return false; }
|
|
762
771
|
if (closed) { stats.dropped += 1; return false; }
|
|
763
772
|
if (isControlFrame(obj)) controlQ.push(text);
|
|
764
|
-
else
|
|
773
|
+
else enqueueData(text);
|
|
765
774
|
pump();
|
|
766
775
|
return true;
|
|
767
776
|
}
|
|
@@ -770,8 +779,7 @@ export function makePrioritySender({
|
|
|
770
779
|
function close() {
|
|
771
780
|
closed = true;
|
|
772
781
|
controlQ.length = 0;
|
|
773
|
-
|
|
774
|
-
bigQ.length = 0;
|
|
782
|
+
dataQ.length = 0;
|
|
775
783
|
}
|
|
776
784
|
|
|
777
785
|
return {
|
|
@@ -779,11 +787,11 @@ export function makePrioritySender({
|
|
|
779
787
|
close,
|
|
780
788
|
stats: () => ({
|
|
781
789
|
...stats,
|
|
782
|
-
queued:
|
|
790
|
+
queued: dataQ.length,
|
|
783
791
|
controlQueued: controlQ.length,
|
|
784
792
|
closed
|
|
785
793
|
}),
|
|
786
|
-
hasBacklog: () =>
|
|
794
|
+
hasBacklog: () => dataQ.length > 0 || controlQ.length > 0
|
|
787
795
|
};
|
|
788
796
|
}
|
|
789
797
|
|