@wanghaopeng1148/deskpet 2.0.5 → 2.0.6
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/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/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'
|