channel-worker 2.5.138 → 2.5.139
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/cli.js +90 -1
- package/lib/command-poller.js +31 -3
- package/lib/daemon.js +199 -21
- package/lib/drain.js +93 -0
- package/lib/job-poller.js +13 -2
- package/lib/singleton.js +4 -0
- package/lib/updater.js +42 -11
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -285,6 +285,93 @@ if (cmd === 'pair') {
|
|
|
285
285
|
}
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
} else if (cmd === 'drain') {
|
|
289
|
+
// Tắt NHẸ: daemon ngừng nhận việc mới, chờ việc đang chạy xong rồi tự tắt.
|
|
290
|
+
// Dùng thay `taskkill /f` / `stop` khi phát hành — xem lib/drain.js (soát
|
|
291
|
+
// 23/09/2026: 9/9 phiên nuôi "Timed out" còn log là bị giết giữa phiên lúc
|
|
292
|
+
// thay bản). Mã thoát: 0 = daemon đã tắt sạch (hoặc không có daemon);
|
|
293
|
+
// 2 = hết hạn, daemon QUAY LẠI làm việc, người quyết định dừng cứng hay chờ;
|
|
294
|
+
// 3 = daemon cũ tắt nhưng đã có daemon MỚI giữ khoá (watchdog/updater bật lại).
|
|
295
|
+
const singleton = require('../lib/singleton');
|
|
296
|
+
const drain = require('../lib/drain');
|
|
297
|
+
const lock = singleton.readLock(CONFIG_DIR);
|
|
298
|
+
if (args.cancel) {
|
|
299
|
+
drain.clearRequest(CONFIG_DIR);
|
|
300
|
+
console.log('[channel-worker] Đã huỷ yêu cầu xả — daemon nhận việc lại ở nhịp kế (≤5s).');
|
|
301
|
+
process.exit(0);
|
|
302
|
+
}
|
|
303
|
+
if (!lock || singleton.isStale(lock)) {
|
|
304
|
+
drain.clearRequest(CONFIG_DIR);
|
|
305
|
+
console.log(`[channel-worker] Không có daemon nào đang sống ở ${CONFIG_DIR} — không có gì để xả.`);
|
|
306
|
+
process.exit(0);
|
|
307
|
+
}
|
|
308
|
+
const timeoutMin = Number(args.timeout || drain.DEFAULT_TIMEOUT_MIN);
|
|
309
|
+
if (!Number.isFinite(timeoutMin) || timeoutMin <= 0) {
|
|
310
|
+
console.error('[channel-worker] --timeout phải là số phút > 0');
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
const req = drain.writeRequest(CONFIG_DIR, { timeoutMin });
|
|
314
|
+
console.log(`[channel-worker] Đã gửi yêu cầu xả tới daemon PID ${lock.pid} (hạn ${req.deadline_at}).`);
|
|
315
|
+
if (process.platform === 'win32') {
|
|
316
|
+
console.log('[channel-worker] Nhớ: watchdog bật lại daemon sau ≤5 phút nếu chưa có C:\\agents\\watchdog.pause.');
|
|
317
|
+
}
|
|
318
|
+
if (lock.supervisor === 'pm2') {
|
|
319
|
+
console.log('[channel-worker] Daemon này chạy dưới pm2: xả xong nó tự tắt nhưng pm2 dựng lại ngay (lệnh sẽ trả mã 3). '
|
|
320
|
+
+ 'Trên máy pm2 cứ để bộ tự cập nhật lo — từ 2.5.139 nó tự xả trước khi cài.');
|
|
321
|
+
}
|
|
322
|
+
if (args['no-wait']) process.exit(0);
|
|
323
|
+
|
|
324
|
+
(async () => {
|
|
325
|
+
// Daemon soi yêu cầu mỗi 5s và tự quay lại làm việc khi hết hạn — chờ quá
|
|
326
|
+
// hạn thêm 30s là chắc chắn đã có kết cục.
|
|
327
|
+
const hardStop = Date.parse(req.deadline_at) + 30000;
|
|
328
|
+
let lastSay = 0;
|
|
329
|
+
let deadAt = 0;
|
|
330
|
+
for (;;) {
|
|
331
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
332
|
+
const cur = singleton.readLock(CONFIG_DIR);
|
|
333
|
+
const newHolder = cur && cur.pid !== lock.pid && !singleton.isStale(cur) ? cur : null;
|
|
334
|
+
if (!singleton.pidAlive(lock.pid)) {
|
|
335
|
+
// "Đã tắt" chỉ tin khi TIẾN TRÌNH chết thật — khoá nhả sớm hơn thế (tắt
|
|
336
|
+
// dở, hoặc bộ tự cập nhật nhả khoá rồi đẻ bản thay). Chết rồi thì soi
|
|
337
|
+
// thêm 2 nhịp: bản thay của updater giành khoá ngay sau đó, và báo
|
|
338
|
+
// "đã dừng" lúc ấy là để người phát hành cài đè lên cây một daemon MỚI
|
|
339
|
+
// đang nạp (đúng loại sự cố MODULE_NOT_FOUND 08/09).
|
|
340
|
+
if (newHolder) {
|
|
341
|
+
console.error(`[channel-worker] Daemon PID ${lock.pid} đã tắt, nhưng daemon MỚI PID ${newHolder.pid} đang giữ khoá `
|
|
342
|
+
+ `(${newHolder.supervisor === 'pm2' ? 'pm2 dựng lại' : 'watchdog hoặc bộ tự cập nhật vừa bật lại'}) — chưa phải trạng thái "đã dừng".`);
|
|
343
|
+
process.exit(3);
|
|
344
|
+
}
|
|
345
|
+
if (!deadAt) deadAt = Date.now();
|
|
346
|
+
if (Date.now() - deadAt >= 4000) {
|
|
347
|
+
console.log(`[channel-worker] Xả xong — daemon PID ${lock.pid} đã tự tắt, không cắt việc nào.`);
|
|
348
|
+
process.exit(0);
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (Date.now() > hardStop) {
|
|
353
|
+
console.error(`[channel-worker] Quá hạn mà daemon PID ${lock.pid} vẫn sống `
|
|
354
|
+
+ `(${cur && cur.pid === lock.pid ? 'vẫn giữ khoá — bản daemon cũ chưa biết lệnh drain?' : 'đã nhả khoá nhưng chưa thoát'}). `
|
|
355
|
+
+ 'Kiểm "channel-worker logs".');
|
|
356
|
+
process.exit(2);
|
|
357
|
+
}
|
|
358
|
+
// Khoá đã nhả / sang tay khác mà tiến trình cũ còn sống = đang tắt dở →
|
|
359
|
+
// chờ nó thoát hẳn.
|
|
360
|
+
if (!cur || cur.pid !== lock.pid) continue;
|
|
361
|
+
const status = drain.readStatus(CONFIG_DIR);
|
|
362
|
+
if (!drain.readRequest(CONFIG_DIR)) {
|
|
363
|
+
console.error('[channel-worker] Daemon đã THÔI XẢ và quay lại làm việc (hết hạn hoặc bị huỷ). '
|
|
364
|
+
+ `Việc còn dở lúc đó: ${drain.describeBusy(status && status.busy)}. `
|
|
365
|
+
+ 'Chờ thêm: chạy lại "channel-worker drain". Chấp nhận cắt: "channel-worker stop".');
|
|
366
|
+
process.exit(2);
|
|
367
|
+
}
|
|
368
|
+
if (Date.now() - lastSay >= 30000) {
|
|
369
|
+
lastSay = Date.now();
|
|
370
|
+
console.log(`[channel-worker] … đang chờ: ${status ? drain.describeBusy(status.busy) : 'daemon chưa nhận yêu cầu (nhịp 5s)'}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
})();
|
|
374
|
+
|
|
288
375
|
} else if (cmd === 'logs') {
|
|
289
376
|
const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
|
|
290
377
|
if (!fs.existsSync(LOG_FILE)) {
|
|
@@ -337,7 +424,9 @@ Commands:
|
|
|
337
424
|
pair Pair with dashboard using a one-time code (recommended)
|
|
338
425
|
init Configure worker manually
|
|
339
426
|
start Start the daemon in background
|
|
340
|
-
stop Stop the daemon
|
|
427
|
+
stop Stop the daemon (cắt ngang việc đang chạy)
|
|
428
|
+
drain Xả: ngừng nhận việc mới, chờ việc đang chạy xong rồi tự tắt
|
|
429
|
+
(--timeout <phút>, mặc định 25 · --no-wait · --cancel)
|
|
341
430
|
restart Restart the daemon
|
|
342
431
|
logs Show recent daemon logs (--lines <n>, default 50)
|
|
343
432
|
update Check and install updates manually
|
package/lib/command-poller.js
CHANGED
|
@@ -21,6 +21,17 @@ class CommandPoller {
|
|
|
21
21
|
this.timer = null;
|
|
22
22
|
this.pollIntervalMs = 3000;
|
|
23
23
|
this.nst = null;
|
|
24
|
+
// XẢ (lib/drain.js): tạm ngừng NHẬN việc mới, việc đang chạy vẫn chạy tiếp.
|
|
25
|
+
this._paused = false;
|
|
26
|
+
// Mọi lệnh đang xử lý, MỌI loại (không riêng _pw): id → type. poll() chạy
|
|
27
|
+
// theo setInterval, không chờ lệnh trước xong, nên một phiên nuôi 15 phút
|
|
28
|
+
// chạy song song với vài lượt lấy số liệu — daemon cần sổ này để biết lúc
|
|
29
|
+
// nào hết việc thật.
|
|
30
|
+
this._handling = new Map();
|
|
31
|
+
// Lượt poll đang bay (đã gửi GET /workers/commands, chưa về). API lật lệnh
|
|
32
|
+
// pending→running NGAY lúc trả về, nên một lượt đang bay lúc daemon quyết
|
|
33
|
+
// "rảnh rồi, tắt" sẽ mang về một lệnh đã bị nhận — tắt lúc đó là giết nó.
|
|
34
|
+
this._pollsInFlight = 0;
|
|
24
35
|
|
|
25
36
|
// Init Nstbrowser if API key available
|
|
26
37
|
if (config.nst_api_key) {
|
|
@@ -69,10 +80,25 @@ class CommandPoller {
|
|
|
69
80
|
if (this._profileTimeoutTimer) { clearInterval(this._profileTimeoutTimer); this._profileTimeoutTimer = null; }
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
// Không dừng timer: đồng bộ profile và đóng profile rảnh vẫn phải chạy trong
|
|
84
|
+
// lúc xả, chỉ riêng việc NHẬN lệnh / phát cảnh là ngừng.
|
|
85
|
+
pauseClaims() { this._paused = true; }
|
|
86
|
+
resumeClaims() { this._paused = false; }
|
|
87
|
+
|
|
72
88
|
async poll() {
|
|
89
|
+
if (this._paused) return;
|
|
90
|
+
let command = null;
|
|
91
|
+
this._pollsInFlight++;
|
|
73
92
|
try {
|
|
74
|
-
|
|
93
|
+
try {
|
|
94
|
+
command = await this.api.getNextCommand(this.config.worker_id);
|
|
95
|
+
} finally {
|
|
96
|
+
this._pollsInFlight--;
|
|
97
|
+
}
|
|
75
98
|
if (!command) return;
|
|
99
|
+
// Lượt poll đã gửi đi TRƯỚC lúc xả vẫn có thể nhận về một lệnh — nó được
|
|
100
|
+
// ghi sổ như mọi lệnh khác, và daemon chờ nó xong mới tắt.
|
|
101
|
+
this._handling.set(String(command._id), command.type);
|
|
76
102
|
|
|
77
103
|
console.log(`[commands] Received: ${command.type} (${command._id})`);
|
|
78
104
|
|
|
@@ -146,6 +172,8 @@ class CommandPoller {
|
|
|
146
172
|
if (this.config.verbose) {
|
|
147
173
|
console.error(`[commands] Poll error: ${err.message}`);
|
|
148
174
|
}
|
|
175
|
+
} finally {
|
|
176
|
+
if (command) this._handling.delete(String(command._id));
|
|
149
177
|
}
|
|
150
178
|
}
|
|
151
179
|
|
|
@@ -1860,7 +1888,7 @@ class CommandPoller {
|
|
|
1860
1888
|
// Processes ONE command per cycle — sequential, no race conditions.
|
|
1861
1889
|
|
|
1862
1890
|
async _dispatchScenes() {
|
|
1863
|
-
if (this._dispatching) return;
|
|
1891
|
+
if (this._paused || this._dispatching) return;
|
|
1864
1892
|
this._dispatching = true;
|
|
1865
1893
|
try {
|
|
1866
1894
|
// 1. Init Nstbrowser if needed
|
|
@@ -2270,7 +2298,7 @@ class CommandPoller {
|
|
|
2270
2298
|
// generate_thumbnail command is pending, so Veo3 renderers stay untouched.
|
|
2271
2299
|
|
|
2272
2300
|
async _dispatchChatgpt() {
|
|
2273
|
-
if (this._chatgptDispatching) return;
|
|
2301
|
+
if (this._paused || this._chatgptDispatching) return;
|
|
2274
2302
|
this._chatgptDispatching = true;
|
|
2275
2303
|
try {
|
|
2276
2304
|
// Lazy-init NstManager — the daemon config often ships with an empty
|
package/lib/daemon.js
CHANGED
|
@@ -7,9 +7,19 @@ const { checkAndUpdateExtension } = require('./extension-updater');
|
|
|
7
7
|
const { CacheServer } = require('./cache-server');
|
|
8
8
|
const { agentAddressForUser } = require('./nst-agent-locator');
|
|
9
9
|
const singleton = require('./singleton');
|
|
10
|
+
const drain = require('./drain');
|
|
10
11
|
const os = require('os');
|
|
11
12
|
const path = require('path');
|
|
12
13
|
|
|
14
|
+
// Bộ tự cập nhật thấy bản mới mà đang bận → xả tối đa ngần này rồi cài luôn.
|
|
15
|
+
// Quá hạn mà còn việc = việc dài bất thường; cài luôn còn hơn kẹt mãi bản cũ.
|
|
16
|
+
const UPDATE_DRAIN_MS = drain.DEFAULT_TIMEOUT_MIN * 60e3;
|
|
17
|
+
// Lượt cài sau khi xả mà hỏng (registry chưa công bố tarball, cây cài cụt…)
|
|
18
|
+
// thì chờ ngần này mới được xả lại để cập nhật. Không có nó, máy bận cứ quay
|
|
19
|
+
// vòng: xả → cài hỏng → nhận việc 10 giây → xả tiếp — tệ hơn bản trước, vốn
|
|
20
|
+
// cứ làm việc bình thường và thử lại 5 phút một lần.
|
|
21
|
+
const UPDATE_RETRY_MS = 15 * 60e3;
|
|
22
|
+
|
|
13
23
|
class Daemon {
|
|
14
24
|
constructor(config) {
|
|
15
25
|
this.config = config;
|
|
@@ -30,20 +40,45 @@ class Daemon {
|
|
|
30
40
|
// Pass daemon URL down so command-poller can write it into per-profile config
|
|
31
41
|
config.daemon_url = this.cacheServer.url();
|
|
32
42
|
this.commandPoller = new CommandPoller(this.api, config);
|
|
33
|
-
|
|
43
|
+
// Có bản mới mà đang bận thì KHÔNG khởi động lại ngang — xả trước (xem
|
|
44
|
+
// _tickDrain), rảnh rồi mới cài.
|
|
45
|
+
this.updateChecker = new UpdateChecker(5 * 60 * 1000, {
|
|
46
|
+
isBusy: () => this._isBusy(),
|
|
47
|
+
onDeferred: (r) => this._onUpdateDeferred(r),
|
|
48
|
+
});
|
|
49
|
+
this._updateRetryAt = 0;
|
|
34
50
|
this.extCheckTimer = null;
|
|
35
51
|
this.nstAgentTimer = null;
|
|
36
52
|
this.lockTimer = null;
|
|
53
|
+
this.drainTimer = null;
|
|
37
54
|
this.configDir = path.join(os.homedir(), '.channel-worker');
|
|
38
55
|
this._evictLogged = false;
|
|
56
|
+
// Yêu cầu xả ghi trước mốc này là của đời daemon trước — xem drain.isForMe.
|
|
57
|
+
this.startedAt = Date.now();
|
|
58
|
+
this._drain = null; // { reason: 'cli'|'update', since, deadline, latest }
|
|
59
|
+
this._drainFinishing = false;
|
|
39
60
|
}
|
|
40
61
|
|
|
41
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Đang bận = có job đang chạy, có phiên Playwright đang giữ profile, hoặc có
|
|
64
|
+
* lệnh nào đang xử lý dở (mọi loại — sổ _handling của command poller).
|
|
65
|
+
*/
|
|
42
66
|
_isBusy() {
|
|
67
|
+
const b = this._busyDetail();
|
|
68
|
+
return b.jobs > 0 || b.pw > 0 || b.commands.length > 0 || b.polls > 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_busyDetail() {
|
|
43
72
|
const jobs = (this.poller && this.poller.running) ? this.poller.running.size : 0;
|
|
44
73
|
const pw = (this.commandPoller && this.commandPoller._pwInFlight)
|
|
45
74
|
? this.commandPoller._pwInFlight.size : 0;
|
|
46
|
-
|
|
75
|
+
const commands = (this.commandPoller && this.commandPoller._handling)
|
|
76
|
+
? [...this.commandPoller._handling.values()] : [];
|
|
77
|
+
// Lượt hỏi việc đang bay: API nhận lệnh/job ngay lúc trả về, tắt lúc này là
|
|
78
|
+
// giết một việc vừa bị nhận (xem CommandPoller._pollsInFlight).
|
|
79
|
+
const polls = ((this.commandPoller && this.commandPoller._pollsInFlight) || 0)
|
|
80
|
+
+ ((this.poller && this.poller.pollsInFlight) || 0);
|
|
81
|
+
return { jobs, pw, commands, polls };
|
|
47
82
|
}
|
|
48
83
|
|
|
49
84
|
/**
|
|
@@ -76,6 +111,158 @@ class Daemon {
|
|
|
76
111
|
else process.exit(0);
|
|
77
112
|
}
|
|
78
113
|
|
|
114
|
+
// ─── XẢ: ngừng nhận việc mới, chờ việc đang chạy xong rồi mới tắt ──────────
|
|
115
|
+
// Hai đường vào:
|
|
116
|
+
// 'cli' — người phát hành chạy `channel-worker drain` (ghi drain.request).
|
|
117
|
+
// Xả xong thì TẮT. Hết hạn mà còn việc thì QUAY LẠI làm việc —
|
|
118
|
+
// người quyết định dừng cứng hay chờ thêm, đàn không bao giờ đứng
|
|
119
|
+
// im chỉ vì một lệnh xả bị bỏ quên.
|
|
120
|
+
// 'update' — bộ tự cập nhật thấy bản mới mà đang bận. Xả xong thì cài +
|
|
121
|
+
// khởi động lại. Hết hạn thì cài luôn (cắt việc dài bất thường).
|
|
122
|
+
// Trong lúc xả: heartbeat, nhịp khoá, đồng bộ/đóng profile rảnh vẫn chạy;
|
|
123
|
+
// chỉ ngừng nhận lệnh, nhận job, phát cảnh, và tắt bộ tự cập nhật (một lượt
|
|
124
|
+
// cài + khởi động lại lọt vào giữa là cắt đúng việc đang chờ).
|
|
125
|
+
_enterDrain({ reason, deadline, latest = null }) {
|
|
126
|
+
if (this._drain) return;
|
|
127
|
+
this._drain = { reason, since: Date.now(), deadline, latest };
|
|
128
|
+
if (this.commandPoller.pauseClaims) this.commandPoller.pauseClaims();
|
|
129
|
+
if (this.poller.pause) this.poller.pause();
|
|
130
|
+
this.updateChecker.stop();
|
|
131
|
+
const why = reason === 'update' ? `để cập nhật lên ${latest}` : 'theo lệnh "channel-worker drain"';
|
|
132
|
+
console.log(`[daemon] XẢ ${why}: ngừng nhận việc mới, chờ ${drain.describeBusy(this._busyDetail())} `
|
|
133
|
+
+ `— hạn ${new Date(deadline).toISOString()}`);
|
|
134
|
+
this._writeDrainStatus();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_resumeFromDrain(why) {
|
|
138
|
+
console.warn(`[daemon] Thôi xả (${why}) — nhận việc lại bình thường.`);
|
|
139
|
+
const wasCli = !!this._drain && this._drain.reason === 'cli';
|
|
140
|
+
this._drain = null;
|
|
141
|
+
// Chỉ dọn yêu cầu khi đang xả VÌ nó (hết hạn / huỷ) — đó là tín hiệu cho lệnh
|
|
142
|
+
// drain đang chờ. Thôi xả để cập nhật mà xoá luôn là nuốt mất lệnh drain
|
|
143
|
+
// người phát hành vừa gửi giữa lúc npm đang cài; để nguyên thì nhịp sau
|
|
144
|
+
// nhận nó và xả để TẮT.
|
|
145
|
+
if (wasCli) drain.clearRequest(this.configDir);
|
|
146
|
+
drain.clearStatus(this.configDir);
|
|
147
|
+
if (this.commandPoller.resumeClaims) this.commandPoller.resumeClaims();
|
|
148
|
+
if (this.poller.resume) this.poller.resume();
|
|
149
|
+
// Không bật lại lượt kiểm 10 giây đầu: vừa thôi xả vì cài hỏng mà kiểm ngay
|
|
150
|
+
// là quay lại đúng vòng xả-cài-hỏng.
|
|
151
|
+
this.updateChecker.start({ initialDelay: false });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_onUpdateDeferred(r, now = Date.now()) {
|
|
155
|
+
if (now < this._updateRetryAt) return; // vừa cài hỏng — đợi, đừng xả lại liên tục
|
|
156
|
+
this._enterDrain({ reason: 'update', deadline: now + UPDATE_DRAIN_MS, latest: r.latest });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_writeDrainStatus() {
|
|
160
|
+
if (!this._drain) return;
|
|
161
|
+
drain.writeStatus(this.configDir, {
|
|
162
|
+
pid: process.pid,
|
|
163
|
+
reason: this._drain.reason,
|
|
164
|
+
since: new Date(this._drain.since).toISOString(),
|
|
165
|
+
deadline: new Date(this._drain.deadline).toISOString(),
|
|
166
|
+
busy: this._busyDetail(),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async _tickDrain(now = Date.now()) {
|
|
171
|
+
if (this._drainFinishing) return;
|
|
172
|
+
// Lệnh drain là cho CHỦ KHOÁ. Daemon thừa (mất khoá) đã tự tắt lúc rảnh ở
|
|
173
|
+
// _tickLock; để nó nghe lệnh là nó xả xong trước rồi xoá mất yêu cầu, chủ
|
|
174
|
+
// khoá đang xả dở thấy "bị huỷ" và quay lại nhận việc.
|
|
175
|
+
if (!singleton.owns(this.configDir)) return;
|
|
176
|
+
const req = drain.readRequest(this.configDir);
|
|
177
|
+
if (req && !drain.isForMe(req, this.startedAt)) {
|
|
178
|
+
console.log(`[daemon] Bỏ yêu cầu xả cũ (${req.requested_at}) — của daemon đời trước.`);
|
|
179
|
+
drain.clearRequest(this.configDir);
|
|
180
|
+
} else if (req && (!this._drain || this._drain.reason !== 'cli')) {
|
|
181
|
+
// Người phát hành thắng bộ tự cập nhật: họ muốn daemon TẮT, không muốn
|
|
182
|
+
// nó tự cài rồi bật lại ngay dưới tay họ.
|
|
183
|
+
if (this._drain) {
|
|
184
|
+
this._drain.reason = 'cli';
|
|
185
|
+
this._drain.deadline = Date.parse(req.deadline_at);
|
|
186
|
+
console.log('[daemon] Lệnh drain đến giữa lúc xả để cập nhật — chuyển sang xả để TẮT.');
|
|
187
|
+
} else {
|
|
188
|
+
this._enterDrain({ reason: 'cli', deadline: Date.parse(req.deadline_at) });
|
|
189
|
+
}
|
|
190
|
+
} else if (!req && this._drain && this._drain.reason === 'cli') {
|
|
191
|
+
this._resumeFromDrain('yêu cầu xả đã bị huỷ');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!this._drain) return;
|
|
195
|
+
|
|
196
|
+
this._writeDrainStatus();
|
|
197
|
+
if (!this._isBusy()) {
|
|
198
|
+
await this._finishDrain();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (now > this._drain.deadline) {
|
|
202
|
+
const left = drain.describeBusy(this._busyDetail());
|
|
203
|
+
if (this._drain.reason === 'update') {
|
|
204
|
+
console.warn(`[daemon] Hết hạn xả mà vẫn còn ${left} — cài bản mới luôn, việc đó sẽ bị cắt.`);
|
|
205
|
+
await this._finishDrain();
|
|
206
|
+
} else {
|
|
207
|
+
this._resumeFromDrain(`hết hạn xả, vẫn còn ${left}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async _finishDrain() {
|
|
213
|
+
this._drainFinishing = true;
|
|
214
|
+
try {
|
|
215
|
+
if (this._drain.reason === 'update') {
|
|
216
|
+
console.log('[daemon] Xả xong — cài bản mới rồi khởi động lại.');
|
|
217
|
+
// Cài xong thì check() tự process.exit. Tới được dòng sau = KHÔNG khởi
|
|
218
|
+
// động lại (registry chưa công bố tarball, cây cài hỏng…) → làm tiếp.
|
|
219
|
+
const r = await this.updateChecker.check({ ignoreBusy: true });
|
|
220
|
+
this._updateRetryAt = Date.now() + UPDATE_RETRY_MS;
|
|
221
|
+
this._resumeFromDrain(`chưa cập nhật được${r && r.error ? `: ${r.error}` : ''}, `
|
|
222
|
+
+ `${UPDATE_RETRY_MS / 60e3} phút nữa mới xả lại để cập nhật`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
console.log('[daemon] Xả xong — không còn việc dở, tự tắt.');
|
|
226
|
+
// shutdown nhả khoá rồi mới dọn drain.request (xem ghi chú ở đó).
|
|
227
|
+
if (this._shutdown) await this._shutdown();
|
|
228
|
+
else process.exit(0);
|
|
229
|
+
} finally {
|
|
230
|
+
this._drainFinishing = false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Dừng mọi nhịp, nhả khoá, dọn file xả, báo offline — mọi thứ trừ process.exit. */
|
|
235
|
+
async _teardown() {
|
|
236
|
+
this.heartbeat.stop();
|
|
237
|
+
this.poller.stop();
|
|
238
|
+
this.commandPoller.stop();
|
|
239
|
+
this.updateChecker.stop();
|
|
240
|
+
if (this.extCheckTimer) clearInterval(this.extCheckTimer);
|
|
241
|
+
if (this.nstAgentTimer) clearInterval(this.nstAgentTimer);
|
|
242
|
+
if (this.lockTimer) clearInterval(this.lockTimer);
|
|
243
|
+
if (this.drainTimer) clearInterval(this.drainTimer);
|
|
244
|
+
try { this.cacheServer.stop(); } catch {}
|
|
245
|
+
// Nhả khoá để con kế tiếp vào ngay, khỏi phải chờ hết hạn STALE.
|
|
246
|
+
let wasOwner = false;
|
|
247
|
+
try { wasOwner = singleton.release(this.configDir); } catch {}
|
|
248
|
+
// Dọn file xả SAU khi nhả khoá: lệnh drain đang chờ thấy khoá nhả trước
|
|
249
|
+
// → "xả xong"; ngược thứ tự nó thấy yêu cầu biến mất khi khoá còn đó và
|
|
250
|
+
// báo nhầm "daemon thôi xả". Chỉ dọn khi CHÍNH mình là chủ khoá đang xả —
|
|
251
|
+
// daemon thừa tự tắt (kể cả con từng xả dở trước khi bị `start --force`
|
|
252
|
+
// cướp khoá) mà xoá thì yêu cầu gửi cho chủ khoá MỚI biến mất giữa chừng.
|
|
253
|
+
if (wasOwner && this._drain) {
|
|
254
|
+
drain.clearRequest(this.configDir);
|
|
255
|
+
drain.clearStatus(this.configDir);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Mark offline
|
|
259
|
+
try {
|
|
260
|
+
await this.api.request('PUT', `/workers/${this.config.worker_id}`, {
|
|
261
|
+
status: 'offline',
|
|
262
|
+
});
|
|
263
|
+
} catch { /* ignore */ }
|
|
264
|
+
}
|
|
265
|
+
|
|
79
266
|
/**
|
|
80
267
|
* Point NST_API_ADDRESS at the agent of the Windows user we're paired with.
|
|
81
268
|
*
|
|
@@ -195,6 +382,14 @@ class Daemon {
|
|
|
195
382
|
this.lockTimer = setInterval(() => { this._tickLock().catch(() => {}); }, singleton.REFRESH_MS);
|
|
196
383
|
console.log(`[daemon] Singleton lock watcher started (every ${singleton.REFRESH_MS / 1000}s)`);
|
|
197
384
|
|
|
385
|
+
// Nhịp xả — soi drain.request (lệnh "channel-worker drain") và theo dõi
|
|
386
|
+
// việc dở trong lúc xả. Trạng thái xả của đời trước là rác, dọn luôn.
|
|
387
|
+
drain.clearStatus(this.configDir);
|
|
388
|
+
this.drainTimer = setInterval(() => {
|
|
389
|
+
this._tickDrain().catch((e) => console.warn(`[daemon] nhịp xả lỗi: ${e.message}`));
|
|
390
|
+
}, drain.TICK_MS);
|
|
391
|
+
console.log(`[daemon] Drain watcher started (every ${drain.TICK_MS / 1000}s)`);
|
|
392
|
+
|
|
198
393
|
// Start auto-update checker
|
|
199
394
|
this.updateChecker.start();
|
|
200
395
|
console.log('[daemon] Auto-update checker started (every 5min)');
|
|
@@ -219,24 +414,7 @@ class Daemon {
|
|
|
219
414
|
// Graceful shutdown
|
|
220
415
|
const shutdown = async () => {
|
|
221
416
|
console.log('\n[daemon] Shutting down...');
|
|
222
|
-
this.
|
|
223
|
-
this.poller.stop();
|
|
224
|
-
this.commandPoller.stop();
|
|
225
|
-
this.updateChecker.stop();
|
|
226
|
-
if (this.extCheckTimer) clearInterval(this.extCheckTimer);
|
|
227
|
-
if (this.nstAgentTimer) clearInterval(this.nstAgentTimer);
|
|
228
|
-
if (this.lockTimer) clearInterval(this.lockTimer);
|
|
229
|
-
try { this.cacheServer.stop(); } catch {}
|
|
230
|
-
// Nhả khoá để con kế tiếp vào ngay, khỏi phải chờ hết hạn STALE.
|
|
231
|
-
try { singleton.release(this.configDir); } catch {}
|
|
232
|
-
|
|
233
|
-
// Mark offline
|
|
234
|
-
try {
|
|
235
|
-
await this.api.request('PUT', `/workers/${this.config.worker_id}`, {
|
|
236
|
-
status: 'offline',
|
|
237
|
-
});
|
|
238
|
-
} catch { /* ignore */ }
|
|
239
|
-
|
|
417
|
+
await this._teardown();
|
|
240
418
|
console.log('[daemon] Goodbye.');
|
|
241
419
|
process.exit(0);
|
|
242
420
|
};
|
package/lib/drain.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XẢ daemon: ngừng nhận việc mới, chờ việc đang chạy xong rồi mới tắt.
|
|
3
|
+
*
|
|
4
|
+
* Vì sao có (soát 23/09/2026): phát hành worker tắt daemon bằng
|
|
5
|
+
* `taskkill /f /im node.exe`, còn bộ tự cập nhật thấy bản mới là cài + khởi
|
|
6
|
+
* động lại ngay — cả hai đều không nhìn việc đang chạy. 9/9 phiên nuôi FB
|
|
7
|
+
* "Timed out … crash giữa chừng" còn log đều là daemon bị thay bằng bản mới
|
|
8
|
+
* GIỮA phiên (31 bản trong 10 ngày), và cùng cơ chế đã cắt ngang lượt đăng bài
|
|
9
|
+
* (kenh-71 lúc 22:29 ngày 23/09, đang ở khung soạn Reel).
|
|
10
|
+
*
|
|
11
|
+
* Giao tiếp qua FILE trong CONFIG_DIR, không qua tín hiệu: trên Windows
|
|
12
|
+
* `process.kill(pid, 'SIGTERM')` là TerminateProcess — không có cách nào nhờ
|
|
13
|
+
* tiến trình khác "tắt nhẹ nhàng". Người phát hành chạy `channel-worker drain`,
|
|
14
|
+
* lệnh đó ghi `drain.request`; daemon soi file mỗi TICK_MS.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const REQUEST_FILE = 'drain.request';
|
|
21
|
+
const STATUS_FILE = 'drain.status';
|
|
22
|
+
// Phiên nuôi dài nhất đo được 18 phút, lượt đăng FB có thể chờ nút Đăng 10 phút
|
|
23
|
+
// cộng các bước trước — 25 phút phủ cả hai mà không treo lượt phát hành cả buổi.
|
|
24
|
+
const DEFAULT_TIMEOUT_MIN = 25;
|
|
25
|
+
const TICK_MS = 5000;
|
|
26
|
+
|
|
27
|
+
const requestPath = (dir) => path.join(dir, REQUEST_FILE);
|
|
28
|
+
const statusPath = (dir) => path.join(dir, STATUS_FILE);
|
|
29
|
+
|
|
30
|
+
function writeRequest(dir, { timeoutMin = DEFAULT_TIMEOUT_MIN, now = Date.now() } = {}) {
|
|
31
|
+
const req = {
|
|
32
|
+
requested_at: new Date(now).toISOString(),
|
|
33
|
+
deadline_at: new Date(now + timeoutMin * 60e3).toISOString(),
|
|
34
|
+
by_pid: process.pid,
|
|
35
|
+
};
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
fs.writeFileSync(requestPath(dir), JSON.stringify(req));
|
|
38
|
+
return req;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readRequest(dir) {
|
|
42
|
+
try {
|
|
43
|
+
const req = JSON.parse(fs.readFileSync(requestPath(dir), 'utf-8'));
|
|
44
|
+
if (!req || !Number.isFinite(Date.parse(req.requested_at)) || !Number.isFinite(Date.parse(req.deadline_at))) return null;
|
|
45
|
+
return req;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Yêu cầu có phải cho daemon này không. Yêu cầu ghi TRƯỚC lúc daemon khởi
|
|
53
|
+
* động là rác của đời trước (daemon cũ chết trước khi kịp dọn) — nghe theo nó
|
|
54
|
+
* thì daemon mới vừa lên đã tự tắt, và watchdog bật lại vô tận.
|
|
55
|
+
*/
|
|
56
|
+
function isForMe(req, startedAt) {
|
|
57
|
+
return !!req && Date.parse(req.requested_at) >= startedAt;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function clearRequest(dir) {
|
|
61
|
+
try { fs.unlinkSync(requestPath(dir)); } catch { /* đã không có */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeStatus(dir, status) {
|
|
65
|
+
try {
|
|
66
|
+
fs.writeFileSync(statusPath(dir), JSON.stringify({ ...status, at: new Date().toISOString() }));
|
|
67
|
+
} catch { /* trạng thái chỉ để người xem, hỏng thì thôi */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readStatus(dir) {
|
|
71
|
+
try { return JSON.parse(fs.readFileSync(statusPath(dir), 'utf-8')); } catch { return null; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function clearStatus(dir) {
|
|
75
|
+
try { fs.unlinkSync(statusPath(dir)); } catch { /* đã không có */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** "2 lệnh (nurture_facebook_pw, upload_facebook_pw) · 1 job" — cho log và lệnh drain. */
|
|
79
|
+
function describeBusy(busy) {
|
|
80
|
+
if (!busy) return 'không rõ';
|
|
81
|
+
const parts = [];
|
|
82
|
+
const cmds = busy.commands || [];
|
|
83
|
+
if (cmds.length) parts.push(`${cmds.length} lệnh (${cmds.join(', ')})`);
|
|
84
|
+
if (busy.jobs) parts.push(`${busy.jobs} job`);
|
|
85
|
+
if (busy.polls) parts.push(`${busy.polls} lượt hỏi việc đang bay`);
|
|
86
|
+
return parts.length ? parts.join(' · ') : 'không còn việc';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
REQUEST_FILE, STATUS_FILE, DEFAULT_TIMEOUT_MIN, TICK_MS,
|
|
91
|
+
requestPath, statusPath, writeRequest, readRequest, isForMe, clearRequest,
|
|
92
|
+
writeStatus, readStatus, clearStatus, describeBusy,
|
|
93
|
+
};
|
package/lib/job-poller.js
CHANGED
|
@@ -8,8 +8,14 @@ class JobPoller {
|
|
|
8
8
|
this.running = new Set(); // job IDs currently executing
|
|
9
9
|
this.timer = null;
|
|
10
10
|
this.pollIntervalMs = 5000;
|
|
11
|
+
this.paused = false; // XẢ: ngừng nhận job mới, job đang chạy vẫn chạy tiếp
|
|
12
|
+
// Lượt hỏi/nhận job đang bay — xem CommandPoller._pollsInFlight.
|
|
13
|
+
this.pollsInFlight = 0;
|
|
11
14
|
}
|
|
12
15
|
|
|
16
|
+
pause() { this.paused = true; }
|
|
17
|
+
resume() { this.paused = false; }
|
|
18
|
+
|
|
13
19
|
start() {
|
|
14
20
|
console.log(`[poller] Started — max concurrent: ${this.config.max_concurrent}`);
|
|
15
21
|
this.poll(); // immediate first poll
|
|
@@ -28,8 +34,11 @@ class JobPoller {
|
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
async poll() {
|
|
31
|
-
if (this.availableSlots <= 0) return;
|
|
37
|
+
if (this.paused || this.availableSlots <= 0) return;
|
|
32
38
|
|
|
39
|
+
this.pollsInFlight++;
|
|
40
|
+
let inFlight = true;
|
|
41
|
+
const settle = () => { if (inFlight) { inFlight = false; this.pollsInFlight--; } };
|
|
33
42
|
try {
|
|
34
43
|
const job = await this.api.getNextJob(this.config.worker_id);
|
|
35
44
|
if (!job) return; // no jobs in queue
|
|
@@ -39,7 +48,7 @@ class JobPoller {
|
|
|
39
48
|
|
|
40
49
|
// Claim the job
|
|
41
50
|
await this.api.claimJob(jobId, this.config.worker_id);
|
|
42
|
-
this.running.add(jobId);
|
|
51
|
+
this.running.add(jobId); // từ đây `running` giữ nó trong sổ bận (finally trả lượt bay)
|
|
43
52
|
|
|
44
53
|
console.log(`[poller] Claimed job ${jobId} (${this.running.size}/${this.config.max_concurrent} slots used)`);
|
|
45
54
|
|
|
@@ -56,6 +65,8 @@ class JobPoller {
|
|
|
56
65
|
if (this.config.verbose) {
|
|
57
66
|
console.error(`[poller] Poll error: ${err.message}`);
|
|
58
67
|
}
|
|
68
|
+
} finally {
|
|
69
|
+
settle();
|
|
59
70
|
}
|
|
60
71
|
}
|
|
61
72
|
}
|
package/lib/singleton.js
CHANGED
|
@@ -70,6 +70,10 @@ function writeLock(configDir, info, { overwrite = false } = {}) {
|
|
|
70
70
|
version: info.version || '',
|
|
71
71
|
started_at: info.started_at || new Date().toISOString(),
|
|
72
72
|
refreshed_at: new Date().toISOString(),
|
|
73
|
+
// Ai dựng lại daemon khi nó tắt: 'pm2' thì `channel-worker drain` biết
|
|
74
|
+
// trước là xả xong pm2 sẽ bật lại ngay (relabs03). Làm tươi khoá chép
|
|
75
|
+
// nguyên giá trị cũ; lần ghi đầu đọc từ môi trường của chính daemon.
|
|
76
|
+
supervisor: info.supervisor !== undefined ? info.supervisor : (process.env.pm_id !== undefined ? 'pm2' : ''),
|
|
73
77
|
});
|
|
74
78
|
// 'wx' = tạo mới, thất bại nếu đã có → chính là phép thử nguyên tử.
|
|
75
79
|
const fd = fs.openSync(lockPath(configDir), overwrite ? 'w' : 'wx');
|
package/lib/updater.js
CHANGED
|
@@ -38,18 +38,26 @@ function restartBlocker() {
|
|
|
38
38
|
// Quyết định THUẦN của một lượt kiểm (test được, không đụng đĩa/mạng):
|
|
39
39
|
// 'none' — đang chạy bản mới nhất
|
|
40
40
|
// 'blocked' — có bản mới nhưng không khởi động lại được → KHÔNG cài
|
|
41
|
+
// 'deferred-busy'— có bản mới nhưng daemon đang có việc dở → CHƯA cài, CHƯA
|
|
42
|
+
// khởi động lại; daemon chuyển sang XẢ rồi quay lại (drain.js)
|
|
41
43
|
// 'restart-only' — bản mới đã có sẵn trên đĩa → chỉ kiểm + khởi động lại
|
|
42
44
|
// 'install' — cài rồi kiểm rồi khởi động lại
|
|
43
45
|
//
|
|
46
|
+
// Vì sao có 'deferred-busy' (soát 23/09/2026): khởi động lại = process.exit, và
|
|
47
|
+
// mọi việc đang chạy chết theo — 9/9 phiên nuôi FB "Timed out … crash giữa
|
|
48
|
+
// chừng" còn log là daemon bị thay bằng bản mới giữa phiên. Cài khi bận cũng
|
|
49
|
+
// không được: npm xoá cây đang chạy trước khi ghi cây mới.
|
|
50
|
+
//
|
|
44
51
|
// Vì sao phải có 'blocked' và 'restart-only' (sự cố 17-19/09/2026, win-worker):
|
|
45
52
|
// worker-1 được bật tay qua start-worker.bat nên restart luôn chết EBUSY. Bản cũ
|
|
46
53
|
// so bản ĐANG CHẠY (2.5.117) với registry (2.5.120) nên lượt nào cũng
|
|
47
54
|
// `npm i -g` lại từ đầu rồi mới vấp EBUSY: 413 lần cài đè trong ~34 giờ, lên
|
|
48
55
|
// đúng thư mục cài mà worker-2 đang chạy. Cài đè cây đang chạy chính là thứ đã
|
|
49
56
|
// giết worker Windows nhiều lần (lib/ bị xoá giữa chừng).
|
|
50
|
-
function planUpdate({ running, onDisk, latest, restartBlocked = '' }) {
|
|
57
|
+
function planUpdate({ running, onDisk, latest, restartBlocked = '', busy = false }) {
|
|
51
58
|
if (!latest || !isNewer(latest, running)) return 'none';
|
|
52
59
|
if (restartBlocked) return 'blocked';
|
|
60
|
+
if (busy) return 'deferred-busy';
|
|
53
61
|
if (onDisk && !isNewer(latest, onDisk)) return 'restart-only';
|
|
54
62
|
return 'install';
|
|
55
63
|
}
|
|
@@ -136,7 +144,7 @@ function verifyInstall() {
|
|
|
136
144
|
return { ok: out.status === 0, detail: detail.slice(-600) };
|
|
137
145
|
}
|
|
138
146
|
|
|
139
|
-
async function checkAndUpdate({ autoRestart = false } = {}) {
|
|
147
|
+
async function checkAndUpdate({ autoRestart = false, isBusy = null } = {}) {
|
|
140
148
|
const local = getLocalVersion();
|
|
141
149
|
const latest = await getLatestVersion();
|
|
142
150
|
|
|
@@ -148,7 +156,11 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
|
|
|
148
156
|
// update` gõ tay thì cứ cài như cũ.
|
|
149
157
|
const onDisk = getInstalledVersion();
|
|
150
158
|
const blocker = autoRestart ? restartBlocker() : '';
|
|
151
|
-
const
|
|
159
|
+
const busy = !!(autoRestart && typeof isBusy === 'function' && isBusy());
|
|
160
|
+
const plan = planUpdate({ running: local, onDisk, latest, restartBlocked: blocker, busy });
|
|
161
|
+
if (plan === 'deferred-busy') {
|
|
162
|
+
return { updated: false, local, latest, deferredBusy: true };
|
|
163
|
+
}
|
|
152
164
|
if (plan === 'blocked') {
|
|
153
165
|
if (Date.now() - lastBlockedWarnAt > BLOCKED_WARN_EVERY_MS) {
|
|
154
166
|
lastBlockedWarnAt = Date.now();
|
|
@@ -238,33 +250,52 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
|
|
|
238
250
|
}
|
|
239
251
|
|
|
240
252
|
class UpdateChecker {
|
|
241
|
-
|
|
253
|
+
/**
|
|
254
|
+
* @param {object} [opts]
|
|
255
|
+
* @param {() => boolean} [opts.isBusy] daemon còn việc dở không
|
|
256
|
+
* @param {(r: object) => void} [opts.onDeferred] có bản mới mà đang bận —
|
|
257
|
+
* daemon chuyển sang XẢ, rảnh rồi gọi lại check({ ignoreBusy: true })
|
|
258
|
+
* @param {Function} [opts.checkFn] thay checkAndUpdate (test)
|
|
259
|
+
*/
|
|
260
|
+
constructor(intervalMs = 5 * 60 * 1000, { isBusy = null, onDeferred = null, checkFn = checkAndUpdate } = {}) {
|
|
242
261
|
this.intervalMs = intervalMs;
|
|
243
262
|
this.timer = null;
|
|
263
|
+
this.startTimer = null;
|
|
264
|
+
this.isBusy = isBusy;
|
|
265
|
+
this.onDeferred = onDeferred;
|
|
266
|
+
this.checkFn = checkFn;
|
|
244
267
|
}
|
|
245
268
|
|
|
246
|
-
start() {
|
|
247
|
-
// Check on startup after 10s delay
|
|
248
|
-
|
|
269
|
+
start({ initialDelay = true } = {}) {
|
|
270
|
+
// Check on startup after 10s delay (bỏ qua khi daemon vừa thôi xả — xem
|
|
271
|
+
// Daemon._resumeFromDrain)
|
|
272
|
+
if (initialDelay) this.startTimer = setTimeout(() => this.check(), 10000);
|
|
249
273
|
// Then periodically
|
|
250
274
|
this.timer = setInterval(() => this.check(), this.intervalMs);
|
|
251
275
|
}
|
|
252
276
|
|
|
253
277
|
stop() {
|
|
278
|
+
// Dọn cả lượt kiểm 10s đầu: dừng ngay sau start() mà để nó chạy là một lượt
|
|
279
|
+
// cài + khởi động lại lọt ra giữa lúc daemon đang xả.
|
|
280
|
+
if (this.startTimer) { clearTimeout(this.startTimer); this.startTimer = null; }
|
|
254
281
|
if (this.timer) {
|
|
255
282
|
clearInterval(this.timer);
|
|
256
283
|
this.timer = null;
|
|
257
284
|
}
|
|
258
285
|
}
|
|
259
286
|
|
|
260
|
-
|
|
287
|
+
/** @param {{ignoreBusy?: boolean}} [opts] ignoreBusy = daemon đã xả xong (hoặc hết hạn xả) */
|
|
288
|
+
async check({ ignoreBusy = false } = {}) {
|
|
261
289
|
try {
|
|
262
|
-
const result = await
|
|
263
|
-
if (
|
|
264
|
-
|
|
290
|
+
const result = await this.checkFn({ autoRestart: true, isBusy: ignoreBusy ? null : this.isBusy });
|
|
291
|
+
if (result && result.deferredBusy) {
|
|
292
|
+
console.log(`[updater] Có bản ${result.latest} nhưng đang có việc dở — xả trước, rảnh rồi mới cài.`);
|
|
293
|
+
if (this.onDeferred) this.onDeferred(result);
|
|
265
294
|
}
|
|
295
|
+
return result;
|
|
266
296
|
} catch (err) {
|
|
267
297
|
console.warn(`[updater] Check failed: ${err.message}`);
|
|
298
|
+
return { updated: false, error: err.message };
|
|
268
299
|
}
|
|
269
300
|
}
|
|
270
301
|
}
|