@wanghaopeng1148/deskpet 2.0.5 → 2.0.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/bin/deskpet.mjs +32 -8
- package/dist/node/server/main.js +34 -23
- package/dist/node/server/utils/instance-guard.js +17 -8
- package/package.json +1 -1
- package/server/main.ts +39 -25
- package/server/utils/instance-guard.ts +17 -8
package/bin/deskpet.mjs
CHANGED
|
@@ -52,13 +52,24 @@ function newestMtime(dir) {
|
|
|
52
52
|
*
|
|
53
53
|
* 为什么需要:bin 优先跑编译产物,若改了 `server/` 却没重新编译,
|
|
54
54
|
* 就会**静默跑旧代码**(开机自启走的正是这条路径,最不容易发现)。
|
|
55
|
-
*
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ 必须留容差,不能用严格比较。踩过的坑:从 npm 安装时包内所有文件是
|
|
57
|
+
* **同一瞬间解包的**,实测彼此只差几百毫秒、且先后顺序随机 ——
|
|
58
|
+
* `dist/node/server/main.js` 是 22.106,`server/http/ws-hub.ts` 是 22.327,
|
|
59
|
+
* 于是严格 `>` 判定「源码更新」,选了 .ts,在 node_modules 下直接崩
|
|
60
|
+
* (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING)。而且哪次中招纯看解包顺序,
|
|
61
|
+
* 同一版本可能这次好下次坏 —— 极难排查。
|
|
62
|
+
*
|
|
63
|
+
* 真实的手改代码与上次编译之间至少相隔数秒,所以留 3 秒容差:
|
|
64
|
+
* 安装形态下的毫秒级乱序被忽略,真实改动照样能识别。
|
|
56
65
|
*/
|
|
66
|
+
const STALE_TOLERANCE_MS = 3000
|
|
67
|
+
|
|
57
68
|
function sourceIsNewer() {
|
|
58
69
|
try {
|
|
59
70
|
const compiledTime = statSync(compiledEntry).mtimeMs
|
|
60
71
|
for (const dir of ['server', 'shared']) {
|
|
61
|
-
if (newestMtime(join(pkgRoot, dir)) > compiledTime) return true
|
|
72
|
+
if (newestMtime(join(pkgRoot, dir)) > compiledTime + STALE_TOLERANCE_MS) return true
|
|
62
73
|
}
|
|
63
74
|
} catch {
|
|
64
75
|
/* 读不到就按「不更新」处理,退回产物 */
|
|
@@ -66,15 +77,26 @@ function sourceIsNewer() {
|
|
|
66
77
|
return false
|
|
67
78
|
}
|
|
68
79
|
|
|
80
|
+
/** 路径是否位于 node_modules 之下 */
|
|
81
|
+
function underNodeModules(p) {
|
|
82
|
+
return /[\\/]node_modules[\\/]/i.test(p)
|
|
83
|
+
}
|
|
84
|
+
|
|
69
85
|
/** 返回 { entry, args }:编译产物无需额外参数 */
|
|
70
86
|
function resolveServerEntry() {
|
|
71
87
|
const hasCompiled = existsSync(compiledEntry)
|
|
72
88
|
const hasSource = existsSync(sourceEntry)
|
|
89
|
+
/**
|
|
90
|
+
* 硬约束:node_modules 下的 .ts **根本跑不起来**(Node 禁止对 node_modules
|
|
91
|
+
* 内的文件做类型擦除)。所以无论新旧判断给出了什么结论,这类入口一律不能用 ——
|
|
92
|
+
* 这是比「新旧」更高优先级的规则,也是上面那个坑的兜底。
|
|
93
|
+
*/
|
|
94
|
+
const sourceUsable = hasSource && !underNodeModules(sourceEntry)
|
|
73
95
|
|
|
74
|
-
if (hasCompiled && !(
|
|
96
|
+
if (hasCompiled && !(sourceUsable && sourceIsNewer())) {
|
|
75
97
|
return { entry: compiledEntry, args: [], compiled: true, stale: false }
|
|
76
98
|
}
|
|
77
|
-
if (
|
|
99
|
+
if (sourceUsable) {
|
|
78
100
|
return {
|
|
79
101
|
entry: sourceEntry,
|
|
80
102
|
args: ['--experimental-strip-types'],
|
|
@@ -83,10 +105,12 @@ function resolveServerEntry() {
|
|
|
83
105
|
stale: hasCompiled
|
|
84
106
|
}
|
|
85
107
|
}
|
|
86
|
-
|
|
87
|
-
console.error(
|
|
88
|
-
console.error(
|
|
89
|
-
console.error('
|
|
108
|
+
// 走到这里只有一种情况:没有编译产物,而 .ts 又位于 node_modules 下不能运行
|
|
109
|
+
console.error('[deskpet] 无法启动:包里没有后端编译产物 dist/node,')
|
|
110
|
+
console.error(' 而 server/main.ts 位于 node_modules 下,不能直接运行')
|
|
111
|
+
console.error(' (Node 禁止对 node_modules 内的文件做类型擦除)')
|
|
112
|
+
console.error(` 期望的产物:${compiledEntry}`)
|
|
113
|
+
console.error(' 处理:重装该包(建议先清缓存),或从源码目录执行 npm start')
|
|
90
114
|
process.exit(1)
|
|
91
115
|
}
|
|
92
116
|
|
package/dist/node/server/main.js
CHANGED
|
@@ -171,11 +171,38 @@ async function main() {
|
|
|
171
171
|
// 监听地址允许用环境变量临时覆盖。
|
|
172
172
|
// 为什么需要:dev-all.mjs 会读 DESKPET_PORT 去等端口,而后端原先只认 settings.json,
|
|
173
173
|
// 两边取值来源不一致 —— 一旦设了环境变量,就成了「后端其实起来了,启动脚本却一直等不到」。
|
|
174
|
-
// 顺带也让同机并行跑第二个实例(调试用)成为可能。
|
|
175
174
|
const envPort = Number(process.env.DESKPET_PORT);
|
|
176
175
|
const serverHost = process.env.DESKPET_HOST?.trim() || config.get('server.host');
|
|
177
176
|
const serverPort = Number.isInteger(envPort) && envPort > 0 ? envPort : config.get('server.port');
|
|
178
|
-
|
|
177
|
+
// ── 单实例:拿不到锁就**拒绝启动** ─────────────────────────
|
|
178
|
+
// 需求是「这台机器上只允许跑一个 DeskPet」。此处必须早于 listen 和任何写库动作:
|
|
179
|
+
// · 同一端口的重复启动,listen 那一步本来就会 EADDRINUSE(实测 Windows 同样如此),
|
|
180
|
+
// 但那条错误信息只说「端口被占用」,容易被当成别的程序占着;
|
|
181
|
+
// · 真正需要这道锁兜的是**换了端口**的第二次启动 —— 端口不冲突,
|
|
182
|
+
// 它会一路跑到底并注册一遍定时任务,同一条 cron 就被执行两次(部署类脚本尤其危险)。
|
|
183
|
+
// 所以这里直接拒绝,而不是「起来了但不抢任务」。
|
|
184
|
+
const instanceLock = acquireInstanceLock();
|
|
185
|
+
if (!instanceLock.acquired) {
|
|
186
|
+
// 用**配置文件**里的端口报地址:本次进程可能带着 --port 覆盖,那个端口属于它自己,
|
|
187
|
+
// 而正在运行的那一个用的通常是配置端口
|
|
188
|
+
const runningUrl = dashboardUrl(config.get('server.host'), config.get('server.port'));
|
|
189
|
+
console.error('[deskpet] 已经有另一个 DeskPet 实例在运行,本次不再启动。');
|
|
190
|
+
console.error(` 数据目录:${getDataRoot()}`);
|
|
191
|
+
console.error(` 那个实例的管理台:${runningUrl}`);
|
|
192
|
+
console.error(' (它若启动时用 --port 指定过端口,以它自己的为准)');
|
|
193
|
+
console.error(' 想停掉它:结束它的进程,或删掉启动文件夹里的 DeskPet.vbs 后重新登录');
|
|
194
|
+
console.error(' 确实要并行跑第二个(例如调试):换个数据目录即可 ——' +
|
|
195
|
+
' DESKPET_HOME=<另一个目录> npx deskpet --port 3400');
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
let httpServer;
|
|
199
|
+
try {
|
|
200
|
+
httpServer = await listen(expressApp, serverPort, serverHost);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
instanceLock.release();
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
179
206
|
wsHub.attach(httpServer);
|
|
180
207
|
const dashUrl = dashboardUrl(serverHost, serverPort);
|
|
181
208
|
console.log(`[deskpet] 管理台: ${dashUrl}`);
|
|
@@ -197,28 +224,12 @@ async function main() {
|
|
|
197
224
|
// 复位,并给未结束的执行记录收尾。这一步是「有罪推定」——它假定
|
|
198
225
|
// 「库里凡是 running 的任务,都是上个进程挂掉时留下的」。
|
|
199
226
|
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
// 因此必须等 listen() 成功之后再启动调度器。
|
|
206
|
-
//
|
|
207
|
-
// 但光靠端口还不够:Windows 上 libuv 默认给监听套接字设 SO_REUSEADDR,
|
|
208
|
-
// 允许多个进程同时绑定同一端口且都 listen 成功,EADDRINUSE 基本不会触发。
|
|
209
|
-
// 所以这里再用心跳文件判一次「是否已有实例在跑」,有的话只跳过恢复动作、
|
|
210
|
-
// 不中断对方(见 utils/instance-guard.ts)。
|
|
211
|
-
// 单实例判定改用**原子锁**(O_EXCL 一步完成「检查 + 创建」),
|
|
212
|
-
// 取代原先心跳那种「读 → 判 → 写」三步:后者在两个实例几乎同时启动时,
|
|
213
|
-
// 双方都可能读到「没有心跳 / 心跳已过期」,于是双双认定「我是唯一实例」,
|
|
214
|
-
// 双双执行 recoverInterrupted(),把对方正在跑的任务掐成「服务重启,执行被中断」。
|
|
215
|
-
const instanceLock = acquireInstanceLock();
|
|
216
|
-
if (!instanceLock.acquired) {
|
|
217
|
-
console.warn('[deskpet] 检测到已有 DeskPet 实例在运行:本次跳过「残留任务恢复」,' +
|
|
218
|
-
'不会中断对方正在执行的任务');
|
|
219
|
-
}
|
|
227
|
+
// 所以顺序很要紧:单实例锁已在上面拒绝掉「已有实例在跑」的情况,listen 又拒绝了
|
|
228
|
+
// 端口被占用的情况(包括被别的程序占用)。走到这里说明本进程确实是唯一的那个,
|
|
229
|
+
// 做残留恢复不会误伤 —— 但仍保持「listen 成功之后才启动调度器」的顺序,
|
|
230
|
+
// 免得端口冲突时白白把库里 running 的记录误收尾一遍。
|
|
220
231
|
startHeartbeat();
|
|
221
|
-
scheduler.start({ recover:
|
|
232
|
+
scheduler.start({ recover: true });
|
|
222
233
|
// 开机自启:启动时应用 + 跟随设置变化
|
|
223
234
|
// removeWhenOff: false —— 启动时的同步只「确保开启时文件存在」,不删除。
|
|
224
235
|
// 否则一个用默认配置(autoStart=false)起来的实例(例如临时跑一次 npx,
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 单实例保护 —— 判定「是否已有另一个 DeskPet 实例正在运行」。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* 为什么在端口检测之外还要做单实例判定:
|
|
5
|
+
* listen 遇到端口被占用会 EADDRINUSE 并退出 —— 实测(Node 22/24、本机 Windows 验证)
|
|
6
|
+
* 第二个进程绑同一端口**同样**报 EADDRINUSE,不存在「双双监听成功」这回事,
|
|
7
|
+
* 所以「同端口重复启动」端口检测已经拦得住。
|
|
8
|
+
*
|
|
9
|
+
* 它拦不住的是另外两种情形:
|
|
10
|
+
* · 换了端口起第二个实例(例如 --port 3400),端口不冲突;
|
|
11
|
+
* · 极小概率的时序竞争,或端口检测因别的错误被跳过。
|
|
12
|
+
* 这两种情况下若两个实例都执行 recoverInterrupted(),就会把对方正在执行的任务掐成中断。
|
|
13
|
+
* 因此再加一道按**数据目录**的原子锁兜底 —— 注意两者作用域不同:
|
|
14
|
+
* 端口是全局的,锁是跟着 DESKPET_HOME 走的(不同数据目录互不影响)。
|
|
9
15
|
*
|
|
10
16
|
* 为什么这件事很要紧:
|
|
11
17
|
* 每个实例启动都会执行 TaskScheduler 的残留恢复(recoverInterrupted)——
|
|
@@ -14,9 +20,12 @@
|
|
|
14
20
|
* 新实例这一下就会把对方正在执行的任务掐掉,用户看到的现象是
|
|
15
21
|
* 「刚点运行不到 1 秒就报:服务重启,执行被中断」,且任务日志一个字节都没产生。
|
|
16
22
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
23
|
+
* 判定方式有两层:
|
|
24
|
+
* ① 原子锁(主):O_EXCL 一步完成「检查 + 创建」,按数据目录独占,见 acquireInstanceLock()。
|
|
25
|
+
* 判断锁属主是否还活着,读的是**锁文件里的 pid**(不是心跳)
|
|
26
|
+
* ② 心跳文件(辅):实例定期写 pid + 时间戳,用于诊断「现在是谁在跑」,
|
|
27
|
+
* 并由 anotherInstanceAlive() 提供「心跳是否新鲜 + 进程是否存活」的判断
|
|
28
|
+
* (该函数目前主要被单测覆盖;启动路径已改用原子锁)
|
|
20
29
|
*/
|
|
21
30
|
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
31
|
import { join } from 'node:path';
|
package/package.json
CHANGED
package/server/main.ts
CHANGED
|
@@ -182,13 +182,45 @@ async function main(): Promise<void> {
|
|
|
182
182
|
// 监听地址允许用环境变量临时覆盖。
|
|
183
183
|
// 为什么需要:dev-all.mjs 会读 DESKPET_PORT 去等端口,而后端原先只认 settings.json,
|
|
184
184
|
// 两边取值来源不一致 —— 一旦设了环境变量,就成了「后端其实起来了,启动脚本却一直等不到」。
|
|
185
|
-
// 顺带也让同机并行跑第二个实例(调试用)成为可能。
|
|
186
185
|
const envPort = Number(process.env.DESKPET_PORT)
|
|
187
186
|
const serverHost = process.env.DESKPET_HOST?.trim() || (config.get('server.host') as string)
|
|
188
187
|
const serverPort =
|
|
189
188
|
Number.isInteger(envPort) && envPort > 0 ? envPort : (config.get('server.port') as number)
|
|
190
189
|
|
|
191
|
-
|
|
190
|
+
// ── 单实例:拿不到锁就**拒绝启动** ─────────────────────────
|
|
191
|
+
// 需求是「这台机器上只允许跑一个 DeskPet」。此处必须早于 listen 和任何写库动作:
|
|
192
|
+
// · 同一端口的重复启动,listen 那一步本来就会 EADDRINUSE(实测 Windows 同样如此),
|
|
193
|
+
// 但那条错误信息只说「端口被占用」,容易被当成别的程序占着;
|
|
194
|
+
// · 真正需要这道锁兜的是**换了端口**的第二次启动 —— 端口不冲突,
|
|
195
|
+
// 它会一路跑到底并注册一遍定时任务,同一条 cron 就被执行两次(部署类脚本尤其危险)。
|
|
196
|
+
// 所以这里直接拒绝,而不是「起来了但不抢任务」。
|
|
197
|
+
const instanceLock = acquireInstanceLock()
|
|
198
|
+
if (!instanceLock.acquired) {
|
|
199
|
+
// 用**配置文件**里的端口报地址:本次进程可能带着 --port 覆盖,那个端口属于它自己,
|
|
200
|
+
// 而正在运行的那一个用的通常是配置端口
|
|
201
|
+
const runningUrl = dashboardUrl(
|
|
202
|
+
config.get('server.host') as string,
|
|
203
|
+
config.get('server.port') as number
|
|
204
|
+
)
|
|
205
|
+
console.error('[deskpet] 已经有另一个 DeskPet 实例在运行,本次不再启动。')
|
|
206
|
+
console.error(` 数据目录:${getDataRoot()}`)
|
|
207
|
+
console.error(` 那个实例的管理台:${runningUrl}`)
|
|
208
|
+
console.error(' (它若启动时用 --port 指定过端口,以它自己的为准)')
|
|
209
|
+
console.error(' 想停掉它:结束它的进程,或删掉启动文件夹里的 DeskPet.vbs 后重新登录')
|
|
210
|
+
console.error(
|
|
211
|
+
' 确实要并行跑第二个(例如调试):换个数据目录即可 ——' +
|
|
212
|
+
' DESKPET_HOME=<另一个目录> npx deskpet --port 3400'
|
|
213
|
+
)
|
|
214
|
+
process.exit(1)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let httpServer: Server
|
|
218
|
+
try {
|
|
219
|
+
httpServer = await listen(expressApp, serverPort, serverHost)
|
|
220
|
+
} catch (err) {
|
|
221
|
+
instanceLock.release()
|
|
222
|
+
throw err
|
|
223
|
+
}
|
|
192
224
|
wsHub.attach(httpServer)
|
|
193
225
|
|
|
194
226
|
const dashUrl = dashboardUrl(serverHost, serverPort)
|
|
@@ -213,30 +245,12 @@ async function main(): Promise<void> {
|
|
|
213
245
|
// 复位,并给未结束的执行记录收尾。这一步是「有罪推定」——它假定
|
|
214
246
|
// 「库里凡是 running 的任务,都是上个进程挂掉时留下的」。
|
|
215
247
|
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
// 因此必须等 listen() 成功之后再启动调度器。
|
|
222
|
-
//
|
|
223
|
-
// 但光靠端口还不够:Windows 上 libuv 默认给监听套接字设 SO_REUSEADDR,
|
|
224
|
-
// 允许多个进程同时绑定同一端口且都 listen 成功,EADDRINUSE 基本不会触发。
|
|
225
|
-
// 所以这里再用心跳文件判一次「是否已有实例在跑」,有的话只跳过恢复动作、
|
|
226
|
-
// 不中断对方(见 utils/instance-guard.ts)。
|
|
227
|
-
// 单实例判定改用**原子锁**(O_EXCL 一步完成「检查 + 创建」),
|
|
228
|
-
// 取代原先心跳那种「读 → 判 → 写」三步:后者在两个实例几乎同时启动时,
|
|
229
|
-
// 双方都可能读到「没有心跳 / 心跳已过期」,于是双双认定「我是唯一实例」,
|
|
230
|
-
// 双双执行 recoverInterrupted(),把对方正在跑的任务掐成「服务重启,执行被中断」。
|
|
231
|
-
const instanceLock = acquireInstanceLock()
|
|
232
|
-
if (!instanceLock.acquired) {
|
|
233
|
-
console.warn(
|
|
234
|
-
'[deskpet] 检测到已有 DeskPet 实例在运行:本次跳过「残留任务恢复」,' +
|
|
235
|
-
'不会中断对方正在执行的任务'
|
|
236
|
-
)
|
|
237
|
-
}
|
|
248
|
+
// 所以顺序很要紧:单实例锁已在上面拒绝掉「已有实例在跑」的情况,listen 又拒绝了
|
|
249
|
+
// 端口被占用的情况(包括被别的程序占用)。走到这里说明本进程确实是唯一的那个,
|
|
250
|
+
// 做残留恢复不会误伤 —— 但仍保持「listen 成功之后才启动调度器」的顺序,
|
|
251
|
+
// 免得端口冲突时白白把库里 running 的记录误收尾一遍。
|
|
238
252
|
startHeartbeat()
|
|
239
|
-
scheduler.start({ recover:
|
|
253
|
+
scheduler.start({ recover: true })
|
|
240
254
|
|
|
241
255
|
// 开机自启:启动时应用 + 跟随设置变化
|
|
242
256
|
// removeWhenOff: false —— 启动时的同步只「确保开启时文件存在」,不删除。
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 单实例保护 —— 判定「是否已有另一个 DeskPet 实例正在运行」。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* 为什么在端口检测之外还要做单实例判定:
|
|
5
|
+
* listen 遇到端口被占用会 EADDRINUSE 并退出 —— 实测(Node 22/24、本机 Windows 验证)
|
|
6
|
+
* 第二个进程绑同一端口**同样**报 EADDRINUSE,不存在「双双监听成功」这回事,
|
|
7
|
+
* 所以「同端口重复启动」端口检测已经拦得住。
|
|
8
|
+
*
|
|
9
|
+
* 它拦不住的是另外两种情形:
|
|
10
|
+
* · 换了端口起第二个实例(例如 --port 3400),端口不冲突;
|
|
11
|
+
* · 极小概率的时序竞争,或端口检测因别的错误被跳过。
|
|
12
|
+
* 这两种情况下若两个实例都执行 recoverInterrupted(),就会把对方正在执行的任务掐成中断。
|
|
13
|
+
* 因此再加一道按**数据目录**的原子锁兜底 —— 注意两者作用域不同:
|
|
14
|
+
* 端口是全局的,锁是跟着 DESKPET_HOME 走的(不同数据目录互不影响)。
|
|
9
15
|
*
|
|
10
16
|
* 为什么这件事很要紧:
|
|
11
17
|
* 每个实例启动都会执行 TaskScheduler 的残留恢复(recoverInterrupted)——
|
|
@@ -14,9 +20,12 @@
|
|
|
14
20
|
* 新实例这一下就会把对方正在执行的任务掐掉,用户看到的现象是
|
|
15
21
|
* 「刚点运行不到 1 秒就报:服务重启,执行被中断」,且任务日志一个字节都没产生。
|
|
16
22
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
23
|
+
* 判定方式有两层:
|
|
24
|
+
* ① 原子锁(主):O_EXCL 一步完成「检查 + 创建」,按数据目录独占,见 acquireInstanceLock()。
|
|
25
|
+
* 判断锁属主是否还活着,读的是**锁文件里的 pid**(不是心跳)
|
|
26
|
+
* ② 心跳文件(辅):实例定期写 pid + 时间戳,用于诊断「现在是谁在跑」,
|
|
27
|
+
* 并由 anotherInstanceAlive() 提供「心跳是否新鲜 + 进程是否存活」的判断
|
|
28
|
+
* (该函数目前主要被单测覆盖;启动路径已改用原子锁)
|
|
20
29
|
*/
|
|
21
30
|
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
22
31
|
import { join } from 'node:path'
|