difftab 0.1.1 → 0.1.3
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 +25 -9
- package/README.zh-CN.md +18 -10
- package/bin/difftab.js +2 -2
- package/dist/server/main.js +101 -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
|
@@ -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,7 +267,7 @@ async function locateRepo(cwd) {
|
|
|
267
267
|
};
|
|
268
268
|
}
|
|
269
269
|
/**
|
|
270
|
-
* 工作区根目录名 —— 页面标题里的项目标识(
|
|
270
|
+
* 工作区根目录名 —— 页面标题里的项目标识(`RepoState.repoName`)。
|
|
271
271
|
*
|
|
272
272
|
* **和 `root` 住在一起,不在消费者那边现切**:下面那条「用 `node:path` 不手写切分」
|
|
273
273
|
* 的理由整个建立在 `root` 是怎么来的之上(`rev-parse --show-toplevel`),而那件事只有
|
|
@@ -287,7 +287,7 @@ function repoNameOf(repo) {
|
|
|
287
287
|
* diff 的基准。
|
|
288
288
|
*
|
|
289
289
|
* 正常仓库是 `HEAD`;空仓库(尚无任何提交)下 HEAD 不存在,`git diff HEAD` 会 fatal,
|
|
290
|
-
*
|
|
290
|
+
* 降级为空树哈希。
|
|
291
291
|
*
|
|
292
292
|
* 不做缓存:`git checkout --orphan` 之后 HEAD 会重新变回未出生状态,缓存的正结果
|
|
293
293
|
* 会让 diff 从此全部 fatal。这条只在取 diff 时调用,不落在冷启动路径上。
|
|
@@ -307,7 +307,7 @@ async function resolveDiffBase(root) {
|
|
|
307
307
|
//#endregion
|
|
308
308
|
//#region src/server/git/diff.ts
|
|
309
309
|
/**
|
|
310
|
-
*
|
|
310
|
+
* 超过这个字节数只提示、不预览。
|
|
311
311
|
*
|
|
312
312
|
* **两条路量的东西不同,但量的都是「前端要吃下多少字节」**:未跟踪那一侧整份文件
|
|
313
313
|
* 就是补丁,所以量文件(还省得把它读进来);已跟踪那一侧补丁只含改动与上下文,
|
|
@@ -316,7 +316,7 @@ async function resolveDiffBase(root) {
|
|
|
316
316
|
const MAX_BYTES = 5242880;
|
|
317
317
|
/**
|
|
318
318
|
* 行数上限。体积阈值挡不住另一头:超长行数的窄文件体积不大,但逐行构造 diff 与
|
|
319
|
-
*
|
|
319
|
+
* 前端渲染同样会卡。
|
|
320
320
|
*
|
|
321
321
|
* 已跟踪那一侧数的是**改动行数**(numstat 的加 + 减),未跟踪数的是文件行数 ——
|
|
322
322
|
* 后者本来就整份都是新增行。两边量的都是「前端要渲染多少行」。
|
|
@@ -359,7 +359,7 @@ async function inIndex(root, path) {
|
|
|
359
359
|
/**
|
|
360
360
|
* `git diff --numstat -z` 的解析。**重命名记录不是一段而是三段。**
|
|
361
361
|
*
|
|
362
|
-
*
|
|
362
|
+
* 实测:普通记录是 `<加>\t<减>\t<路径>`,一条占一个 NUL 段;
|
|
363
363
|
* 重命名记录的**路径字段是空的**,后面紧跟两段 `<旧路径>` `<新路径>` ——
|
|
364
364
|
* 与 `status --porcelain=v2` 的 `2 ` 记录**顺序相反**(那边是新在前、旧在后)。
|
|
365
365
|
* 无状态地按 NUL 平铺切分,就会把 `<旧路径>` 当成下一条记录的开头。
|
|
@@ -399,18 +399,18 @@ function parseNumstat(output) {
|
|
|
399
399
|
* `ls-files` 输出为空),status 却照样报 `1 D.`、基准侧也还在。只认 index 会把
|
|
400
400
|
* 它误判成未跟踪、掉进读磁盘那条路,以「文件不存在」告终;
|
|
401
401
|
* 2. **是不是二进制**。已跟踪那一侧一律以 git 自己的判定为准(含 `.gitattributes`),
|
|
402
|
-
* 比 NUL
|
|
402
|
+
* 比 NUL 探测准确,形态是 `-\t-\t<路径>`;
|
|
403
403
|
* 3. **改了多少行**。行数阈值那一路的判据。
|
|
404
404
|
*
|
|
405
405
|
* 重命名同样要传两个路径 + `-M`:只传新路径时 git 无法配对,numstat 会把它算成
|
|
406
|
-
* 一个全新增文件(行数因此是整份文件)
|
|
406
|
+
* 一个全新增文件(行数因此是整份文件),与补丁那一侧的退化是同一回事。
|
|
407
407
|
*/
|
|
408
408
|
async function readNumstat(root, base, path, oldPath) {
|
|
409
409
|
/**
|
|
410
410
|
* **一次查询可以回不止一条记录**,所以必须按路径挑,不能取 `[0]`:
|
|
411
411
|
*
|
|
412
412
|
* 传了两个路径而 git **配不上对**时(`git mv` 之后把内容全部重写、留在工作区不
|
|
413
|
-
* `git add` —— status 照报 `R100`,所以 `oldPath`
|
|
413
|
+
* `git add` —— status 照报 `R100`,所以 `oldPath` 是有的),它会拆成
|
|
414
414
|
* 「删掉旧的」+「新增新的」两条,按路径排序。取 `[0]` 就是掷硬币:实测那条
|
|
415
415
|
* 60,000 行的改名里,排在前面的是旧文件那条 100 行的删除,于是行数闸放它过去,
|
|
416
416
|
* 一份 6 万行的补丁照旧发给浏览器 —— 正是本阶段要防的那次冻结。二进制同理,
|
|
@@ -450,7 +450,7 @@ async function worktreeSize(abs) {
|
|
|
450
450
|
}
|
|
451
451
|
}
|
|
452
452
|
/**
|
|
453
|
-
*
|
|
453
|
+
* 已跟踪文件的补丁,以及三道拒绝。
|
|
454
454
|
*
|
|
455
455
|
* **已跟踪那一侧卡的是「补丁多大」,不是「文件多大」**:文件体积只对未跟踪那条路
|
|
456
456
|
* 成立(整份文件就是补丁),而已跟踪文件的补丁只包含改动与上下文 —— 按文件体积拒绝,
|
|
@@ -464,10 +464,10 @@ async function worktreeSize(abs) {
|
|
|
464
464
|
*
|
|
465
465
|
* `size` 只用于**说话**(告诉用户这文件多大),不再参与判定,所以**只在要拒绝的那两条
|
|
466
466
|
* 分支上才去 `lstat`**:正常那条路(绝大多数请求)一次系统调用都不欠。文件已被删除时
|
|
467
|
-
* 取不到,给 0
|
|
467
|
+
* 取不到,给 0,前端据此不显示体积。
|
|
468
468
|
*
|
|
469
469
|
* **重命名条目必须同时传新旧两个路径**:只传新路径时 git 看不到另一侧、无法配对,
|
|
470
|
-
* 会把重命名退化成一个全新增文件(
|
|
470
|
+
* 会把重命名退化成一个全新增文件(已实测),「重命名识别并标注」随之落空。
|
|
471
471
|
* 两个路径都来自 status 的 `2 ` 记录,无需额外查询。
|
|
472
472
|
*/
|
|
473
473
|
async function trackedDiff(root, base, path, abs, oldPath, stat) {
|
|
@@ -519,8 +519,7 @@ function newFileHeader(path, mode) {
|
|
|
519
519
|
/**
|
|
520
520
|
* 未跟踪文件的补丁:**直接读文件内容手工构造 unified diff**。
|
|
521
521
|
*
|
|
522
|
-
* 不用 `git diff --no-index` —— 它依赖 `/dev/null` 作为对比端,Windows
|
|
523
|
-
* (spec §5.2 / §10)。
|
|
522
|
+
* 不用 `git diff --no-index` —— 它依赖 `/dev/null` 作为对比端,Windows 上不可移植。
|
|
524
523
|
*/
|
|
525
524
|
async function untrackedDiff(root, path) {
|
|
526
525
|
const abs = resolveInRepo(root, path);
|
|
@@ -587,12 +586,12 @@ async function readDiff(root, query) {
|
|
|
587
586
|
//#endregion
|
|
588
587
|
//#region src/server/git/operation.ts
|
|
589
588
|
/**
|
|
590
|
-
*
|
|
589
|
+
* 判据表,**按序取第一个命中**。
|
|
591
590
|
*
|
|
592
591
|
* 两处顺序不是随手排的:
|
|
593
592
|
*
|
|
594
593
|
* - **rebase 排在 merge 之前**。rebase 冲突停下时 git 目录里同时躺着 `rebase-merge/`
|
|
595
|
-
* 与 `MERGE_MSG` / `AUTO_MERGE`(
|
|
594
|
+
* 与 `MERGE_MSG` / `AUTO_MERGE`(已实测),而用户处在的是 rebase —— 反过来
|
|
596
595
|
* 排会把它标成「合并中」,页面上看不出任何异样,只是说错了用户在干什么。
|
|
597
596
|
* `MERGE_HEAD` 本身在那种情形下不出现,所以这一条更像是给后来者的防线
|
|
598
597
|
* - **`rebase-apply/rebasing` 排在 `rebase-apply` 之前**。`git am` 与 `git rebase --apply`
|
|
@@ -637,9 +636,9 @@ function exists(path) {
|
|
|
637
636
|
* 当前进行中的多步操作;没有则 `undefined`。
|
|
638
637
|
*
|
|
639
638
|
* **传进来的必须是 `git rev-parse --git-dir` 的返回值,不能自己拼 `<root>/.git`**
|
|
640
|
-
|
|
639
|
+
*:linked worktree 下 `.git` 是个文件、真正的 git 目录在
|
|
641
640
|
* `<主仓库>/.git/worktrees/<名>`,submodule 在 `<父仓库>/.git/modules/<路径>`
|
|
642
|
-
* (
|
|
641
|
+
* (均已实测)。拼出来的路径在那两种仓库里永远读不到 —— 于是永远标不出操作,
|
|
643
642
|
* 而它不报错。`RepoInfo.gitDir` 已经是这个值,调用方照传即可。
|
|
644
643
|
*
|
|
645
644
|
* 七个探针**并发发**再按上表定序:顺序判据只有一处(上面那张表),而不是散落在
|
|
@@ -660,7 +659,7 @@ async function readOperation(gitDir) {
|
|
|
660
659
|
* 导出成常量是为了让 S3b 的降级轮询**复用逐字相同的这一条**,而不是各写各的:
|
|
661
660
|
* 漏掉 `-uall` 的后果是静默的 —— git 会把未跟踪目录折叠成一行 `dir/`,于是在一个
|
|
662
661
|
* 已存在的未跟踪目录里新增文件根本不改变输出,轮询判定「无变化」、页面不刷新,
|
|
663
|
-
* 而那正是 agent
|
|
662
|
+
* 而那正是 agent 边跑边生成文件时最常见的形态。
|
|
664
663
|
*
|
|
665
664
|
* 两个参数都不能省:`-uall` 见上;`-z` 是因为不加它 git 会对含非 ASCII 字符、空格、
|
|
666
665
|
* 引号的路径做 C 风格转义并加引号(已实测)。
|
|
@@ -803,10 +802,10 @@ function parseStatus(raw) {
|
|
|
803
802
|
/**
|
|
804
803
|
* 主查询的**原始输出**,不解析。
|
|
805
804
|
*
|
|
806
|
-
*
|
|
805
|
+
* 存在的唯一理由是降级轮询:它只需要回答「变没变」,而逐字节比对原始输出
|
|
807
806
|
* 既是最省的判据,也让「轮询与主查询是同一条命令」成为**构造上的事实**而不是
|
|
808
807
|
* 两处各自维护的巧合 —— 后者漏个 `-uall` 不报错,只是在一个已存在的未跟踪目录里
|
|
809
|
-
* 新增文件时页面不刷新(
|
|
808
|
+
* 新增文件时页面不刷新(红线)。
|
|
810
809
|
*
|
|
811
810
|
* 比对解析后的结构也做得到,但那要么写一份深比较、要么 `JSON.stringify` 一遍,
|
|
812
811
|
* 而两者都会随协议类型增删字段而静默改变灵敏度。
|
|
@@ -818,7 +817,7 @@ async function readStatusRaw(root) {
|
|
|
818
817
|
* 变更列表 + 分支状态。
|
|
819
818
|
*
|
|
820
819
|
* 取 `RepoInfo` 而不是两个字符串:进行中的多步操作只能从 **git 目录**下的状态文件读
|
|
821
|
-
|
|
820
|
+
*,而它和工作区根是两条不同的路径 —— linked worktree 与 submodule 下差得很远。
|
|
822
821
|
* 两个同类型参数并排放着,调换顺序不会报错,只会让 `operation` 从此恒为空。
|
|
823
822
|
*
|
|
824
823
|
* 两件事并发:`git status` 是一次子进程,`readOperation` 是七个 `access`,互不依赖。
|
|
@@ -832,14 +831,14 @@ async function readStatus(repo) {
|
|
|
832
831
|
|
|
833
832
|
//#endregion
|
|
834
833
|
//#region src/server/watch/tier.ts
|
|
835
|
-
/** `fs.watch` 的 `ignore`
|
|
834
|
+
/** `fs.watch` 的 `ignore` 选项自此版本可用。 */
|
|
836
835
|
const IGNORE_SINCE = {
|
|
837
836
|
major: 24,
|
|
838
837
|
minor: 14,
|
|
839
838
|
patch: 0
|
|
840
839
|
};
|
|
841
840
|
/**
|
|
842
|
-
* 强制指定档位的**内部**环境变量(
|
|
841
|
+
* 强制指定档位的**内部**环境变量(S3b1 的首个交付物)。
|
|
843
842
|
*
|
|
844
843
|
* 没有它,S3b2 的六条档位验收项在单机上一条都无从自查 —— 一台机器只有一个 Node
|
|
845
844
|
* 版本、一个平台,而三档正是按这两者分的。它不是给用户的开关,README 不写它。
|
|
@@ -887,7 +886,7 @@ function supportsIgnoreOption(nodeVersion) {
|
|
|
887
886
|
return !v.prerelease;
|
|
888
887
|
}
|
|
889
888
|
/**
|
|
890
|
-
*
|
|
889
|
+
* 档位判定。
|
|
891
890
|
*
|
|
892
891
|
* - **A**:有 `ignore`,三端通用 —— Linux 上是注册前跳过,正是配额问题的官方解法
|
|
893
892
|
* - **B**:没有 `ignore`,但在 macOS / Windows 上走原生 FSEvents / `ReadDirectoryChangesW`,
|
|
@@ -916,7 +915,7 @@ function resolveTier(env = process.env, nodeVersion = process.versions.node, pla
|
|
|
916
915
|
return forcedTier(env) ?? detectTier(nodeVersion, platform);
|
|
917
916
|
}
|
|
918
917
|
/**
|
|
919
|
-
* 该档位下**工作区通路**的既定形态(
|
|
918
|
+
* 该档位下**工作区通路**的既定形态(`WatchState.mode`)。
|
|
920
919
|
*
|
|
921
920
|
* C 档一开始就以轮询为工作区通路,A / B 档则是原生监听。**它只是「还没起监听时
|
|
922
921
|
* 答什么」**:监听懒起(见 http/server.ts),起了之后真实取值由 `WatchHandle.mode`
|
|
@@ -928,7 +927,7 @@ function initialMode(tier) {
|
|
|
928
927
|
/**
|
|
929
928
|
* 强制指定 A 档、但这个 Node 根本没有 `ignore` 时的一句提醒(没有则返回 null)。
|
|
930
929
|
*
|
|
931
|
-
*
|
|
930
|
+
* **不是报错**:「三档均可通过内部环境变量强制指定」是已经勾掉的验收项,
|
|
932
931
|
* 在 Node 22 上拒绝启动会把它推翻,而 macOS / Windows 上强制 A 档去看别的行为
|
|
933
932
|
* 也是正当用法。但沉默同样不行 —— Node 对未知选项是**静默忽略**,于是这次
|
|
934
933
|
* 「A 档」跑的是一个**没有任何过滤的递归 watch**:在 Linux 上那正是耗尽
|
|
@@ -970,7 +969,7 @@ function caseFold(segment, platform) {
|
|
|
970
969
|
}
|
|
971
970
|
/**
|
|
972
971
|
* 造一个绑定平台的匹配器。**平台可注入是为了让三端的归一行为都能在一台机器上验**
|
|
973
|
-
* —— 与 `DIFFTAB_WATCH_TIER`
|
|
972
|
+
* —— 与 `DIFFTAB_WATCH_TIER` 同一个理由。
|
|
974
973
|
*
|
|
975
974
|
* 收到的是**相对被监听目录的路径**(A 档 Linux 侧是遍历到的条目,A 档 macOS/Windows
|
|
976
975
|
* 与 B 档是事件路径),不是绝对路径 —— 传绝对路径进来也能工作,只是仓库自身若躺在
|
|
@@ -985,7 +984,7 @@ const isIgnored = createIsIgnored();
|
|
|
985
984
|
//#endregion
|
|
986
985
|
//#region src/server/watch/watcher.ts
|
|
987
986
|
/**
|
|
988
|
-
* 事件合并窗口(
|
|
987
|
+
* 事件合并窗口(建议 100-200ms)。
|
|
989
988
|
*
|
|
990
989
|
* **在 Linux 上这是必需项而非优化项**:用户态递归实现初次遍历目录树时会对遍历到的
|
|
991
990
|
* 每个条目 emit 一次事件,启动瞬间即产生一波与实际变更无关的风暴。`.git` 侧同样需要:
|
|
@@ -993,17 +992,17 @@ const isIgnored = createIsIgnored();
|
|
|
993
992
|
* refs/heads/<分支>,五六个事件是常态。
|
|
994
993
|
*/
|
|
995
994
|
const DEBOUNCE_MS = 150;
|
|
996
|
-
/** 降级轮询的周期(
|
|
995
|
+
/** 降级轮询的周期(1.5s)。C 档的工作区通路也是它。 */
|
|
997
996
|
const POLL_MS = 1500;
|
|
998
997
|
/**
|
|
999
|
-
* **原生档(A / B)
|
|
998
|
+
* **原生档(A / B)的低频安全轮询周期**。
|
|
1000
999
|
*
|
|
1001
1000
|
* 它补的是一个没有任何信号的缺口:Linux 上 inotify 配额在**遍历途中**耗尽时,Node
|
|
1002
|
-
* 一次都不 emit(2026-08-18
|
|
1001
|
+
* 一次都不 emit(2026-08-18 实测,推翻了此前的源码推断)—— 没轮上注册的那些
|
|
1003
1002
|
* 目录里,改一个**启动前就存在**的文件从此静默丢失,`mode` 还一直说 `native`。
|
|
1004
1003
|
* 原生监听少报了什么是没法从监听那一侧知道的,只有拿 status 输出本身去比才看得见。
|
|
1005
1004
|
*
|
|
1006
|
-
* **取 30s 而不是 1.5s
|
|
1005
|
+
* **取 30s 而不是 1.5s**:要求「原生监听模式下空闲 CPU 接近零」,一次 status 几十
|
|
1007
1006
|
* 毫秒,30s 一拍的占空比是千分之几;代价是那个病态场景下最坏 30s 的滞后。
|
|
1008
1007
|
*/
|
|
1009
1008
|
const SAFETY_POLL_MS = 3e4;
|
|
@@ -1012,7 +1011,7 @@ const SAFETY_POLL_MS = 3e4;
|
|
|
1012
1011
|
*
|
|
1013
1012
|
* **一律是目录,绝不是单个文件**:Linux / macOS 上 watch 绑的是 inode,而 git 写
|
|
1014
1013
|
* `HEAD` / `index` 走的是「写临时文件 + 原子 rename」,新文件是新 inode,对文件建的
|
|
1015
|
-
* watch
|
|
1014
|
+
* watch 从此静默失效。
|
|
1016
1015
|
*
|
|
1017
1016
|
* **绝不递归、更绝不进 `objects`**:一次 gc 就是几万个条目,既是配额灾难,也会把
|
|
1018
1017
|
* 与展示无关的写入变成事件风暴。清单之所以短得像不够用,是因为主力其实是 `gitDir`
|
|
@@ -1040,7 +1039,7 @@ function gitWatchDirs(gitDir) {
|
|
|
1040
1039
|
* 起监听。
|
|
1041
1040
|
*
|
|
1042
1041
|
* 关于「我们自己会不会把自己触发起来」:不会,而且这件事全靠封装层那条
|
|
1043
|
-
* `GIT_OPTIONAL_LOCKS=0`(
|
|
1042
|
+
* `GIT_OPTIONAL_LOCKS=0`(红线)。不设它的话 `git status` 会把 stat 缓存写回
|
|
1044
1043
|
* `.git/index`,于是每次刷新都写一次 index、每次写 index 都触发一次刷新 —— 一个不报错、
|
|
1045
1044
|
* 只是 CPU 常年 1% 的自激循环,而 status 的输出从头到尾都是对的。轮询那条路上这
|
|
1046
1045
|
* 一点更要命:它每 1.5s 主动跑一次 status。
|
|
@@ -1150,7 +1149,7 @@ function createWatcher(options) {
|
|
|
1150
1149
|
if (initialMode(tier) === "polling") usePolling(null);
|
|
1151
1150
|
else
|
|
1152
1151
|
/**
|
|
1153
|
-
*
|
|
1152
|
+
* 原生档的低频安全轮询。**直接起循环而不经 `usePolling`**:那个闩一合上
|
|
1154
1153
|
* 就意味着「已降级」,而这里没有任何东西坏掉 —— `mode` 必须还是 `native`,
|
|
1155
1154
|
* 否则页面会把一次完全正常的运行标成降级。首拍照例只建立基线。
|
|
1156
1155
|
*/
|
|
@@ -1169,8 +1168,8 @@ function createWatcher(options) {
|
|
|
1169
1168
|
* **不传 `recursive`**(见 gitWatchDirs 的注释);`persistent: false` 是说
|
|
1170
1169
|
* 监听不吊住事件循环。
|
|
1171
1170
|
*
|
|
1172
|
-
* **filename 一概不看**。它可能为 null
|
|
1173
|
-
* 值得刷新一次:非递归就是真的非递归 —— macOS 已实测(2026-08-11
|
|
1171
|
+
* **filename 一概不看**。它可能为 null,而这几个目录里的任何写入都
|
|
1172
|
+
* 值得刷新一次:非递归就是真的非递归 —— macOS 已实测(2026-08-11),
|
|
1174
1173
|
* Linux 的 inotify 与 Windows 的 `bWatchSubtree=FALSE` 按机制如此 —— 于是
|
|
1175
1174
|
* `objects/` 的海量写入根本到不了这里,没有需要按名字排除的东西。曾按
|
|
1176
1175
|
* 「macOS 会漏过来」加过一个顶层段过滤,那是把**建流窗口的补报**(建流前一刻的
|
|
@@ -1194,7 +1193,7 @@ function createWatcher(options) {
|
|
|
1194
1193
|
}
|
|
1195
1194
|
}
|
|
1196
1195
|
/**
|
|
1197
|
-
*
|
|
1196
|
+
* 工作区侧,按档位分。
|
|
1198
1197
|
*
|
|
1199
1198
|
* **C 档一个递归 watch 都不建**:Node 在 Linux 上的递归实现是用户态遍历,对遍历到的
|
|
1200
1199
|
* 每个**普通文件**也注册一个 inotify watch,monorepo 下足以耗尽
|
|
@@ -1204,14 +1203,14 @@ function createWatcher(options) {
|
|
|
1204
1203
|
if (tier !== "C") try {
|
|
1205
1204
|
const handler = (_event, filename) => {
|
|
1206
1205
|
/**
|
|
1207
|
-
* **B 档的过滤必须在这里,也就是合并窗口之前**(
|
|
1206
|
+
* **B 档的过滤必须在这里,也就是合并窗口之前**(红线)。放在窗口之后
|
|
1208
1207
|
* 等于让 `node_modules` 的写入噪声照样把窗口顶开、触发无谓刷新 —— 而
|
|
1209
|
-
*
|
|
1208
|
+
* 那条「`node_modules` 的嵌套子目录里批量写文件不触发刷新」正是钉这件事。
|
|
1210
1209
|
*
|
|
1211
1210
|
* A 档不在这里过滤:`ignore` 已经过滤过了(Linux 上还是**注册前跳过**,
|
|
1212
1211
|
* 那才是配额问题的解法)。
|
|
1213
1212
|
*
|
|
1214
|
-
* `filename` 可能为 null(
|
|
1213
|
+
* `filename` 可能为 null(Node 文档载明即便在支持的平台上也不保证提供),
|
|
1215
1214
|
* 那时**放行**:漏刷一次比多刷一次糟得多。
|
|
1216
1215
|
*/
|
|
1217
1216
|
if (tier === "B" && typeof filename === "string" && isIgnored(filename)) return;
|
|
@@ -1314,9 +1313,9 @@ async function readAsset(asset) {
|
|
|
1314
1313
|
|
|
1315
1314
|
//#endregion
|
|
1316
1315
|
//#region src/server/http/idle.ts
|
|
1317
|
-
/** 宽限期。取 30-60s
|
|
1316
|
+
/** 宽限期。取 30-60s 区间中值。 */
|
|
1318
1317
|
const IDLE_EXIT_MS = 45e3;
|
|
1319
|
-
/**
|
|
1318
|
+
/** 内部环境变量,名字定在。不是给用户的开关,不进 `--help` 与 README。 */
|
|
1320
1319
|
const IDLE_ENV = "DIFFTAB_IDLE_MS";
|
|
1321
1320
|
var IdleConfigError = class extends Error {
|
|
1322
1321
|
constructor(message) {
|
|
@@ -1327,7 +1326,7 @@ var IdleConfigError = class extends Error {
|
|
|
1327
1326
|
/**
|
|
1328
1327
|
* 读宽限期。
|
|
1329
1328
|
*
|
|
1330
|
-
* **取值不合法即启动失败,不退回默认的 45s** —— 与
|
|
1329
|
+
* **取值不合法即启动失败,不退回默认的 45s** —— 与 `DIFFTAB_WATCH_TIER`
|
|
1331
1330
|
* 同一条理由:退回时手滑写错的那次照样启动成功、照样跑出一个看着合理的行为,
|
|
1332
1331
|
* 于是「我验过空闲退出了」建立在一次根本没生效的强制指定上(那条用例会等满 45s
|
|
1333
1332
|
* 而不是它以为的 800ms,表现为超时,读起来像产品坏了)。
|
|
@@ -1384,7 +1383,7 @@ const ALLOWED_HOSTNAMES = ["127.0.0.1", "localhost"];
|
|
|
1384
1383
|
/**
|
|
1385
1384
|
* cookie 名带上端口。
|
|
1386
1385
|
*
|
|
1387
|
-
* cookie 的作用域是 host 而非 origin
|
|
1386
|
+
* cookie 的作用域是 host 而非 origin,**不隔离端口**:同机另一个监听
|
|
1388
1387
|
* `127.0.0.1:<其他端口>` 的服务同样会收到我们的 cookie,反过来它设的同名 cookie
|
|
1389
1388
|
* 也会盖掉我们的。名字里带端口,两个 difftab 实例(不同仓库、不同端口)才能
|
|
1390
1389
|
* 在同一个浏览器里共存。
|
|
@@ -1400,7 +1399,7 @@ function createSecret() {
|
|
|
1400
1399
|
* token 形如 `<port>.<secret>`。
|
|
1401
1400
|
*
|
|
1402
1401
|
* 端口写进 token 本身,是为了让服务端校验时**一并绑定校验本次会话的端口**
|
|
1403
|
-
*
|
|
1402
|
+
* cookie 的作用域是 host 而非 origin:它会泄漏给本机其他 localhost 服务,带上端口后
|
|
1404
1403
|
* 那份 token 在别处无法复用。
|
|
1405
1404
|
*/
|
|
1406
1405
|
function composeToken(port, secret) {
|
|
@@ -1410,7 +1409,7 @@ function composeToken(port, secret) {
|
|
|
1410
1409
|
* 打印给用户、也拿去拉起浏览器的那个 URL。
|
|
1411
1410
|
*
|
|
1412
1411
|
* **只此一处**:探活复用命中时,是**另一个进程**按注册表里的 port + token 重新拼出
|
|
1413
|
-
* 同一个 URL
|
|
1412
|
+
* 同一个 URL 交给浏览器。两处各拼一遍的话,哪天 token 的落地方式变了
|
|
1414
1413
|
* (换 query 名、换编码),复用那条路会拼出一个 403 的链接 —— 而它平时不走,
|
|
1415
1414
|
* 谁也不会先注意到。
|
|
1416
1415
|
*/
|
|
@@ -1458,9 +1457,9 @@ function tokensMatch(presented, expected) {
|
|
|
1458
1457
|
/**
|
|
1459
1458
|
* 第 3 道之外的公共响应头。
|
|
1460
1459
|
*
|
|
1461
|
-
* CSP 的后三个指令**不回退到 `default-src
|
|
1460
|
+
* CSP 的后三个指令**不回退到 `default-src`**,不显式写就等于没设:
|
|
1462
1461
|
* `'none'` 一并挡掉被 iframe 嵌套、`<base>` 改写相对 URL 与表单外发。
|
|
1463
|
-
* 不开 `'unsafe-inline'`
|
|
1462
|
+
* 不开 `'unsafe-inline'` 是构建链路顺带解锁的 —— 产物是独立的 .js / .css、
|
|
1464
1463
|
* 页面无内联脚本,才有条件不开。
|
|
1465
1464
|
*/
|
|
1466
1465
|
const SECURITY_HEADERS = {
|
|
@@ -1481,7 +1480,7 @@ const SECURITY_HEADERS = {
|
|
|
1481
1480
|
* 把 token 落成 cookie 的 Set-Cookie 值。
|
|
1482
1481
|
*
|
|
1483
1482
|
* `HttpOnly; SameSite=Strict` + 随后 302 掉 query,避免 token 长期滞留在浏览器
|
|
1484
|
-
*
|
|
1483
|
+
* 历史、地址栏和日志中。不设 `Secure` —— 那会让 http://127.0.0.1
|
|
1485
1484
|
* 上的 cookie 直接不生效。
|
|
1486
1485
|
*/
|
|
1487
1486
|
function tokenCookie(port, token) {
|
|
@@ -1491,7 +1490,7 @@ function tokenCookie(port, token) {
|
|
|
1491
1490
|
//#endregion
|
|
1492
1491
|
//#region src/server/shared/protocol.ts
|
|
1493
1492
|
/**
|
|
1494
|
-
* 服务端 SSE 心跳周期(
|
|
1493
|
+
* 服务端 SSE 心跳周期(约 15s)。
|
|
1495
1494
|
*
|
|
1496
1495
|
* **它是协议的一部分,所以定在这里而不是 http/sse.ts**:后端拿它当发送周期,前端
|
|
1497
1496
|
* 拿它当「对端还在」的判据(`web/state/events.ts` 的 `STALE_MS` 由它推出来)。两边
|
|
@@ -1555,7 +1554,7 @@ function createSseChannel({ heartbeatMs = HEARTBEAT_MS, onChange } = {}) {
|
|
|
1555
1554
|
//#endregion
|
|
1556
1555
|
//#region src/server/http/server.ts
|
|
1557
1556
|
/**
|
|
1558
|
-
*
|
|
1557
|
+
* 错误信息不含绝对路径。
|
|
1559
1558
|
*
|
|
1560
1559
|
* git 的 fatal 文本里经常带着完整仓库路径,原样回给页面就把本机目录结构泄漏进了
|
|
1561
1560
|
* 一个任何本地页面都可能读到的响应体。
|
|
@@ -1584,7 +1583,7 @@ async function startServer(repo, options = {}) {
|
|
|
1584
1583
|
*
|
|
1585
1584
|
* 早先只在兜底的 500 分支上过一次 sanitizeMessage,于是「响应体不含绝对路径」这条
|
|
1586
1585
|
* 不变式实际挂在「以后新增的错误消息恰好都不带路径」上 —— 那不是不变式,是巧合。
|
|
1587
|
-
*
|
|
1586
|
+
* 放在唯一出口上,新增分支自动被覆盖。
|
|
1588
1587
|
*/
|
|
1589
1588
|
const sendError = (res, status, code, message) => {
|
|
1590
1589
|
sendJson(res, status, { error: {
|
|
@@ -1599,10 +1598,10 @@ async function startServer(repo, options = {}) {
|
|
|
1599
1598
|
/**
|
|
1600
1599
|
* 档位在**启动时**定,不等第一个订阅者。
|
|
1601
1600
|
*
|
|
1602
|
-
* `/api/state`
|
|
1601
|
+
* `/api/state` 里就带着它,而那个请求先于任何 SSE 连接到达;更要紧的是
|
|
1603
1602
|
* `DIFFTAB_WATCH_TIER` 写错时要在启动那一刻响亮地失败,而不是等到某次订阅。
|
|
1604
1603
|
* 判定本身是纯计算(读 `process.versions.node` 与 `process.platform`),不落在
|
|
1605
|
-
*
|
|
1604
|
+
* 300ms 冷启动预算上。
|
|
1606
1605
|
*/
|
|
1607
1606
|
const tier = resolveTier();
|
|
1608
1607
|
const tierWarning = forcedTierWarning();
|
|
@@ -1613,7 +1612,7 @@ async function startServer(repo, options = {}) {
|
|
|
1613
1612
|
*/
|
|
1614
1613
|
const events = createSseChannel({ onChange: () => idle.touch() });
|
|
1615
1614
|
/**
|
|
1616
|
-
*
|
|
1615
|
+
* 空闲退出的计时器。**判据是 SSE 连接数,但任何通过校验的请求都重置
|
|
1617
1616
|
* 计时** —— 连接数是正面判据,而它在两种情形下同样是 0:刚被探活复用、浏览器
|
|
1618
1617
|
* 还在启动的那几秒,以及页面活着但 SSE 被中间层悄悄回收了。两者取并集,退出条件
|
|
1619
1618
|
* 因此严格弱于「连接数为 0 持续 45s」,只会晚退不会早退。
|
|
@@ -1629,11 +1628,11 @@ async function startServer(repo, options = {}) {
|
|
|
1629
1628
|
/**
|
|
1630
1629
|
* 监听**懒起**:第一个 SSE 订阅者到了才建,起了就一直留到关服务。
|
|
1631
1630
|
*
|
|
1632
|
-
*
|
|
1631
|
+
* 两头都是有理由的。懒起是因为冷启动门禁量的是「监听成功并打印 URL」,而
|
|
1633
1632
|
* S3b2 的 A/B 档要在 repoRoot 上建递归 watch —— Linux 上那是用户态遍历整棵树,
|
|
1634
1633
|
* 大仓库里足以把 300ms 预算一口吃掉,而此刻还没有任何人在等变更通知。
|
|
1635
1634
|
* 不随最后一个订阅者关掉,则是因为刷新页面 = 断开再连,那会让上面那趟遍历
|
|
1636
|
-
* 每刷新一次重来一遍;空闲着的原生 watch 本身开销接近零(
|
|
1635
|
+
* 每刷新一次重来一遍;空闲着的原生 watch 本身开销接近零(资源占用项)。
|
|
1637
1636
|
*/
|
|
1638
1637
|
let watcher = null;
|
|
1639
1638
|
const ensureWatcher = () => {
|
|
@@ -1645,13 +1644,13 @@ async function startServer(repo, options = {}) {
|
|
|
1645
1644
|
onChange: () => events.send("change", {}),
|
|
1646
1645
|
/**
|
|
1647
1646
|
* 轮询探针。**注入而不是让 watch/ 自己去调 git**:git 子进程只许出现在
|
|
1648
|
-
* server/git(
|
|
1647
|
+
* server/git(架构边界不变式 1),而依赖方向里也没有 watch → git 这条边。
|
|
1649
1648
|
* 传进去的是主查询本身(`readStatusRaw` 用的就是 `STATUS_ARGS`),于是「轮询
|
|
1650
1649
|
* 与主查询逐字相同」这条红线在这里是**一处赋值**而不是两处各自维护的巧合。
|
|
1651
1650
|
* 拦住它的不是类型(签名只是 `() => Promise<string>`),是这个唯一的注入点
|
|
1652
1651
|
* 加上冒烟里那条「C 档在已存在的未跟踪目录里新增文件要推出 change」。
|
|
1653
1652
|
*
|
|
1654
|
-
* **已知的覆盖边界**:`/api/state` 里的 `operation
|
|
1653
|
+
* **已知的覆盖边界**:`/api/state` 里的 `operation`不来自 status 输出,
|
|
1655
1654
|
* 所以这条探针看不见它。三档都在 `gitDir` 上建了非递归 watch,`MERGE_HEAD` /
|
|
1656
1655
|
* `rebase-merge/` 的增删就落在那儿,正常情况下照样推得出 change;只有**那条
|
|
1657
1656
|
* watch 自己也失败、整体落到轮询**之后,一次「只动 git 目录、不动 HEAD 也不动
|
|
@@ -1661,8 +1660,8 @@ async function startServer(repo, options = {}) {
|
|
|
1661
1660
|
*/
|
|
1662
1661
|
pollStatus: () => readStatusRaw(repo.root),
|
|
1663
1662
|
/**
|
|
1664
|
-
* 降级为轮询(
|
|
1665
|
-
* `/api/state` 里,前端不重取就看不到降级 ——
|
|
1663
|
+
* 降级为轮询(兜底)。**推一个 `change` 是必需的**:`mode` 只在
|
|
1664
|
+
* `/api/state` 里,前端不重取就看不到降级 —— 而它自己无从推断这件事,
|
|
1666
1665
|
* 于是页面会一直标着「原生监听」直到下一次真的有文件变更。
|
|
1667
1666
|
*/
|
|
1668
1667
|
onDegrade: (cause) => {
|
|
@@ -1672,7 +1671,7 @@ async function startServer(repo, options = {}) {
|
|
|
1672
1671
|
});
|
|
1673
1672
|
};
|
|
1674
1673
|
/**
|
|
1675
|
-
* 当前的监听状态(
|
|
1674
|
+
* 当前的监听状态(`WatchState`)。
|
|
1676
1675
|
*
|
|
1677
1676
|
* **每次请求现算,不是启动时算一次**:降级发生在运行中,而算一次存起来的那份
|
|
1678
1677
|
* 不会报错,只是从此永远说「原生监听」。监听懒起(见上),还没起来时按档位的
|
|
@@ -1716,14 +1715,14 @@ async function startServer(repo, options = {}) {
|
|
|
1716
1715
|
/**
|
|
1717
1716
|
* 到这里才算「有人在」,**三道校验之前不算**(上面那个 302 分支同理:token 对了
|
|
1718
1717
|
* 才 touch)。放在函数开头看起来更省事,代价是本机任何一个端口扫描器都能无限期
|
|
1719
|
-
* 续命一个该退的进程 ——
|
|
1718
|
+
* 续命一个该退的进程 —— 而承诺是「不留后台常驻进程」。
|
|
1720
1719
|
*/
|
|
1721
1720
|
idle.touch();
|
|
1722
1721
|
/**
|
|
1723
|
-
*
|
|
1722
|
+
* 只读工具不需要任何非幂等端点。
|
|
1724
1723
|
*
|
|
1725
|
-
* **必须留在三道校验之后,不得挪回函数开头** —— 理由(泄漏服务存在性)
|
|
1726
|
-
*
|
|
1724
|
+
* **必须留在三道校验之后,不得挪回函数开头** —— 理由(泄漏服务存在性)
|
|
1725
|
+
* 见安全那节。副作用是上面那个 query token 分支也排在它前面:带合法
|
|
1727
1726
|
* token 的非幂等请求会先被 302 换成 cookie 而不是 405。它已经过了三道校验,是取舍。
|
|
1728
1727
|
*/
|
|
1729
1728
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
@@ -1747,13 +1746,13 @@ async function startServer(repo, options = {}) {
|
|
|
1747
1746
|
return;
|
|
1748
1747
|
}
|
|
1749
1748
|
/**
|
|
1750
|
-
*
|
|
1749
|
+
* 探活复用的应答。**消费者是下一个 CLI 进程,不是前端。**
|
|
1751
1750
|
*
|
|
1752
1751
|
* 它答的是「这个端口现在还是**这个仓库**的实例吗」。光有三道校验答不了:
|
|
1753
1752
|
* token 不匹配只证明「不是我们这一份会话」,而端口被系统回收给另一个仓库的
|
|
1754
1753
|
* difftab 时,那边同样有一份合法 token —— 只不过不是我们记在注册表里的那个,
|
|
1755
1754
|
* 于是它会 403、被判为陈旧,正确。真正需要这个正文的是相反的一侧:200 之后
|
|
1756
|
-
* 还要确认路径确实是我们这个仓库,否则「复用」会把用户带到别人的页面(
|
|
1755
|
+
* 还要确认路径确实是我们这个仓库,否则「复用」会把用户带到别人的页面(
|
|
1757
1756
|
* 排除 pid 判活也是同一条理由)。
|
|
1758
1757
|
*/
|
|
1759
1758
|
case "/api/instance":
|
|
@@ -1776,8 +1775,8 @@ async function startServer(repo, options = {}) {
|
|
|
1776
1775
|
return;
|
|
1777
1776
|
}
|
|
1778
1777
|
/**
|
|
1779
|
-
* SSE
|
|
1780
|
-
* 「所有端点(含 SSE)
|
|
1778
|
+
* SSE。**三道校验在上面已经统一走过**,这里没有例外分支 ——
|
|
1779
|
+
* 「所有端点(含 SSE)统一校验」不留例外。
|
|
1781
1780
|
*/
|
|
1782
1781
|
case "/api/events": {
|
|
1783
1782
|
const headers = {
|
|
@@ -1801,13 +1800,13 @@ async function startServer(repo, options = {}) {
|
|
|
1801
1800
|
* 是整个进程带着裸栈崩掉,而不是丢一条心跳。
|
|
1802
1801
|
*
|
|
1803
1802
|
* 顺序则是因为 add 之后、挂上之前的那一小段里断开的话,就再没人把它摘出去:
|
|
1804
|
-
*
|
|
1803
|
+
* 心跳会一直写向一条死响应,空闲退出也永远数不回 0。
|
|
1805
1804
|
*/
|
|
1806
1805
|
/**
|
|
1807
1806
|
* 断开之后**必须再 touch 一次**:计时只在 `touch()` 里起,而这是「最后一个
|
|
1808
1807
|
* 标签被关掉」在服务端的唯一形态。少了它,进程要等到下一个请求(而那正是
|
|
1809
1808
|
* 没有人再发的东西)才想起来自己已经空了 —— 症状是关完浏览器留一个永久
|
|
1810
|
-
*
|
|
1809
|
+
* 常驻进程,与那条验收项正相反。
|
|
1811
1810
|
*/
|
|
1812
1811
|
res.on("close", () => events.remove(res));
|
|
1813
1812
|
res.on("error", () => events.remove(res));
|
|
@@ -1861,11 +1860,11 @@ async function startServer(repo, options = {}) {
|
|
|
1861
1860
|
port = address.port;
|
|
1862
1861
|
token = composeToken(port, secret);
|
|
1863
1862
|
/**
|
|
1864
|
-
*
|
|
1863
|
+
* **宽限期从这一刻就开始计,不等第一个客户端**。
|
|
1865
1864
|
*
|
|
1866
1865
|
* 等第一个客户端才起计时看着更稳妥,实则把「浏览器压根没拉起来」整类情形
|
|
1867
1866
|
* (headless、无 `xdg-open`、`--no-open` 之后用户改了主意)变成永久常驻的后台进程。
|
|
1868
|
-
* 45 秒足够覆盖冷启动一个浏览器进程的 2-5s
|
|
1867
|
+
* 45 秒足够覆盖冷启动一个浏览器进程的 2-5s。
|
|
1869
1868
|
*/
|
|
1870
1869
|
idle.touch();
|
|
1871
1870
|
return {
|
|
@@ -1888,7 +1887,7 @@ async function startServer(repo, options = {}) {
|
|
|
1888
1887
|
* 平台 → 命令的固定映射。**纯函数,便于测试逐条钉住**。
|
|
1889
1888
|
*
|
|
1890
1889
|
* Windows 那串里的空串不是笔误:它是 `start` 的窗口标题占位,少了它,带引号的 URL
|
|
1891
|
-
*
|
|
1890
|
+
* 会被当作标题吞掉,浏览器根本不开。
|
|
1892
1891
|
*/
|
|
1893
1892
|
function browserCommand(platform, url) {
|
|
1894
1893
|
if (platform === "darwin") return {
|
|
@@ -1910,7 +1909,7 @@ function browserCommand(platform, url) {
|
|
|
1910
1909
|
};
|
|
1911
1910
|
}
|
|
1912
1911
|
/**
|
|
1913
|
-
* CI
|
|
1912
|
+
* CI 与冷启动测量里必须能关掉这个调用,否则每跑一次测试就弹一次浏览器。
|
|
1914
1913
|
*
|
|
1915
1914
|
* 这不是「为开发便利而放宽的分支」—— 它不改变任何校验,只决定要不要多起一个进程。
|
|
1916
1915
|
*/
|
|
@@ -1920,8 +1919,8 @@ function browserDisabled(env = process.env) {
|
|
|
1920
1919
|
/**
|
|
1921
1920
|
* 返回是否真的尝试了拉起。
|
|
1922
1921
|
*
|
|
1923
|
-
* 失败(没有 `xdg-open`、headless 环境)只打印 URL
|
|
1924
|
-
*
|
|
1922
|
+
* 失败(没有 `xdg-open`、headless 环境)只打印 URL 让用户自行访问,**不作为启动失败**。
|
|
1923
|
+
* URL 在调用本函数之前已经打印过了,所以这里什么都不用补。
|
|
1925
1924
|
*/
|
|
1926
1925
|
function openBrowser(url) {
|
|
1927
1926
|
if (browserDisabled()) return false;
|
|
@@ -1944,8 +1943,8 @@ function openBrowser(url) {
|
|
|
1944
1943
|
/**
|
|
1945
1944
|
* 注册表目录。
|
|
1946
1945
|
*
|
|
1947
|
-
* **绝不能写进 `.git/` 或工作区** —— 那既污染 `git status
|
|
1948
|
-
*
|
|
1946
|
+
* **绝不能写进 `.git/` 或工作区** —— 那既污染 `git status`,也实质违背零写操作承诺。
|
|
1947
|
+
* `os.tmpdir()` 的权限因平台而异,Linux 上是 `/tmp`(1777,
|
|
1949
1948
|
* 同机其他用户可读),所以这里再套一层每用户私有子目录,文件本身另有 0o600。
|
|
1950
1949
|
*/
|
|
1951
1950
|
function registryDir() {
|
|
@@ -1982,7 +1981,7 @@ function registryPath(repoRoot) {
|
|
|
1982
1981
|
* 写入注册表。
|
|
1983
1982
|
*
|
|
1984
1983
|
* `0o600` 必须**配合 `O_EXCL` 在创建时**给出(`'wx'` + mode),而不是先建后 chmod ——
|
|
1985
|
-
* 后者留下一个竞态窗口,窗口里文件是可读的,而它存着本次会话的 token
|
|
1984
|
+
* 后者留下一个竞态窗口,窗口里文件是可读的,而它存着本次会话的 token。
|
|
1986
1985
|
*/
|
|
1987
1986
|
function writeExclusive(path, payload) {
|
|
1988
1987
|
const fd = openSync(path, "wx", 384);
|
|
@@ -2008,7 +2007,7 @@ function writeRegistry(entry) {
|
|
|
2008
2007
|
* 写注册表),所以覆盖是对的。
|
|
2009
2008
|
*
|
|
2010
2009
|
* 保留 `O_EXCL` + 显式 unlink 而不是改成 `'w'`:两者的区别只在这个分支要不要
|
|
2011
|
-
*
|
|
2010
|
+
* 显式承认「我在覆盖别人的条目」。而那条 `0o600` 必须在创建时给出的要求
|
|
2012
2011
|
* 也只有 `'wx'` 满足 —— `'w'` 对已存在的文件根本不套用 mode,一次遗留的
|
|
2013
2012
|
* 0644 条目会被原样沿用,里面躺着本次会话的 token。
|
|
2014
2013
|
*/
|
|
@@ -2058,9 +2057,9 @@ function removeRegistry(repoRoot) {
|
|
|
2058
2057
|
//#endregion
|
|
2059
2058
|
//#region src/server/cli/probe.ts
|
|
2060
2059
|
/**
|
|
2061
|
-
*
|
|
2060
|
+
* 探活超时。**不取更短**。
|
|
2062
2061
|
*
|
|
2063
|
-
*
|
|
2062
|
+
* 被探的实例可能正卡在那趟用户态递归遍历里(Linux 大仓库上几百毫秒到数秒),
|
|
2064
2063
|
* 超时过短的代价不是慢一点,而是**给同一个仓库起了第二个进程**——正是本模块要防的
|
|
2065
2064
|
* 那件事。反过来这 1.5s 平时根本走不到:注册表不存在时压根不探(常态,冷启动门禁
|
|
2066
2065
|
* 不受影响),端口已死时 localhost 上的 `ECONNREFUSED` 是立即返回的。
|
|
@@ -2077,7 +2076,7 @@ const MAX_BODY_BYTES = 65536;
|
|
|
2077
2076
|
/**
|
|
2078
2077
|
* 向记录的端口要一份 `InstanceInfo`。任何不顺利一律返回 null(= 按陈旧处理)。
|
|
2079
2078
|
*
|
|
2080
|
-
* **带上 token 与合规的 `Host
|
|
2079
|
+
* **带上 token 与合规的 `Host`**:三道校验一视同仁,探活不是例外 ——
|
|
2081
2080
|
* 那也正是「端口已经归了另一个仓库的 difftab」这种情形的判据:它那份 token
|
|
2082
2081
|
* 不是我们记下的这个,于是 403、判陈旧,正确。
|
|
2083
2082
|
*/
|
|
@@ -2165,7 +2164,7 @@ async function findLiveInstance(repoRoot, { timeoutMs = PROBE_TIMEOUT_MS } = {})
|
|
|
2165
2164
|
const info = await requestInstance(entry, timeoutMs);
|
|
2166
2165
|
if (!info) return null;
|
|
2167
2166
|
/**
|
|
2168
|
-
* **200
|
|
2167
|
+
* **200 还不够,路径也得对得上**。
|
|
2169
2168
|
*
|
|
2170
2169
|
* 这一条挡的是「注册表条目还在,而那个端口已经被系统回收给了别的东西」——
|
|
2171
2170
|
* 它恰好也认得这份 token 的概率不高,但代价是把用户带到别人的页面,而这类判断
|
|
@@ -2187,7 +2186,7 @@ async function start(options) {
|
|
|
2187
2186
|
*
|
|
2188
2187
|
* 复用与新起两条路各写一遍的话,只有新起那条天天在跑 —— 改了第一行的形状
|
|
2189
2188
|
* (加前缀、换写法)会在复用那条上静默漂走,而 `scripts/bench-startup.mjs` 与
|
|
2190
|
-
* 冒烟套件的 ready
|
|
2189
|
+
* 冒烟套件的 ready 判据都压在这一行上。
|
|
2191
2190
|
*/
|
|
2192
2191
|
function announce(url, note) {
|
|
2193
2192
|
process.stdout.write(`${url}\n`);
|
|
@@ -2196,7 +2195,7 @@ async function start(options) {
|
|
|
2196
2195
|
}
|
|
2197
2196
|
const repo = await locateRepo(options.cwd);
|
|
2198
2197
|
/**
|
|
2199
|
-
*
|
|
2198
|
+
* **同一个仓库已经有实例在跑,就把用户送去那一个**。
|
|
2200
2199
|
*
|
|
2201
2200
|
* 判活是 HTTP 探活而不是 pid —— 理由在 probe.ts。命中这条路之后**什么都不写**:
|
|
2202
2201
|
* 不写注册表(那条目属于对面那个进程)、不装信号与 exit 处理器(装了会在本进程
|
|
@@ -2209,7 +2208,7 @@ async function start(options) {
|
|
|
2209
2208
|
}
|
|
2210
2209
|
const server = await startServer(repo, {
|
|
2211
2210
|
/**
|
|
2212
|
-
*
|
|
2211
|
+
* 空闲宽限期走满。**「怎么退」归这一层**:http/ 那边只知道
|
|
2213
2212
|
* 「没人了」,退出码、提示语、以及退出前要不要先 close 都是启动流程的事。
|
|
2214
2213
|
*/
|
|
2215
2214
|
onIdle: () => stop(0, "difftab: no tabs left — exiting.\n") });
|
|
@@ -2267,7 +2266,7 @@ function readVersion() {
|
|
|
2267
2266
|
const manifestPath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
|
2268
2267
|
return JSON.parse(readFileSync(manifestPath, "utf8")).version;
|
|
2269
2268
|
}
|
|
2270
|
-
/** 一句话友好报错,不是 Node 异常栈(
|
|
2269
|
+
/** 一句话友好报错,不是 Node 异常栈(启动前置检查)。 */
|
|
2271
2270
|
function fail(message) {
|
|
2272
2271
|
try {
|
|
2273
2272
|
writeSync(2, `difftab: ${message}\n`);
|
|
@@ -2281,7 +2280,7 @@ function fail(message) {
|
|
|
2281
2280
|
* 失败因此以一个 `'error'` 事件到达 —— 零监听器的流收到 `'error'` 就是整个进程带着
|
|
2282
2281
|
* 裸栈以 1 退出。已在 CI 的 windows × Node 24 档实测到:`head` 一退,紧接着那句
|
|
2283
2282
|
* 「read-only view of this repository」就把服务打死了,而 macOS / Linux 上同一条路
|
|
2284
|
-
*
|
|
2283
|
+
* 一声不响。
|
|
2285
2284
|
*
|
|
2286
2285
|
* 服务本身没坏 —— 浏览器照常用得上,45 秒没人连再自己退,所以这里只是把 EPIPE
|
|
2287
2286
|
* 咽掉。**非 EPIPE 的错误照旧抛**:那是真出事了,不该借这条路一起被吞掉。
|