difftab 0.1.0 → 0.1.2
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 +35 -118
- package/README.zh-CN.md +22 -100
- package/bin/difftab.js +2 -2
- package/dist/server/main.js +120 -102
- package/dist/web/app.css +1 -1
- package/dist/web/app.js +15 -15
- package/package.json +1 -1
package/dist/server/main.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { closeSync, mkdirSync, openSync, readFileSync, realpathSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
|
-
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { createServer, request } from "node:http";
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -53,7 +53,7 @@ function parseCliArgs(argv) {
|
|
|
53
53
|
//#endregion
|
|
54
54
|
//#region src/server/git/run.ts
|
|
55
55
|
/**
|
|
56
|
-
* 所有 git
|
|
56
|
+
* 所有 git 调用统一注入,不留给各调用点自己记得加。
|
|
57
57
|
*
|
|
58
58
|
* `-z` 只作用于 status / numstat 这类**列表输出**,管不到 `git diff` 的补丁正文 ——
|
|
59
59
|
* 正文里的 `diff --git` / `---` / `+++` / `rename from|to` 头部行仍会按 C 风格转义
|
|
@@ -67,11 +67,11 @@ const GLOBAL_CONFIG = ["-c", "core.quotePath=false"];
|
|
|
67
67
|
*
|
|
68
68
|
* `GIT_OPTIONAL_LOCKS=0` 是只读承诺的一部分而不是性能开关:默认情况下 `git status`
|
|
69
69
|
* 会顺手把刷新过的 stat 缓存写回 `.git/index`。那不改变 status 的输出,因此
|
|
70
|
-
* 「前后 `git status` 比对」这类验证发现不了它(
|
|
70
|
+
* 「前后 `git status` 比对」这类验证发现不了它(排除该做法的原因之一)。
|
|
71
71
|
*
|
|
72
|
-
* **把这一行删掉,只读 `.git` 那半层门禁照样全绿**(
|
|
72
|
+
* **把这一行删掉,只读 `.git` 那半层门禁照样全绿**(已实测):git 把 index
|
|
73
73
|
* 回写当 best-effort,`.git` 不可写时它静默跳过、exit 0、stderr 全空。真正看得见
|
|
74
|
-
*
|
|
74
|
+
* 的是第二层的 **B 半**——在可写的 `.git` 上前后做逐字节快照比对
|
|
75
75
|
* (`test/smoke/readonly-git-dir.test.js`)。别把 B 半当成 A 半的重复给删了。
|
|
76
76
|
*
|
|
77
77
|
* 该变量在 git < 2.15 上不存在,设了也无害 —— 那个区间的 git 只是照旧写 index。
|
|
@@ -80,7 +80,7 @@ const GLOBAL_CONFIG = ["-c", "core.quotePath=false"];
|
|
|
80
80
|
*
|
|
81
81
|
* **`GIT_LITERAL_PATHSPECS=1` 是安全项而不是洁癖**:`--` 后面的路径默认是 **wildmatch
|
|
82
82
|
* 模式**,不是字面路径。我们的路径全部来自 URL query,于是 `path=*` 会让
|
|
83
|
-
* `git diff HEAD -- '*'` 回一份**整仓 diff**(已实测)
|
|
83
|
+
* `git diff HEAD -- '*'` 回一份**整仓 diff**(已实测)——正是红线明令禁止、
|
|
84
84
|
* 会把浏览器主线程冻上数十秒的那件事;而一个真实存在、名字里带 `*` 的文件同样会
|
|
85
85
|
* 匹配到别人身上,页面在 A 的标题下显示 B 的补丁。设了它,`*` / `?` / `[…]` 一律
|
|
86
86
|
* 按字面比较。本项目的路径无一例外来自 git 自己的输出,不需要任何通配语义。
|
|
@@ -94,7 +94,7 @@ const GIT_ENV = {
|
|
|
94
94
|
* stdout 的**兜底**上限。没有它,一个几百 MB 的文件的 diff 会被整个读进内存,
|
|
95
95
|
* 而这条路径上没有任何东西会先失败。
|
|
96
96
|
*
|
|
97
|
-
* 产品语义那道 5MB 闸不在这里,而是由调用方按次传 `maxStdoutBytes`(
|
|
97
|
+
* 产品语义那道 5MB 闸不在这里,而是由调用方按次传 `maxStdoutBytes`(
|
|
98
98
|
* 「卡补丁字节数」——见 git/diff.ts):**能不能渲染取决于补丁多大,而不是文件多大**,
|
|
99
99
|
* 一个 6MB 的数据文件改一行照样该看得见。本常量只管「别把进程撑爆」。
|
|
100
100
|
*/
|
|
@@ -197,14 +197,14 @@ async function runGitStrict(args, cwd) {
|
|
|
197
197
|
|
|
198
198
|
//#endregion
|
|
199
199
|
//#region src/server/git/repo.ts
|
|
200
|
-
/** `--porcelain=v2`
|
|
200
|
+
/** `--porcelain=v2` 的最低要求。 */
|
|
201
201
|
const MIN_GIT = {
|
|
202
202
|
major: 2,
|
|
203
203
|
minor: 11
|
|
204
204
|
};
|
|
205
205
|
/**
|
|
206
206
|
* 空树对象哈希。空仓库下 HEAD 不存在、`git diff HEAD` 直接 fatal,改用它作 diff 基准
|
|
207
|
-
*
|
|
207
|
+
* 即可,不必为空仓库写一条特殊分支。
|
|
208
208
|
*
|
|
209
209
|
* 硬编码是**要求**而不是偷懒:`git hash-object -t tree /dev/null` 依赖 `/dev/null`,
|
|
210
210
|
* Windows 上不可移植;`git mktree` 会写对象库,直接违反只读承诺。
|
|
@@ -213,7 +213,7 @@ const EMPTY_TREE = {
|
|
|
213
213
|
sha1: "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
|
|
214
214
|
sha256: "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"
|
|
215
215
|
};
|
|
216
|
-
/** 启动前置检查失败。CLI 据此打印一句话友好报错,而不是抛 Node
|
|
216
|
+
/** 启动前置检查失败。CLI 据此打印一句话友好报错,而不是抛 Node 异常栈。 */
|
|
217
217
|
var PreflightError = class extends Error {
|
|
218
218
|
code;
|
|
219
219
|
constructor(code, message) {
|
|
@@ -237,7 +237,7 @@ function tooOld(v) {
|
|
|
237
237
|
/**
|
|
238
238
|
* 前置检查 + 定位,一次做完。
|
|
239
239
|
*
|
|
240
|
-
* 两条命令并发跑:串行等于把两次进程启动开销叠加进
|
|
240
|
+
* 两条命令并发跑:串行等于把两次进程启动开销叠加进 300ms 冷启动预算,
|
|
241
241
|
* 而它们之间没有依赖。失败路径上的额外一次 `rev-parse` 不计成本 —— 那条路径的
|
|
242
242
|
* 终点是打印一行报错然后退出。
|
|
243
243
|
*/
|
|
@@ -267,10 +267,27 @@ async function locateRepo(cwd) {
|
|
|
267
267
|
};
|
|
268
268
|
}
|
|
269
269
|
/**
|
|
270
|
+
* 工作区根目录名 —— 页面标题里的项目标识(`RepoState.repoName`)。
|
|
271
|
+
*
|
|
272
|
+
* **和 `root` 住在一起,不在消费者那边现切**:下面那条「用 `node:path` 不手写切分」
|
|
273
|
+
* 的理由整个建立在 `root` 是怎么来的之上(`rev-parse --show-toplevel`),而那件事只有
|
|
274
|
+
* 本文件知道。放在 `http/` 里的话,这里改一次 `root` 的产出方式,那边的论证就静默过期。
|
|
275
|
+
*
|
|
276
|
+
* **用 `node:path` 的 `basename`,不要自己切 `/`**:Windows 上 `--show-toplevel` 回的是
|
|
277
|
+
* `C:/…` 正斜杠,而 win32 的 basename 两种分隔符都认;POSIX 上 `\` 是合法文件名字符,
|
|
278
|
+
* posix 的 basename 不会误把它当分隔符。两个平台各有一半是手写切分挑不对的。
|
|
279
|
+
*
|
|
280
|
+
* 根目录没有 basename 时(`/`、Windows 盘符根)回空串,**不编一个名字出来** ——
|
|
281
|
+
* 「取不到时显示什么」是展示决定,归消费者(见 `shared/protocol.ts`)。
|
|
282
|
+
*/
|
|
283
|
+
function repoNameOf(repo) {
|
|
284
|
+
return basename(repo.root);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
270
287
|
* diff 的基准。
|
|
271
288
|
*
|
|
272
289
|
* 正常仓库是 `HEAD`;空仓库(尚无任何提交)下 HEAD 不存在,`git diff HEAD` 会 fatal,
|
|
273
|
-
*
|
|
290
|
+
* 降级为空树哈希。
|
|
274
291
|
*
|
|
275
292
|
* 不做缓存:`git checkout --orphan` 之后 HEAD 会重新变回未出生状态,缓存的正结果
|
|
276
293
|
* 会让 diff 从此全部 fatal。这条只在取 diff 时调用,不落在冷启动路径上。
|
|
@@ -290,7 +307,7 @@ async function resolveDiffBase(root) {
|
|
|
290
307
|
//#endregion
|
|
291
308
|
//#region src/server/git/diff.ts
|
|
292
309
|
/**
|
|
293
|
-
*
|
|
310
|
+
* 超过这个字节数只提示、不预览。
|
|
294
311
|
*
|
|
295
312
|
* **两条路量的东西不同,但量的都是「前端要吃下多少字节」**:未跟踪那一侧整份文件
|
|
296
313
|
* 就是补丁,所以量文件(还省得把它读进来);已跟踪那一侧补丁只含改动与上下文,
|
|
@@ -299,7 +316,7 @@ async function resolveDiffBase(root) {
|
|
|
299
316
|
const MAX_BYTES = 5242880;
|
|
300
317
|
/**
|
|
301
318
|
* 行数上限。体积阈值挡不住另一头:超长行数的窄文件体积不大,但逐行构造 diff 与
|
|
302
|
-
*
|
|
319
|
+
* 前端渲染同样会卡。
|
|
303
320
|
*
|
|
304
321
|
* 已跟踪那一侧数的是**改动行数**(numstat 的加 + 减),未跟踪数的是文件行数 ——
|
|
305
322
|
* 后者本来就整份都是新增行。两边量的都是「前端要渲染多少行」。
|
|
@@ -342,7 +359,7 @@ async function inIndex(root, path) {
|
|
|
342
359
|
/**
|
|
343
360
|
* `git diff --numstat -z` 的解析。**重命名记录不是一段而是三段。**
|
|
344
361
|
*
|
|
345
|
-
*
|
|
362
|
+
* 实测:普通记录是 `<加>\t<减>\t<路径>`,一条占一个 NUL 段;
|
|
346
363
|
* 重命名记录的**路径字段是空的**,后面紧跟两段 `<旧路径>` `<新路径>` ——
|
|
347
364
|
* 与 `status --porcelain=v2` 的 `2 ` 记录**顺序相反**(那边是新在前、旧在后)。
|
|
348
365
|
* 无状态地按 NUL 平铺切分,就会把 `<旧路径>` 当成下一条记录的开头。
|
|
@@ -382,18 +399,18 @@ function parseNumstat(output) {
|
|
|
382
399
|
* `ls-files` 输出为空),status 却照样报 `1 D.`、基准侧也还在。只认 index 会把
|
|
383
400
|
* 它误判成未跟踪、掉进读磁盘那条路,以「文件不存在」告终;
|
|
384
401
|
* 2. **是不是二进制**。已跟踪那一侧一律以 git 自己的判定为准(含 `.gitattributes`),
|
|
385
|
-
* 比 NUL
|
|
402
|
+
* 比 NUL 探测准确,形态是 `-\t-\t<路径>`;
|
|
386
403
|
* 3. **改了多少行**。行数阈值那一路的判据。
|
|
387
404
|
*
|
|
388
405
|
* 重命名同样要传两个路径 + `-M`:只传新路径时 git 无法配对,numstat 会把它算成
|
|
389
|
-
* 一个全新增文件(行数因此是整份文件)
|
|
406
|
+
* 一个全新增文件(行数因此是整份文件),与补丁那一侧的退化是同一回事。
|
|
390
407
|
*/
|
|
391
408
|
async function readNumstat(root, base, path, oldPath) {
|
|
392
409
|
/**
|
|
393
410
|
* **一次查询可以回不止一条记录**,所以必须按路径挑,不能取 `[0]`:
|
|
394
411
|
*
|
|
395
412
|
* 传了两个路径而 git **配不上对**时(`git mv` 之后把内容全部重写、留在工作区不
|
|
396
|
-
* `git add` —— status 照报 `R100`,所以 `oldPath`
|
|
413
|
+
* `git add` —— status 照报 `R100`,所以 `oldPath` 是有的),它会拆成
|
|
397
414
|
* 「删掉旧的」+「新增新的」两条,按路径排序。取 `[0]` 就是掷硬币:实测那条
|
|
398
415
|
* 60,000 行的改名里,排在前面的是旧文件那条 100 行的删除,于是行数闸放它过去,
|
|
399
416
|
* 一份 6 万行的补丁照旧发给浏览器 —— 正是本阶段要防的那次冻结。二进制同理,
|
|
@@ -433,7 +450,7 @@ async function worktreeSize(abs) {
|
|
|
433
450
|
}
|
|
434
451
|
}
|
|
435
452
|
/**
|
|
436
|
-
*
|
|
453
|
+
* 已跟踪文件的补丁,以及三道拒绝。
|
|
437
454
|
*
|
|
438
455
|
* **已跟踪那一侧卡的是「补丁多大」,不是「文件多大」**:文件体积只对未跟踪那条路
|
|
439
456
|
* 成立(整份文件就是补丁),而已跟踪文件的补丁只包含改动与上下文 —— 按文件体积拒绝,
|
|
@@ -447,10 +464,10 @@ async function worktreeSize(abs) {
|
|
|
447
464
|
*
|
|
448
465
|
* `size` 只用于**说话**(告诉用户这文件多大),不再参与判定,所以**只在要拒绝的那两条
|
|
449
466
|
* 分支上才去 `lstat`**:正常那条路(绝大多数请求)一次系统调用都不欠。文件已被删除时
|
|
450
|
-
* 取不到,给 0
|
|
467
|
+
* 取不到,给 0,前端据此不显示体积。
|
|
451
468
|
*
|
|
452
469
|
* **重命名条目必须同时传新旧两个路径**:只传新路径时 git 看不到另一侧、无法配对,
|
|
453
|
-
* 会把重命名退化成一个全新增文件(
|
|
470
|
+
* 会把重命名退化成一个全新增文件(已实测),「重命名识别并标注」随之落空。
|
|
454
471
|
* 两个路径都来自 status 的 `2 ` 记录,无需额外查询。
|
|
455
472
|
*/
|
|
456
473
|
async function trackedDiff(root, base, path, abs, oldPath, stat) {
|
|
@@ -502,8 +519,7 @@ function newFileHeader(path, mode) {
|
|
|
502
519
|
/**
|
|
503
520
|
* 未跟踪文件的补丁:**直接读文件内容手工构造 unified diff**。
|
|
504
521
|
*
|
|
505
|
-
* 不用 `git diff --no-index` —— 它依赖 `/dev/null` 作为对比端,Windows
|
|
506
|
-
* (spec §5.2 / §10)。
|
|
522
|
+
* 不用 `git diff --no-index` —— 它依赖 `/dev/null` 作为对比端,Windows 上不可移植。
|
|
507
523
|
*/
|
|
508
524
|
async function untrackedDiff(root, path) {
|
|
509
525
|
const abs = resolveInRepo(root, path);
|
|
@@ -570,12 +586,12 @@ async function readDiff(root, query) {
|
|
|
570
586
|
//#endregion
|
|
571
587
|
//#region src/server/git/operation.ts
|
|
572
588
|
/**
|
|
573
|
-
*
|
|
589
|
+
* 判据表,**按序取第一个命中**。
|
|
574
590
|
*
|
|
575
591
|
* 两处顺序不是随手排的:
|
|
576
592
|
*
|
|
577
593
|
* - **rebase 排在 merge 之前**。rebase 冲突停下时 git 目录里同时躺着 `rebase-merge/`
|
|
578
|
-
* 与 `MERGE_MSG` / `AUTO_MERGE`(
|
|
594
|
+
* 与 `MERGE_MSG` / `AUTO_MERGE`(已实测),而用户处在的是 rebase —— 反过来
|
|
579
595
|
* 排会把它标成「合并中」,页面上看不出任何异样,只是说错了用户在干什么。
|
|
580
596
|
* `MERGE_HEAD` 本身在那种情形下不出现,所以这一条更像是给后来者的防线
|
|
581
597
|
* - **`rebase-apply/rebasing` 排在 `rebase-apply` 之前**。`git am` 与 `git rebase --apply`
|
|
@@ -620,9 +636,9 @@ function exists(path) {
|
|
|
620
636
|
* 当前进行中的多步操作;没有则 `undefined`。
|
|
621
637
|
*
|
|
622
638
|
* **传进来的必须是 `git rev-parse --git-dir` 的返回值,不能自己拼 `<root>/.git`**
|
|
623
|
-
|
|
639
|
+
*:linked worktree 下 `.git` 是个文件、真正的 git 目录在
|
|
624
640
|
* `<主仓库>/.git/worktrees/<名>`,submodule 在 `<父仓库>/.git/modules/<路径>`
|
|
625
|
-
* (
|
|
641
|
+
* (均已实测)。拼出来的路径在那两种仓库里永远读不到 —— 于是永远标不出操作,
|
|
626
642
|
* 而它不报错。`RepoInfo.gitDir` 已经是这个值,调用方照传即可。
|
|
627
643
|
*
|
|
628
644
|
* 七个探针**并发发**再按上表定序:顺序判据只有一处(上面那张表),而不是散落在
|
|
@@ -643,7 +659,7 @@ async function readOperation(gitDir) {
|
|
|
643
659
|
* 导出成常量是为了让 S3b 的降级轮询**复用逐字相同的这一条**,而不是各写各的:
|
|
644
660
|
* 漏掉 `-uall` 的后果是静默的 —— git 会把未跟踪目录折叠成一行 `dir/`,于是在一个
|
|
645
661
|
* 已存在的未跟踪目录里新增文件根本不改变输出,轮询判定「无变化」、页面不刷新,
|
|
646
|
-
* 而那正是 agent
|
|
662
|
+
* 而那正是 agent 边跑边生成文件时最常见的形态。
|
|
647
663
|
*
|
|
648
664
|
* 两个参数都不能省:`-uall` 见上;`-z` 是因为不加它 git 会对含非 ASCII 字符、空格、
|
|
649
665
|
* 引号的路径做 C 风格转义并加引号(已实测)。
|
|
@@ -786,10 +802,10 @@ function parseStatus(raw) {
|
|
|
786
802
|
/**
|
|
787
803
|
* 主查询的**原始输出**,不解析。
|
|
788
804
|
*
|
|
789
|
-
*
|
|
805
|
+
* 存在的唯一理由是降级轮询:它只需要回答「变没变」,而逐字节比对原始输出
|
|
790
806
|
* 既是最省的判据,也让「轮询与主查询是同一条命令」成为**构造上的事实**而不是
|
|
791
807
|
* 两处各自维护的巧合 —— 后者漏个 `-uall` 不报错,只是在一个已存在的未跟踪目录里
|
|
792
|
-
* 新增文件时页面不刷新(
|
|
808
|
+
* 新增文件时页面不刷新(红线)。
|
|
793
809
|
*
|
|
794
810
|
* 比对解析后的结构也做得到,但那要么写一份深比较、要么 `JSON.stringify` 一遍,
|
|
795
811
|
* 而两者都会随协议类型增删字段而静默改变灵敏度。
|
|
@@ -801,7 +817,7 @@ async function readStatusRaw(root) {
|
|
|
801
817
|
* 变更列表 + 分支状态。
|
|
802
818
|
*
|
|
803
819
|
* 取 `RepoInfo` 而不是两个字符串:进行中的多步操作只能从 **git 目录**下的状态文件读
|
|
804
|
-
|
|
820
|
+
*,而它和工作区根是两条不同的路径 —— linked worktree 与 submodule 下差得很远。
|
|
805
821
|
* 两个同类型参数并排放着,调换顺序不会报错,只会让 `operation` 从此恒为空。
|
|
806
822
|
*
|
|
807
823
|
* 两件事并发:`git status` 是一次子进程,`readOperation` 是七个 `access`,互不依赖。
|
|
@@ -815,14 +831,14 @@ async function readStatus(repo) {
|
|
|
815
831
|
|
|
816
832
|
//#endregion
|
|
817
833
|
//#region src/server/watch/tier.ts
|
|
818
|
-
/** `fs.watch` 的 `ignore`
|
|
834
|
+
/** `fs.watch` 的 `ignore` 选项自此版本可用。 */
|
|
819
835
|
const IGNORE_SINCE = {
|
|
820
836
|
major: 24,
|
|
821
837
|
minor: 14,
|
|
822
838
|
patch: 0
|
|
823
839
|
};
|
|
824
840
|
/**
|
|
825
|
-
* 强制指定档位的**内部**环境变量(
|
|
841
|
+
* 强制指定档位的**内部**环境变量(S3b1 的首个交付物)。
|
|
826
842
|
*
|
|
827
843
|
* 没有它,S3b2 的六条档位验收项在单机上一条都无从自查 —— 一台机器只有一个 Node
|
|
828
844
|
* 版本、一个平台,而三档正是按这两者分的。它不是给用户的开关,README 不写它。
|
|
@@ -870,7 +886,7 @@ function supportsIgnoreOption(nodeVersion) {
|
|
|
870
886
|
return !v.prerelease;
|
|
871
887
|
}
|
|
872
888
|
/**
|
|
873
|
-
*
|
|
889
|
+
* 档位判定。
|
|
874
890
|
*
|
|
875
891
|
* - **A**:有 `ignore`,三端通用 —— Linux 上是注册前跳过,正是配额问题的官方解法
|
|
876
892
|
* - **B**:没有 `ignore`,但在 macOS / Windows 上走原生 FSEvents / `ReadDirectoryChangesW`,
|
|
@@ -899,7 +915,7 @@ function resolveTier(env = process.env, nodeVersion = process.versions.node, pla
|
|
|
899
915
|
return forcedTier(env) ?? detectTier(nodeVersion, platform);
|
|
900
916
|
}
|
|
901
917
|
/**
|
|
902
|
-
* 该档位下**工作区通路**的既定形态(
|
|
918
|
+
* 该档位下**工作区通路**的既定形态(`WatchState.mode`)。
|
|
903
919
|
*
|
|
904
920
|
* C 档一开始就以轮询为工作区通路,A / B 档则是原生监听。**它只是「还没起监听时
|
|
905
921
|
* 答什么」**:监听懒起(见 http/server.ts),起了之后真实取值由 `WatchHandle.mode`
|
|
@@ -911,7 +927,7 @@ function initialMode(tier) {
|
|
|
911
927
|
/**
|
|
912
928
|
* 强制指定 A 档、但这个 Node 根本没有 `ignore` 时的一句提醒(没有则返回 null)。
|
|
913
929
|
*
|
|
914
|
-
*
|
|
930
|
+
* **不是报错**:「三档均可通过内部环境变量强制指定」是已经勾掉的验收项,
|
|
915
931
|
* 在 Node 22 上拒绝启动会把它推翻,而 macOS / Windows 上强制 A 档去看别的行为
|
|
916
932
|
* 也是正当用法。但沉默同样不行 —— Node 对未知选项是**静默忽略**,于是这次
|
|
917
933
|
* 「A 档」跑的是一个**没有任何过滤的递归 watch**:在 Linux 上那正是耗尽
|
|
@@ -953,7 +969,7 @@ function caseFold(segment, platform) {
|
|
|
953
969
|
}
|
|
954
970
|
/**
|
|
955
971
|
* 造一个绑定平台的匹配器。**平台可注入是为了让三端的归一行为都能在一台机器上验**
|
|
956
|
-
* —— 与 `DIFFTAB_WATCH_TIER`
|
|
972
|
+
* —— 与 `DIFFTAB_WATCH_TIER` 同一个理由。
|
|
957
973
|
*
|
|
958
974
|
* 收到的是**相对被监听目录的路径**(A 档 Linux 侧是遍历到的条目,A 档 macOS/Windows
|
|
959
975
|
* 与 B 档是事件路径),不是绝对路径 —— 传绝对路径进来也能工作,只是仓库自身若躺在
|
|
@@ -968,7 +984,7 @@ const isIgnored = createIsIgnored();
|
|
|
968
984
|
//#endregion
|
|
969
985
|
//#region src/server/watch/watcher.ts
|
|
970
986
|
/**
|
|
971
|
-
* 事件合并窗口(
|
|
987
|
+
* 事件合并窗口(建议 100-200ms)。
|
|
972
988
|
*
|
|
973
989
|
* **在 Linux 上这是必需项而非优化项**:用户态递归实现初次遍历目录树时会对遍历到的
|
|
974
990
|
* 每个条目 emit 一次事件,启动瞬间即产生一波与实际变更无关的风暴。`.git` 侧同样需要:
|
|
@@ -976,17 +992,17 @@ const isIgnored = createIsIgnored();
|
|
|
976
992
|
* refs/heads/<分支>,五六个事件是常态。
|
|
977
993
|
*/
|
|
978
994
|
const DEBOUNCE_MS = 150;
|
|
979
|
-
/** 降级轮询的周期(
|
|
995
|
+
/** 降级轮询的周期(1.5s)。C 档的工作区通路也是它。 */
|
|
980
996
|
const POLL_MS = 1500;
|
|
981
997
|
/**
|
|
982
|
-
* **原生档(A / B)
|
|
998
|
+
* **原生档(A / B)的低频安全轮询周期**。
|
|
983
999
|
*
|
|
984
1000
|
* 它补的是一个没有任何信号的缺口:Linux 上 inotify 配额在**遍历途中**耗尽时,Node
|
|
985
|
-
* 一次都不 emit(2026-08-18
|
|
1001
|
+
* 一次都不 emit(2026-08-18 实测,推翻了此前的源码推断)—— 没轮上注册的那些
|
|
986
1002
|
* 目录里,改一个**启动前就存在**的文件从此静默丢失,`mode` 还一直说 `native`。
|
|
987
1003
|
* 原生监听少报了什么是没法从监听那一侧知道的,只有拿 status 输出本身去比才看得见。
|
|
988
1004
|
*
|
|
989
|
-
* **取 30s 而不是 1.5s
|
|
1005
|
+
* **取 30s 而不是 1.5s**:要求「原生监听模式下空闲 CPU 接近零」,一次 status 几十
|
|
990
1006
|
* 毫秒,30s 一拍的占空比是千分之几;代价是那个病态场景下最坏 30s 的滞后。
|
|
991
1007
|
*/
|
|
992
1008
|
const SAFETY_POLL_MS = 3e4;
|
|
@@ -995,7 +1011,7 @@ const SAFETY_POLL_MS = 3e4;
|
|
|
995
1011
|
*
|
|
996
1012
|
* **一律是目录,绝不是单个文件**:Linux / macOS 上 watch 绑的是 inode,而 git 写
|
|
997
1013
|
* `HEAD` / `index` 走的是「写临时文件 + 原子 rename」,新文件是新 inode,对文件建的
|
|
998
|
-
* watch
|
|
1014
|
+
* watch 从此静默失效。
|
|
999
1015
|
*
|
|
1000
1016
|
* **绝不递归、更绝不进 `objects`**:一次 gc 就是几万个条目,既是配额灾难,也会把
|
|
1001
1017
|
* 与展示无关的写入变成事件风暴。清单之所以短得像不够用,是因为主力其实是 `gitDir`
|
|
@@ -1023,7 +1039,7 @@ function gitWatchDirs(gitDir) {
|
|
|
1023
1039
|
* 起监听。
|
|
1024
1040
|
*
|
|
1025
1041
|
* 关于「我们自己会不会把自己触发起来」:不会,而且这件事全靠封装层那条
|
|
1026
|
-
* `GIT_OPTIONAL_LOCKS=0`(
|
|
1042
|
+
* `GIT_OPTIONAL_LOCKS=0`(红线)。不设它的话 `git status` 会把 stat 缓存写回
|
|
1027
1043
|
* `.git/index`,于是每次刷新都写一次 index、每次写 index 都触发一次刷新 —— 一个不报错、
|
|
1028
1044
|
* 只是 CPU 常年 1% 的自激循环,而 status 的输出从头到尾都是对的。轮询那条路上这
|
|
1029
1045
|
* 一点更要命:它每 1.5s 主动跑一次 status。
|
|
@@ -1133,7 +1149,7 @@ function createWatcher(options) {
|
|
|
1133
1149
|
if (initialMode(tier) === "polling") usePolling(null);
|
|
1134
1150
|
else
|
|
1135
1151
|
/**
|
|
1136
|
-
*
|
|
1152
|
+
* 原生档的低频安全轮询。**直接起循环而不经 `usePolling`**:那个闩一合上
|
|
1137
1153
|
* 就意味着「已降级」,而这里没有任何东西坏掉 —— `mode` 必须还是 `native`,
|
|
1138
1154
|
* 否则页面会把一次完全正常的运行标成降级。首拍照例只建立基线。
|
|
1139
1155
|
*/
|
|
@@ -1152,8 +1168,8 @@ function createWatcher(options) {
|
|
|
1152
1168
|
* **不传 `recursive`**(见 gitWatchDirs 的注释);`persistent: false` 是说
|
|
1153
1169
|
* 监听不吊住事件循环。
|
|
1154
1170
|
*
|
|
1155
|
-
* **filename 一概不看**。它可能为 null
|
|
1156
|
-
* 值得刷新一次:非递归就是真的非递归 —— macOS 已实测(2026-08-11
|
|
1171
|
+
* **filename 一概不看**。它可能为 null,而这几个目录里的任何写入都
|
|
1172
|
+
* 值得刷新一次:非递归就是真的非递归 —— macOS 已实测(2026-08-11),
|
|
1157
1173
|
* Linux 的 inotify 与 Windows 的 `bWatchSubtree=FALSE` 按机制如此 —— 于是
|
|
1158
1174
|
* `objects/` 的海量写入根本到不了这里,没有需要按名字排除的东西。曾按
|
|
1159
1175
|
* 「macOS 会漏过来」加过一个顶层段过滤,那是把**建流窗口的补报**(建流前一刻的
|
|
@@ -1177,7 +1193,7 @@ function createWatcher(options) {
|
|
|
1177
1193
|
}
|
|
1178
1194
|
}
|
|
1179
1195
|
/**
|
|
1180
|
-
*
|
|
1196
|
+
* 工作区侧,按档位分。
|
|
1181
1197
|
*
|
|
1182
1198
|
* **C 档一个递归 watch 都不建**:Node 在 Linux 上的递归实现是用户态遍历,对遍历到的
|
|
1183
1199
|
* 每个**普通文件**也注册一个 inotify watch,monorepo 下足以耗尽
|
|
@@ -1187,14 +1203,14 @@ function createWatcher(options) {
|
|
|
1187
1203
|
if (tier !== "C") try {
|
|
1188
1204
|
const handler = (_event, filename) => {
|
|
1189
1205
|
/**
|
|
1190
|
-
* **B 档的过滤必须在这里,也就是合并窗口之前**(
|
|
1206
|
+
* **B 档的过滤必须在这里,也就是合并窗口之前**(红线)。放在窗口之后
|
|
1191
1207
|
* 等于让 `node_modules` 的写入噪声照样把窗口顶开、触发无谓刷新 —— 而
|
|
1192
|
-
*
|
|
1208
|
+
* 那条「`node_modules` 的嵌套子目录里批量写文件不触发刷新」正是钉这件事。
|
|
1193
1209
|
*
|
|
1194
1210
|
* A 档不在这里过滤:`ignore` 已经过滤过了(Linux 上还是**注册前跳过**,
|
|
1195
1211
|
* 那才是配额问题的解法)。
|
|
1196
1212
|
*
|
|
1197
|
-
* `filename` 可能为 null(
|
|
1213
|
+
* `filename` 可能为 null(Node 文档载明即便在支持的平台上也不保证提供),
|
|
1198
1214
|
* 那时**放行**:漏刷一次比多刷一次糟得多。
|
|
1199
1215
|
*/
|
|
1200
1216
|
if (tier === "B" && typeof filename === "string" && isIgnored(filename)) return;
|
|
@@ -1297,9 +1313,9 @@ async function readAsset(asset) {
|
|
|
1297
1313
|
|
|
1298
1314
|
//#endregion
|
|
1299
1315
|
//#region src/server/http/idle.ts
|
|
1300
|
-
/** 宽限期。取 30-60s
|
|
1316
|
+
/** 宽限期。取 30-60s 区间中值。 */
|
|
1301
1317
|
const IDLE_EXIT_MS = 45e3;
|
|
1302
|
-
/**
|
|
1318
|
+
/** 内部环境变量,名字定在。不是给用户的开关,不进 `--help` 与 README。 */
|
|
1303
1319
|
const IDLE_ENV = "DIFFTAB_IDLE_MS";
|
|
1304
1320
|
var IdleConfigError = class extends Error {
|
|
1305
1321
|
constructor(message) {
|
|
@@ -1310,7 +1326,7 @@ var IdleConfigError = class extends Error {
|
|
|
1310
1326
|
/**
|
|
1311
1327
|
* 读宽限期。
|
|
1312
1328
|
*
|
|
1313
|
-
* **取值不合法即启动失败,不退回默认的 45s** —— 与
|
|
1329
|
+
* **取值不合法即启动失败,不退回默认的 45s** —— 与 `DIFFTAB_WATCH_TIER`
|
|
1314
1330
|
* 同一条理由:退回时手滑写错的那次照样启动成功、照样跑出一个看着合理的行为,
|
|
1315
1331
|
* 于是「我验过空闲退出了」建立在一次根本没生效的强制指定上(那条用例会等满 45s
|
|
1316
1332
|
* 而不是它以为的 800ms,表现为超时,读起来像产品坏了)。
|
|
@@ -1367,7 +1383,7 @@ const ALLOWED_HOSTNAMES = ["127.0.0.1", "localhost"];
|
|
|
1367
1383
|
/**
|
|
1368
1384
|
* cookie 名带上端口。
|
|
1369
1385
|
*
|
|
1370
|
-
* cookie 的作用域是 host 而非 origin
|
|
1386
|
+
* cookie 的作用域是 host 而非 origin,**不隔离端口**:同机另一个监听
|
|
1371
1387
|
* `127.0.0.1:<其他端口>` 的服务同样会收到我们的 cookie,反过来它设的同名 cookie
|
|
1372
1388
|
* 也会盖掉我们的。名字里带端口,两个 difftab 实例(不同仓库、不同端口)才能
|
|
1373
1389
|
* 在同一个浏览器里共存。
|
|
@@ -1383,7 +1399,7 @@ function createSecret() {
|
|
|
1383
1399
|
* token 形如 `<port>.<secret>`。
|
|
1384
1400
|
*
|
|
1385
1401
|
* 端口写进 token 本身,是为了让服务端校验时**一并绑定校验本次会话的端口**
|
|
1386
|
-
*
|
|
1402
|
+
* cookie 的作用域是 host 而非 origin:它会泄漏给本机其他 localhost 服务,带上端口后
|
|
1387
1403
|
* 那份 token 在别处无法复用。
|
|
1388
1404
|
*/
|
|
1389
1405
|
function composeToken(port, secret) {
|
|
@@ -1393,7 +1409,7 @@ function composeToken(port, secret) {
|
|
|
1393
1409
|
* 打印给用户、也拿去拉起浏览器的那个 URL。
|
|
1394
1410
|
*
|
|
1395
1411
|
* **只此一处**:探活复用命中时,是**另一个进程**按注册表里的 port + token 重新拼出
|
|
1396
|
-
* 同一个 URL
|
|
1412
|
+
* 同一个 URL 交给浏览器。两处各拼一遍的话,哪天 token 的落地方式变了
|
|
1397
1413
|
* (换 query 名、换编码),复用那条路会拼出一个 403 的链接 —— 而它平时不走,
|
|
1398
1414
|
* 谁也不会先注意到。
|
|
1399
1415
|
*/
|
|
@@ -1441,9 +1457,9 @@ function tokensMatch(presented, expected) {
|
|
|
1441
1457
|
/**
|
|
1442
1458
|
* 第 3 道之外的公共响应头。
|
|
1443
1459
|
*
|
|
1444
|
-
* CSP 的后三个指令**不回退到 `default-src
|
|
1460
|
+
* CSP 的后三个指令**不回退到 `default-src`**,不显式写就等于没设:
|
|
1445
1461
|
* `'none'` 一并挡掉被 iframe 嵌套、`<base>` 改写相对 URL 与表单外发。
|
|
1446
|
-
* 不开 `'unsafe-inline'`
|
|
1462
|
+
* 不开 `'unsafe-inline'` 是构建链路顺带解锁的 —— 产物是独立的 .js / .css、
|
|
1447
1463
|
* 页面无内联脚本,才有条件不开。
|
|
1448
1464
|
*/
|
|
1449
1465
|
const SECURITY_HEADERS = {
|
|
@@ -1464,7 +1480,7 @@ const SECURITY_HEADERS = {
|
|
|
1464
1480
|
* 把 token 落成 cookie 的 Set-Cookie 值。
|
|
1465
1481
|
*
|
|
1466
1482
|
* `HttpOnly; SameSite=Strict` + 随后 302 掉 query,避免 token 长期滞留在浏览器
|
|
1467
|
-
*
|
|
1483
|
+
* 历史、地址栏和日志中。不设 `Secure` —— 那会让 http://127.0.0.1
|
|
1468
1484
|
* 上的 cookie 直接不生效。
|
|
1469
1485
|
*/
|
|
1470
1486
|
function tokenCookie(port, token) {
|
|
@@ -1474,7 +1490,7 @@ function tokenCookie(port, token) {
|
|
|
1474
1490
|
//#endregion
|
|
1475
1491
|
//#region src/server/shared/protocol.ts
|
|
1476
1492
|
/**
|
|
1477
|
-
* 服务端 SSE 心跳周期(
|
|
1493
|
+
* 服务端 SSE 心跳周期(约 15s)。
|
|
1478
1494
|
*
|
|
1479
1495
|
* **它是协议的一部分,所以定在这里而不是 http/sse.ts**:后端拿它当发送周期,前端
|
|
1480
1496
|
* 拿它当「对端还在」的判据(`web/state/events.ts` 的 `STALE_MS` 由它推出来)。两边
|
|
@@ -1538,7 +1554,7 @@ function createSseChannel({ heartbeatMs = HEARTBEAT_MS, onChange } = {}) {
|
|
|
1538
1554
|
//#endregion
|
|
1539
1555
|
//#region src/server/http/server.ts
|
|
1540
1556
|
/**
|
|
1541
|
-
*
|
|
1557
|
+
* 错误信息不含绝对路径。
|
|
1542
1558
|
*
|
|
1543
1559
|
* git 的 fatal 文本里经常带着完整仓库路径,原样回给页面就把本机目录结构泄漏进了
|
|
1544
1560
|
* 一个任何本地页面都可能读到的响应体。
|
|
@@ -1567,7 +1583,7 @@ async function startServer(repo, options = {}) {
|
|
|
1567
1583
|
*
|
|
1568
1584
|
* 早先只在兜底的 500 分支上过一次 sanitizeMessage,于是「响应体不含绝对路径」这条
|
|
1569
1585
|
* 不变式实际挂在「以后新增的错误消息恰好都不带路径」上 —— 那不是不变式,是巧合。
|
|
1570
|
-
*
|
|
1586
|
+
* 放在唯一出口上,新增分支自动被覆盖。
|
|
1571
1587
|
*/
|
|
1572
1588
|
const sendError = (res, status, code, message) => {
|
|
1573
1589
|
sendJson(res, status, { error: {
|
|
@@ -1575,16 +1591,17 @@ async function startServer(repo, options = {}) {
|
|
|
1575
1591
|
message: sanitizeMessage(message, repo.root)
|
|
1576
1592
|
} });
|
|
1577
1593
|
};
|
|
1594
|
+
const repoName = repoNameOf(repo);
|
|
1578
1595
|
const secret = createSecret();
|
|
1579
1596
|
let token = "";
|
|
1580
1597
|
let port = 0;
|
|
1581
1598
|
/**
|
|
1582
1599
|
* 档位在**启动时**定,不等第一个订阅者。
|
|
1583
1600
|
*
|
|
1584
|
-
* `/api/state`
|
|
1601
|
+
* `/api/state` 里就带着它,而那个请求先于任何 SSE 连接到达;更要紧的是
|
|
1585
1602
|
* `DIFFTAB_WATCH_TIER` 写错时要在启动那一刻响亮地失败,而不是等到某次订阅。
|
|
1586
1603
|
* 判定本身是纯计算(读 `process.versions.node` 与 `process.platform`),不落在
|
|
1587
|
-
*
|
|
1604
|
+
* 300ms 冷启动预算上。
|
|
1588
1605
|
*/
|
|
1589
1606
|
const tier = resolveTier();
|
|
1590
1607
|
const tierWarning = forcedTierWarning();
|
|
@@ -1595,7 +1612,7 @@ async function startServer(repo, options = {}) {
|
|
|
1595
1612
|
*/
|
|
1596
1613
|
const events = createSseChannel({ onChange: () => idle.touch() });
|
|
1597
1614
|
/**
|
|
1598
|
-
*
|
|
1615
|
+
* 空闲退出的计时器。**判据是 SSE 连接数,但任何通过校验的请求都重置
|
|
1599
1616
|
* 计时** —— 连接数是正面判据,而它在两种情形下同样是 0:刚被探活复用、浏览器
|
|
1600
1617
|
* 还在启动的那几秒,以及页面活着但 SSE 被中间层悄悄回收了。两者取并集,退出条件
|
|
1601
1618
|
* 因此严格弱于「连接数为 0 持续 45s」,只会晚退不会早退。
|
|
@@ -1611,11 +1628,11 @@ async function startServer(repo, options = {}) {
|
|
|
1611
1628
|
/**
|
|
1612
1629
|
* 监听**懒起**:第一个 SSE 订阅者到了才建,起了就一直留到关服务。
|
|
1613
1630
|
*
|
|
1614
|
-
*
|
|
1631
|
+
* 两头都是有理由的。懒起是因为冷启动门禁量的是「监听成功并打印 URL」,而
|
|
1615
1632
|
* S3b2 的 A/B 档要在 repoRoot 上建递归 watch —— Linux 上那是用户态遍历整棵树,
|
|
1616
1633
|
* 大仓库里足以把 300ms 预算一口吃掉,而此刻还没有任何人在等变更通知。
|
|
1617
1634
|
* 不随最后一个订阅者关掉,则是因为刷新页面 = 断开再连,那会让上面那趟遍历
|
|
1618
|
-
* 每刷新一次重来一遍;空闲着的原生 watch 本身开销接近零(
|
|
1635
|
+
* 每刷新一次重来一遍;空闲着的原生 watch 本身开销接近零(资源占用项)。
|
|
1619
1636
|
*/
|
|
1620
1637
|
let watcher = null;
|
|
1621
1638
|
const ensureWatcher = () => {
|
|
@@ -1627,13 +1644,13 @@ async function startServer(repo, options = {}) {
|
|
|
1627
1644
|
onChange: () => events.send("change", {}),
|
|
1628
1645
|
/**
|
|
1629
1646
|
* 轮询探针。**注入而不是让 watch/ 自己去调 git**:git 子进程只许出现在
|
|
1630
|
-
* server/git(
|
|
1647
|
+
* server/git(架构边界不变式 1),而依赖方向里也没有 watch → git 这条边。
|
|
1631
1648
|
* 传进去的是主查询本身(`readStatusRaw` 用的就是 `STATUS_ARGS`),于是「轮询
|
|
1632
1649
|
* 与主查询逐字相同」这条红线在这里是**一处赋值**而不是两处各自维护的巧合。
|
|
1633
1650
|
* 拦住它的不是类型(签名只是 `() => Promise<string>`),是这个唯一的注入点
|
|
1634
1651
|
* 加上冒烟里那条「C 档在已存在的未跟踪目录里新增文件要推出 change」。
|
|
1635
1652
|
*
|
|
1636
|
-
* **已知的覆盖边界**:`/api/state` 里的 `operation
|
|
1653
|
+
* **已知的覆盖边界**:`/api/state` 里的 `operation`不来自 status 输出,
|
|
1637
1654
|
* 所以这条探针看不见它。三档都在 `gitDir` 上建了非递归 watch,`MERGE_HEAD` /
|
|
1638
1655
|
* `rebase-merge/` 的增删就落在那儿,正常情况下照样推得出 change;只有**那条
|
|
1639
1656
|
* watch 自己也失败、整体落到轮询**之后,一次「只动 git 目录、不动 HEAD 也不动
|
|
@@ -1643,8 +1660,8 @@ async function startServer(repo, options = {}) {
|
|
|
1643
1660
|
*/
|
|
1644
1661
|
pollStatus: () => readStatusRaw(repo.root),
|
|
1645
1662
|
/**
|
|
1646
|
-
* 降级为轮询(
|
|
1647
|
-
* `/api/state` 里,前端不重取就看不到降级 ——
|
|
1663
|
+
* 降级为轮询(兜底)。**推一个 `change` 是必需的**:`mode` 只在
|
|
1664
|
+
* `/api/state` 里,前端不重取就看不到降级 —— 而它自己无从推断这件事,
|
|
1648
1665
|
* 于是页面会一直标着「原生监听」直到下一次真的有文件变更。
|
|
1649
1666
|
*/
|
|
1650
1667
|
onDegrade: (cause) => {
|
|
@@ -1654,7 +1671,7 @@ async function startServer(repo, options = {}) {
|
|
|
1654
1671
|
});
|
|
1655
1672
|
};
|
|
1656
1673
|
/**
|
|
1657
|
-
* 当前的监听状态(
|
|
1674
|
+
* 当前的监听状态(`WatchState`)。
|
|
1658
1675
|
*
|
|
1659
1676
|
* **每次请求现算,不是启动时算一次**:降级发生在运行中,而算一次存起来的那份
|
|
1660
1677
|
* 不会报错,只是从此永远说「原生监听」。监听懒起(见上),还没起来时按档位的
|
|
@@ -1698,14 +1715,14 @@ async function startServer(repo, options = {}) {
|
|
|
1698
1715
|
/**
|
|
1699
1716
|
* 到这里才算「有人在」,**三道校验之前不算**(上面那个 302 分支同理:token 对了
|
|
1700
1717
|
* 才 touch)。放在函数开头看起来更省事,代价是本机任何一个端口扫描器都能无限期
|
|
1701
|
-
* 续命一个该退的进程 ——
|
|
1718
|
+
* 续命一个该退的进程 —— 而承诺是「不留后台常驻进程」。
|
|
1702
1719
|
*/
|
|
1703
1720
|
idle.touch();
|
|
1704
1721
|
/**
|
|
1705
|
-
*
|
|
1722
|
+
* 只读工具不需要任何非幂等端点。
|
|
1706
1723
|
*
|
|
1707
|
-
* **必须留在三道校验之后,不得挪回函数开头** —— 理由(泄漏服务存在性)
|
|
1708
|
-
*
|
|
1724
|
+
* **必须留在三道校验之后,不得挪回函数开头** —— 理由(泄漏服务存在性)
|
|
1725
|
+
* 见安全那节。副作用是上面那个 query token 分支也排在它前面:带合法
|
|
1709
1726
|
* token 的非幂等请求会先被 302 换成 cookie 而不是 405。它已经过了三道校验,是取舍。
|
|
1710
1727
|
*/
|
|
1711
1728
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
@@ -1721,6 +1738,7 @@ async function startServer(repo, options = {}) {
|
|
|
1721
1738
|
case "/api/state": {
|
|
1722
1739
|
const status = await readStatus(repo);
|
|
1723
1740
|
sendJson(res, 200, {
|
|
1741
|
+
repoName,
|
|
1724
1742
|
branch: status.branch,
|
|
1725
1743
|
files: status.files,
|
|
1726
1744
|
watch: currentWatchState()
|
|
@@ -1728,13 +1746,13 @@ async function startServer(repo, options = {}) {
|
|
|
1728
1746
|
return;
|
|
1729
1747
|
}
|
|
1730
1748
|
/**
|
|
1731
|
-
*
|
|
1749
|
+
* 探活复用的应答。**消费者是下一个 CLI 进程,不是前端。**
|
|
1732
1750
|
*
|
|
1733
1751
|
* 它答的是「这个端口现在还是**这个仓库**的实例吗」。光有三道校验答不了:
|
|
1734
1752
|
* token 不匹配只证明「不是我们这一份会话」,而端口被系统回收给另一个仓库的
|
|
1735
1753
|
* difftab 时,那边同样有一份合法 token —— 只不过不是我们记在注册表里的那个,
|
|
1736
1754
|
* 于是它会 403、被判为陈旧,正确。真正需要这个正文的是相反的一侧:200 之后
|
|
1737
|
-
* 还要确认路径确实是我们这个仓库,否则「复用」会把用户带到别人的页面(
|
|
1755
|
+
* 还要确认路径确实是我们这个仓库,否则「复用」会把用户带到别人的页面(
|
|
1738
1756
|
* 排除 pid 判活也是同一条理由)。
|
|
1739
1757
|
*/
|
|
1740
1758
|
case "/api/instance":
|
|
@@ -1757,8 +1775,8 @@ async function startServer(repo, options = {}) {
|
|
|
1757
1775
|
return;
|
|
1758
1776
|
}
|
|
1759
1777
|
/**
|
|
1760
|
-
* SSE
|
|
1761
|
-
* 「所有端点(含 SSE)
|
|
1778
|
+
* SSE。**三道校验在上面已经统一走过**,这里没有例外分支 ——
|
|
1779
|
+
* 「所有端点(含 SSE)统一校验」不留例外。
|
|
1762
1780
|
*/
|
|
1763
1781
|
case "/api/events": {
|
|
1764
1782
|
const headers = {
|
|
@@ -1782,13 +1800,13 @@ async function startServer(repo, options = {}) {
|
|
|
1782
1800
|
* 是整个进程带着裸栈崩掉,而不是丢一条心跳。
|
|
1783
1801
|
*
|
|
1784
1802
|
* 顺序则是因为 add 之后、挂上之前的那一小段里断开的话,就再没人把它摘出去:
|
|
1785
|
-
*
|
|
1803
|
+
* 心跳会一直写向一条死响应,空闲退出也永远数不回 0。
|
|
1786
1804
|
*/
|
|
1787
1805
|
/**
|
|
1788
1806
|
* 断开之后**必须再 touch 一次**:计时只在 `touch()` 里起,而这是「最后一个
|
|
1789
1807
|
* 标签被关掉」在服务端的唯一形态。少了它,进程要等到下一个请求(而那正是
|
|
1790
1808
|
* 没有人再发的东西)才想起来自己已经空了 —— 症状是关完浏览器留一个永久
|
|
1791
|
-
*
|
|
1809
|
+
* 常驻进程,与那条验收项正相反。
|
|
1792
1810
|
*/
|
|
1793
1811
|
res.on("close", () => events.remove(res));
|
|
1794
1812
|
res.on("error", () => events.remove(res));
|
|
@@ -1842,11 +1860,11 @@ async function startServer(repo, options = {}) {
|
|
|
1842
1860
|
port = address.port;
|
|
1843
1861
|
token = composeToken(port, secret);
|
|
1844
1862
|
/**
|
|
1845
|
-
*
|
|
1863
|
+
* **宽限期从这一刻就开始计,不等第一个客户端**。
|
|
1846
1864
|
*
|
|
1847
1865
|
* 等第一个客户端才起计时看着更稳妥,实则把「浏览器压根没拉起来」整类情形
|
|
1848
1866
|
* (headless、无 `xdg-open`、`--no-open` 之后用户改了主意)变成永久常驻的后台进程。
|
|
1849
|
-
* 45 秒足够覆盖冷启动一个浏览器进程的 2-5s
|
|
1867
|
+
* 45 秒足够覆盖冷启动一个浏览器进程的 2-5s。
|
|
1850
1868
|
*/
|
|
1851
1869
|
idle.touch();
|
|
1852
1870
|
return {
|
|
@@ -1869,7 +1887,7 @@ async function startServer(repo, options = {}) {
|
|
|
1869
1887
|
* 平台 → 命令的固定映射。**纯函数,便于测试逐条钉住**。
|
|
1870
1888
|
*
|
|
1871
1889
|
* Windows 那串里的空串不是笔误:它是 `start` 的窗口标题占位,少了它,带引号的 URL
|
|
1872
|
-
*
|
|
1890
|
+
* 会被当作标题吞掉,浏览器根本不开。
|
|
1873
1891
|
*/
|
|
1874
1892
|
function browserCommand(platform, url) {
|
|
1875
1893
|
if (platform === "darwin") return {
|
|
@@ -1891,7 +1909,7 @@ function browserCommand(platform, url) {
|
|
|
1891
1909
|
};
|
|
1892
1910
|
}
|
|
1893
1911
|
/**
|
|
1894
|
-
* CI
|
|
1912
|
+
* CI 与冷启动测量里必须能关掉这个调用,否则每跑一次测试就弹一次浏览器。
|
|
1895
1913
|
*
|
|
1896
1914
|
* 这不是「为开发便利而放宽的分支」—— 它不改变任何校验,只决定要不要多起一个进程。
|
|
1897
1915
|
*/
|
|
@@ -1901,8 +1919,8 @@ function browserDisabled(env = process.env) {
|
|
|
1901
1919
|
/**
|
|
1902
1920
|
* 返回是否真的尝试了拉起。
|
|
1903
1921
|
*
|
|
1904
|
-
* 失败(没有 `xdg-open`、headless 环境)只打印 URL
|
|
1905
|
-
*
|
|
1922
|
+
* 失败(没有 `xdg-open`、headless 环境)只打印 URL 让用户自行访问,**不作为启动失败**。
|
|
1923
|
+
* URL 在调用本函数之前已经打印过了,所以这里什么都不用补。
|
|
1906
1924
|
*/
|
|
1907
1925
|
function openBrowser(url) {
|
|
1908
1926
|
if (browserDisabled()) return false;
|
|
@@ -1925,8 +1943,8 @@ function openBrowser(url) {
|
|
|
1925
1943
|
/**
|
|
1926
1944
|
* 注册表目录。
|
|
1927
1945
|
*
|
|
1928
|
-
* **绝不能写进 `.git/` 或工作区** —— 那既污染 `git status
|
|
1929
|
-
*
|
|
1946
|
+
* **绝不能写进 `.git/` 或工作区** —— 那既污染 `git status`,也实质违背零写操作承诺。
|
|
1947
|
+
* `os.tmpdir()` 的权限因平台而异,Linux 上是 `/tmp`(1777,
|
|
1930
1948
|
* 同机其他用户可读),所以这里再套一层每用户私有子目录,文件本身另有 0o600。
|
|
1931
1949
|
*/
|
|
1932
1950
|
function registryDir() {
|
|
@@ -1963,7 +1981,7 @@ function registryPath(repoRoot) {
|
|
|
1963
1981
|
* 写入注册表。
|
|
1964
1982
|
*
|
|
1965
1983
|
* `0o600` 必须**配合 `O_EXCL` 在创建时**给出(`'wx'` + mode),而不是先建后 chmod ——
|
|
1966
|
-
* 后者留下一个竞态窗口,窗口里文件是可读的,而它存着本次会话的 token
|
|
1984
|
+
* 后者留下一个竞态窗口,窗口里文件是可读的,而它存着本次会话的 token。
|
|
1967
1985
|
*/
|
|
1968
1986
|
function writeExclusive(path, payload) {
|
|
1969
1987
|
const fd = openSync(path, "wx", 384);
|
|
@@ -1989,7 +2007,7 @@ function writeRegistry(entry) {
|
|
|
1989
2007
|
* 写注册表),所以覆盖是对的。
|
|
1990
2008
|
*
|
|
1991
2009
|
* 保留 `O_EXCL` + 显式 unlink 而不是改成 `'w'`:两者的区别只在这个分支要不要
|
|
1992
|
-
*
|
|
2010
|
+
* 显式承认「我在覆盖别人的条目」。而那条 `0o600` 必须在创建时给出的要求
|
|
1993
2011
|
* 也只有 `'wx'` 满足 —— `'w'` 对已存在的文件根本不套用 mode,一次遗留的
|
|
1994
2012
|
* 0644 条目会被原样沿用,里面躺着本次会话的 token。
|
|
1995
2013
|
*/
|
|
@@ -2039,9 +2057,9 @@ function removeRegistry(repoRoot) {
|
|
|
2039
2057
|
//#endregion
|
|
2040
2058
|
//#region src/server/cli/probe.ts
|
|
2041
2059
|
/**
|
|
2042
|
-
*
|
|
2060
|
+
* 探活超时。**不取更短**。
|
|
2043
2061
|
*
|
|
2044
|
-
*
|
|
2062
|
+
* 被探的实例可能正卡在那趟用户态递归遍历里(Linux 大仓库上几百毫秒到数秒),
|
|
2045
2063
|
* 超时过短的代价不是慢一点,而是**给同一个仓库起了第二个进程**——正是本模块要防的
|
|
2046
2064
|
* 那件事。反过来这 1.5s 平时根本走不到:注册表不存在时压根不探(常态,冷启动门禁
|
|
2047
2065
|
* 不受影响),端口已死时 localhost 上的 `ECONNREFUSED` 是立即返回的。
|
|
@@ -2058,7 +2076,7 @@ const MAX_BODY_BYTES = 65536;
|
|
|
2058
2076
|
/**
|
|
2059
2077
|
* 向记录的端口要一份 `InstanceInfo`。任何不顺利一律返回 null(= 按陈旧处理)。
|
|
2060
2078
|
*
|
|
2061
|
-
* **带上 token 与合规的 `Host
|
|
2079
|
+
* **带上 token 与合规的 `Host`**:三道校验一视同仁,探活不是例外 ——
|
|
2062
2080
|
* 那也正是「端口已经归了另一个仓库的 difftab」这种情形的判据:它那份 token
|
|
2063
2081
|
* 不是我们记下的这个,于是 403、判陈旧,正确。
|
|
2064
2082
|
*/
|
|
@@ -2146,7 +2164,7 @@ async function findLiveInstance(repoRoot, { timeoutMs = PROBE_TIMEOUT_MS } = {})
|
|
|
2146
2164
|
const info = await requestInstance(entry, timeoutMs);
|
|
2147
2165
|
if (!info) return null;
|
|
2148
2166
|
/**
|
|
2149
|
-
* **200
|
|
2167
|
+
* **200 还不够,路径也得对得上**。
|
|
2150
2168
|
*
|
|
2151
2169
|
* 这一条挡的是「注册表条目还在,而那个端口已经被系统回收给了别的东西」——
|
|
2152
2170
|
* 它恰好也认得这份 token 的概率不高,但代价是把用户带到别人的页面,而这类判断
|
|
@@ -2168,7 +2186,7 @@ async function start(options) {
|
|
|
2168
2186
|
*
|
|
2169
2187
|
* 复用与新起两条路各写一遍的话,只有新起那条天天在跑 —— 改了第一行的形状
|
|
2170
2188
|
* (加前缀、换写法)会在复用那条上静默漂走,而 `scripts/bench-startup.mjs` 与
|
|
2171
|
-
* 冒烟套件的 ready
|
|
2189
|
+
* 冒烟套件的 ready 判据都压在这一行上。
|
|
2172
2190
|
*/
|
|
2173
2191
|
function announce(url, note) {
|
|
2174
2192
|
process.stdout.write(`${url}\n`);
|
|
@@ -2177,7 +2195,7 @@ async function start(options) {
|
|
|
2177
2195
|
}
|
|
2178
2196
|
const repo = await locateRepo(options.cwd);
|
|
2179
2197
|
/**
|
|
2180
|
-
*
|
|
2198
|
+
* **同一个仓库已经有实例在跑,就把用户送去那一个**。
|
|
2181
2199
|
*
|
|
2182
2200
|
* 判活是 HTTP 探活而不是 pid —— 理由在 probe.ts。命中这条路之后**什么都不写**:
|
|
2183
2201
|
* 不写注册表(那条目属于对面那个进程)、不装信号与 exit 处理器(装了会在本进程
|
|
@@ -2190,7 +2208,7 @@ async function start(options) {
|
|
|
2190
2208
|
}
|
|
2191
2209
|
const server = await startServer(repo, {
|
|
2192
2210
|
/**
|
|
2193
|
-
*
|
|
2211
|
+
* 空闲宽限期走满。**「怎么退」归这一层**:http/ 那边只知道
|
|
2194
2212
|
* 「没人了」,退出码、提示语、以及退出前要不要先 close 都是启动流程的事。
|
|
2195
2213
|
*/
|
|
2196
2214
|
onIdle: () => stop(0, "difftab: no tabs left — exiting.\n") });
|
|
@@ -2248,7 +2266,7 @@ function readVersion() {
|
|
|
2248
2266
|
const manifestPath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
|
2249
2267
|
return JSON.parse(readFileSync(manifestPath, "utf8")).version;
|
|
2250
2268
|
}
|
|
2251
|
-
/** 一句话友好报错,不是 Node 异常栈(
|
|
2269
|
+
/** 一句话友好报错,不是 Node 异常栈(启动前置检查)。 */
|
|
2252
2270
|
function fail(message) {
|
|
2253
2271
|
try {
|
|
2254
2272
|
writeSync(2, `difftab: ${message}\n`);
|
|
@@ -2262,7 +2280,7 @@ function fail(message) {
|
|
|
2262
2280
|
* 失败因此以一个 `'error'` 事件到达 —— 零监听器的流收到 `'error'` 就是整个进程带着
|
|
2263
2281
|
* 裸栈以 1 退出。已在 CI 的 windows × Node 24 档实测到:`head` 一退,紧接着那句
|
|
2264
2282
|
* 「read-only view of this repository」就把服务打死了,而 macOS / Linux 上同一条路
|
|
2265
|
-
*
|
|
2283
|
+
* 一声不响。
|
|
2266
2284
|
*
|
|
2267
2285
|
* 服务本身没坏 —— 浏览器照常用得上,45 秒没人连再自己退,所以这里只是把 EPIPE
|
|
2268
2286
|
* 咽掉。**非 EPIPE 的错误照旧抛**:那是真出事了,不该借这条路一起被吞掉。
|