channel-worker 2.5.72 → 2.5.74

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 CHANGED
@@ -136,21 +136,55 @@ if (cmd === 'pair') {
136
136
  // Save merged config for next time
137
137
  saveConfig(config);
138
138
 
139
+ const singleton = require('../lib/singleton');
140
+
139
141
  if (args._daemon) {
140
- // Actually run the daemon (spawned by ourselves)
141
- const { Daemon } = require('../lib/daemon');
142
- const daemon = new Daemon(config);
143
- daemon.start();
142
+ // Actually run the daemon (spawned by ourselves). MỘT CONFIG_DIR = MỘT
143
+ // daemon: giành khoá trước, không giành được thì thoát chứ tuyệt đối không
144
+ // chạy chồng (xem lib/singleton.js — sự cố 3 daemon relabs03 04/09/2026).
145
+ (async () => {
146
+ const res = await singleton.acquire(CONFIG_DIR, {
147
+ // Chờ để nhường nhịp BÀN GIAO: con cũ nhả khoá rồi mới tắt hẳn.
148
+ waitMs: args.force ? 0 : 30000,
149
+ force: !!args.force,
150
+ info: { worker_id: config.worker_id, version: require('../package.json').version },
151
+ });
152
+ if (!res.ok) {
153
+ console.error(`[channel-worker] Đã có daemon khác giữ ${CONFIG_DIR} `
154
+ + `(PID ${res.holder.pid}, chạy từ ${res.holder.started_at}) — thoát để không chạy trùng. `
155
+ + 'Muốn thay: "channel-worker restart", hoặc "start --force".');
156
+ process.exit(0);
157
+ }
158
+ if (res.stolen) {
159
+ console.log(`[channel-worker] Tiếp quản khoá của PID ${res.stolen.pid} `
160
+ + `(${args.force ? 'ép bằng --force' : 'đã chết hoặc treo quá lâu'}).`);
161
+ }
162
+ // Ghi ĐÚNG pid của daemon thật — updater/restart trước đây ghi pid của
163
+ // tiến trình bọc, nên "stop" bắn trượt và để lại mồ côi.
164
+ fs.writeFileSync(path.join(CONFIG_DIR, 'daemon.pid'), String(process.pid));
165
+
166
+ const { Daemon } = require('../lib/daemon');
167
+ const daemon = new Daemon(config);
168
+ daemon.start();
169
+ })();
144
170
  } else {
145
171
  // Spawn detached background process and exit
172
+ const existing = singleton.readLock(CONFIG_DIR);
173
+ if (existing && !singleton.isStale(existing) && !args.force) {
174
+ console.error(`[channel-worker] Daemon đang chạy rồi (PID ${existing.pid}, từ ${existing.started_at}). `
175
+ + 'Dùng "channel-worker restart", hoặc thêm --force nếu thật sự muốn thêm một con nữa.');
176
+ process.exit(1);
177
+ }
178
+
146
179
  const { spawn } = require('child_process');
147
180
  const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
148
181
  const logFd = fs.openSync(LOG_FILE, 'a');
149
182
 
150
- const child = spawn(process.execPath, [__filename, 'start', '--_daemon'], {
183
+ const child = spawn(process.execPath, [__filename, 'start', '--_daemon', ...(args.force ? ['--force'] : [])], {
151
184
  detached: true,
152
185
  stdio: ['ignore', logFd, logFd],
153
186
  cwd: os.homedir(),
187
+ windowsHide: true,
154
188
  });
155
189
  child.unref();
156
190
 
@@ -182,19 +216,28 @@ if (cmd === 'pair') {
182
216
  })();
183
217
 
184
218
  } else if (cmd === 'stop') {
219
+ const singleton = require('../lib/singleton');
185
220
  const pidFile = path.join(CONFIG_DIR, 'daemon.pid');
186
- if (!fs.existsSync(pidFile)) {
187
- console.log('[channel-worker] No daemon running (no PID file).');
221
+ // Khoá là nguồn đáng tin hơn daemon.pid: pid file từng bị ghi đè bằng pid của
222
+ // tiến trình bọc (updater/restart cũ), bắn SIGTERM vào đó là trượt.
223
+ const lock = singleton.readLock(CONFIG_DIR);
224
+ const pid = (lock && lock.pid)
225
+ || (fs.existsSync(pidFile) ? parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10) : NaN);
226
+ if (!Number.isInteger(pid)) {
227
+ console.log('[channel-worker] No daemon running (no lock, no PID file).');
188
228
  process.exit(0);
189
229
  }
190
- const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
230
+ const cleanup = () => {
231
+ try { fs.unlinkSync(pidFile); } catch {}
232
+ try { fs.unlinkSync(singleton.lockPath(CONFIG_DIR)); } catch {}
233
+ };
191
234
  try {
192
235
  process.kill(pid, 'SIGTERM');
193
- fs.unlinkSync(pidFile);
236
+ cleanup();
194
237
  console.log(`[channel-worker] Daemon stopped (PID: ${pid})`);
195
238
  } catch (err) {
196
239
  if (err.code === 'ESRCH') {
197
- fs.unlinkSync(pidFile);
240
+ cleanup();
198
241
  console.log(`[channel-worker] Daemon was not running (stale PID: ${pid}). Cleaned up.`);
199
242
  } else {
200
243
  console.error(`[channel-worker] Failed to stop: ${err.message}`);
@@ -215,18 +258,27 @@ if (cmd === 'pair') {
215
258
 
216
259
  } else if (cmd === 'restart') {
217
260
  // Stop existing daemon, then start new one
261
+ const singleton = require('../lib/singleton');
218
262
  const pidFile = path.join(CONFIG_DIR, 'daemon.pid');
219
- if (fs.existsSync(pidFile)) {
220
- const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
263
+ const lock = singleton.readLock(CONFIG_DIR);
264
+ const pid = (lock && lock.pid)
265
+ || (fs.existsSync(pidFile) ? parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10) : NaN);
266
+ if (Number.isInteger(pid)) {
221
267
  try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
222
- fs.unlinkSync(pidFile);
223
268
  console.log(`[channel-worker] Stopped old daemon (PID: ${pid})`);
224
269
  }
270
+ // Con vừa bị bắn có thể chưa kịp nhả khoá (hoặc bị SIGKILL trước đó) — dọn
271
+ // khoá của ĐÚNG pid vừa giết để con mới không bị chính nó chặn cửa.
272
+ if (lock && lock.pid === pid && !singleton.pidAlive(pid)) {
273
+ try { fs.unlinkSync(singleton.lockPath(CONFIG_DIR)); } catch {}
274
+ }
275
+ try { fs.unlinkSync(pidFile); } catch {}
225
276
  // Re-invoke start
226
277
  const { spawn } = require('child_process');
227
278
  const child = spawn(process.execPath, [__filename, 'start'], {
228
279
  stdio: 'inherit',
229
280
  cwd: process.cwd(),
281
+ windowsHide: true,
230
282
  });
231
283
  child.on('exit', (code) => process.exit(code));
232
284
 
@@ -119,11 +119,11 @@ class CacheServer {
119
119
  try {
120
120
  if (process.platform === 'win32') {
121
121
  const out = execSync(`tasklist /FI "PID eq ${pid}" /NH /FO CSV`,
122
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
122
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
123
123
  return /^"node\.exe"/i.test(out.trim());
124
124
  }
125
125
  const out = execSync(`ps -p ${pid} -o comm=`,
126
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
126
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
127
127
  return /node/i.test(out.trim());
128
128
  } catch {
129
129
  return false;
@@ -137,7 +137,7 @@ class CacheServer {
137
137
  if (process.platform === 'win32') {
138
138
  // netstat columns: Proto Local Foreign State PID
139
139
  const out = execSync(`netstat -ano -p TCP | findstr :${this.port} | findstr LISTENING`,
140
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
140
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
141
141
  const pids = new Set();
142
142
  for (const line of out.split('\n')) {
143
143
  const m = line.trim().match(/\s(\d+)\s*$/);
@@ -152,13 +152,13 @@ class CacheServer {
152
152
  continue;
153
153
  }
154
154
  console.log(`[cache-server] Killing leaked PID ${pid} holding port ${this.port}`);
155
- try { execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' }); killed++; } catch {}
155
+ try { execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore', windowsHide: true }); killed++; } catch {}
156
156
  }
157
157
  return killed;
158
158
  }
159
159
  // mac/linux
160
160
  const out = execSync(`lsof -ti:${this.port} -sTCP:LISTEN`,
161
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
161
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
162
162
  const pids = out.split('\n').map(s => s.trim()).filter(Boolean);
163
163
  let killed = 0;
164
164
  for (const pid of pids) {
@@ -168,7 +168,7 @@ class CacheServer {
168
168
  continue;
169
169
  }
170
170
  console.log(`[cache-server] Killing leaked PID ${pid} holding port ${this.port}`);
171
- try { execSync(`kill -9 ${pid}`, { stdio: 'ignore' }); killed++; } catch {}
171
+ try { execSync(`kill -9 ${pid}`, { stdio: 'ignore', windowsHide: true }); killed++; } catch {}
172
172
  }
173
173
  return killed;
174
174
  } catch {
@@ -558,7 +558,7 @@ class CommandPoller {
558
558
 
559
559
  // Extract
560
560
  fs.mkdirSync(tmpExtract, { recursive: true });
561
- execSync(`tar -xzf "${tmpArchive}" -C "${tmpExtract}"`, { timeout: 30000 });
561
+ execSync(`tar -xzf "${tmpArchive}" -C "${tmpExtract}"`, { timeout: 30000, windowsHide: true });
562
562
 
563
563
  // Tar extracts into subfolder — find it (could be content-creator or content-creator-ext)
564
564
  const subdirs = fs.readdirSync(tmpExtract).filter(f => fs.statSync(path.join(tmpExtract, f)).isDirectory());
@@ -1338,16 +1338,24 @@ class CommandPoller {
1338
1338
  const logFile = path.join(configDir, 'daemon.log');
1339
1339
  const logFd = fs.openSync(logFile, 'a');
1340
1340
 
1341
+ // Nhả khoá trước rồi mới đẻ con thay thế — và CHỈ con đang giữ khoá mới
1342
+ // được đẻ. Nếu lệnh restart rơi vào một daemon thừa thì nó chỉ việc tắt,
1343
+ // daemon thật vẫn chạy tiếp (xem lib/singleton.js).
1344
+ const singleton = require('./singleton');
1345
+ const wasOwner = singleton.release(configDir);
1346
+ if (!wasOwner) {
1347
+ console.log('[commands] Tiến trình này không giữ khoá daemon (bản thừa) — tắt, không spawn bản mới.');
1348
+ process.exit(0);
1349
+ }
1350
+
1341
1351
  const child = spawn(process.execPath, [cliPath, 'start', '--_daemon'], {
1342
1352
  detached: true,
1343
1353
  stdio: ['ignore', logFd, logFd],
1344
1354
  cwd: os.homedir(),
1355
+ windowsHide: true,
1345
1356
  });
1346
1357
  child.unref();
1347
1358
 
1348
- // Update PID file
1349
- const pidFile = path.join(configDir, 'daemon.pid');
1350
- fs.writeFileSync(pidFile, String(child.pid));
1351
1359
  console.log(`[commands] New daemon spawned (PID: ${child.pid}), exiting current process...`);
1352
1360
 
1353
1361
  process.exit(0);
package/lib/daemon.js CHANGED
@@ -6,6 +6,9 @@ const { UpdateChecker, getLocalVersion } = require('./updater');
6
6
  const { checkAndUpdateExtension } = require('./extension-updater');
7
7
  const { CacheServer } = require('./cache-server');
8
8
  const { agentAddressForUser } = require('./nst-agent-locator');
9
+ const singleton = require('./singleton');
10
+ const os = require('os');
11
+ const path = require('path');
9
12
 
10
13
  class Daemon {
11
14
  constructor(config) {
@@ -30,6 +33,47 @@ class Daemon {
30
33
  this.updateChecker = new UpdateChecker(5 * 60 * 1000); // check every 5min
31
34
  this.extCheckTimer = null;
32
35
  this.nstAgentTimer = null;
36
+ this.lockTimer = null;
37
+ this.configDir = path.join(os.homedir(), '.channel-worker');
38
+ this._evictLogged = false;
39
+ }
40
+
41
+ /** Đang bận = có job đang chạy, hoặc có phiên Playwright đang giữ profile. */
42
+ _isBusy() {
43
+ const jobs = (this.poller && this.poller.running) ? this.poller.running.size : 0;
44
+ const pw = (this.commandPoller && this.commandPoller._pwInFlight)
45
+ ? this.commandPoller._pwInFlight.size : 0;
46
+ return jobs > 0 || pw > 0;
47
+ }
48
+
49
+ /**
50
+ * Nhịp khoá singleton (30s):
51
+ * - còn là chủ → làm tươi để đứa khác biết mình sống;
52
+ * - khoá trống → giành lại (ai đó dọn tay), đừng tự sát vô cớ;
53
+ * - khoá sang tay đứa khác → MÌNH LÀ MỒ CÔI: một daemon mới đã tiếp quản,
54
+ * rút lui khi hết việc thay vì poll chồng lên nó. Đây là đường tự hội tụ
55
+ * cho những máy đã lỡ có sẵn nhiều daemon.
56
+ */
57
+ async _tickLock() {
58
+ if (singleton.refresh(this.configDir)) return;
59
+ const lock = singleton.readLock(this.configDir);
60
+ if (!lock) {
61
+ await singleton.acquire(this.configDir, {
62
+ waitMs: 0,
63
+ info: { worker_id: this.config.worker_id, version: getLocalVersion() },
64
+ }).catch(() => {});
65
+ return;
66
+ }
67
+ if (lock.pid === process.pid) return;
68
+ if (!this._evictLogged) {
69
+ this._evictLogged = true;
70
+ console.warn(`[daemon] Khoá ${singleton.LOCK_FILE} đã sang PID ${lock.pid} — `
71
+ + `tiến trình này (PID ${process.pid}) là daemon THỪA, sẽ tự tắt khi hết việc.`);
72
+ }
73
+ if (this._isBusy()) return; // đang chạy dở thì để yên, nhịp sau tính tiếp
74
+ console.warn('[daemon] Hết việc — daemon thừa tự tắt để máy chỉ còn một con.');
75
+ if (this._shutdown) await this._shutdown();
76
+ else process.exit(0);
33
77
  }
34
78
 
35
79
  /**
@@ -147,6 +191,10 @@ class Daemon {
147
191
  console.log(`[daemon] NST agent watcher started (user "${this.config.nst_agent_user}", every 60s)`);
148
192
  }
149
193
 
194
+ // Nhịp khoá singleton — xem _tickLock().
195
+ this.lockTimer = setInterval(() => { this._tickLock().catch(() => {}); }, singleton.REFRESH_MS);
196
+ console.log(`[daemon] Singleton lock watcher started (every ${singleton.REFRESH_MS / 1000}s)`);
197
+
150
198
  // Start auto-update checker
151
199
  this.updateChecker.start();
152
200
  console.log('[daemon] Auto-update checker started (every 5min)');
@@ -177,7 +225,10 @@ class Daemon {
177
225
  this.updateChecker.stop();
178
226
  if (this.extCheckTimer) clearInterval(this.extCheckTimer);
179
227
  if (this.nstAgentTimer) clearInterval(this.nstAgentTimer);
228
+ if (this.lockTimer) clearInterval(this.lockTimer);
180
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 {}
181
232
 
182
233
  // Mark offline
183
234
  try {
@@ -190,6 +241,7 @@ class Daemon {
190
241
  process.exit(0);
191
242
  };
192
243
 
244
+ this._shutdown = shutdown;
193
245
  process.on('SIGINT', shutdown);
194
246
  process.on('SIGTERM', shutdown);
195
247
  }
@@ -41,7 +41,7 @@ async function checkAndUpdateExtension(api, extensionPath) {
41
41
 
42
42
  // Extract
43
43
  fs.mkdirSync(tmpExtract, { recursive: true });
44
- execSync(`tar -xzf "${tmpArchive}" -C "${tmpExtract}"`, { timeout: 30000 });
44
+ execSync(`tar -xzf "${tmpArchive}" -C "${tmpExtract}"`, { timeout: 30000, windowsHide: true });
45
45
 
46
46
  // tar extracts into a subfolder named "channel-manager-ext"
47
47
  const extracted = path.join(tmpExtract, 'channel-manager-ext');
@@ -14,7 +14,11 @@
14
14
 
15
15
  const { execSync } = require('child_process');
16
16
 
17
- const sh = (cmd) => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000 });
17
+ // windowsHide: mặc định của child_process FALSE mỗi execSync trên Windows
18
+ // nháy một cửa sổ console đen. Hàm này chạy mỗi 60s (watcher agent NST) nên
19
+ // nó chính là thủ phạm "cứ một lúc lại có cửa sổ đen bật lên rồi tắt" mà
20
+ // chủ tịch thấy trên win-worker 04/09/2026.
21
+ const sh = (cmd) => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000, windowsHide: true });
18
22
 
19
23
  // PIDs of agent.exe, optionally narrowed to one owner. Owner filtering happens
20
24
  // in tasklist rather than in JS because listing owners needs WMI/CIM, which is
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Một CONFIG_DIR = MỘT daemon. Không có khoá này thì `channel-worker start`
3
+ * (người gõ tay, scheduled task chạy lại, bộ tự-cập-nhật, lệnh restart_worker)
4
+ * đều đẻ thêm một daemon nữa mà không ai biết — `stop` chỉ giết được pid ghi
5
+ * trong daemon.pid, phần còn lại thành mồ côi poll song song cùng một hàng đợi.
6
+ *
7
+ * Sự cố thật 04/09/2026 trên relabs03: 3 daemon cùng sống (khởi động 00:42,
8
+ * 00:47, 00:52 — lệch đúng 5 phút = nhịp updater của từng con). Chúng có từ
9
+ * trước; khi 2.5.73 ra, MỖI con tự cài rồi tự spawn bản thay thế của riêng nó
10
+ * → 3 con cũ chết, 3 con mới sinh, sĩ số không bao giờ giảm. Dấu vết ở API:
11
+ * hai hàng `relabs03` trong `workers`, một hàng heartbeat đứng hình.
12
+ *
13
+ * Cách chống: khoá theo file trong chính CONFIG_DIR (nên 2 daemon 2 home trên
14
+ * win-worker vẫn chạy song song bình thường).
15
+ * - chủ khoá làm tươi `refreshed_at` mỗi 30s → khoá cũ quá STALE_MS coi như
16
+ * chết, chống cả ca pid bị hệ điều hành cấp lại cho tiến trình khác;
17
+ * - `acquire` chờ tới `waitMs` để nhường nhịp bàn giao (con cũ nhả rồi mới
18
+ * thoát), hết giờ thì con mới tự thoát chứ KHÔNG chạy chồng;
19
+ * - `owns()` cho daemon tự soi: mất khoá vào tay đứa khác = mình là mồ côi,
20
+ * tự rút lui lúc rảnh việc.
21
+ */
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+
26
+ const LOCK_FILE = 'daemon.lock';
27
+ const REFRESH_MS = 30_000;
28
+ // Chủ khoá làm tươi mỗi 30s; quá 6 nhịp không tươi = treo hoặc chết bất thường.
29
+ const STALE_MS = 180_000;
30
+
31
+ function lockPath(configDir) {
32
+ return path.join(configDir, LOCK_FILE);
33
+ }
34
+
35
+ function readLock(configDir) {
36
+ try {
37
+ const raw = fs.readFileSync(lockPath(configDir), 'utf-8');
38
+ const data = JSON.parse(raw);
39
+ if (!data || !Number.isInteger(data.pid)) return null;
40
+ return data;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function pidAlive(pid) {
47
+ if (!Number.isInteger(pid) || pid <= 0) return false;
48
+ try {
49
+ process.kill(pid, 0);
50
+ return true;
51
+ } catch (err) {
52
+ // EPERM = tiến trình có thật nhưng khác quyền → vẫn tính là sống.
53
+ return err.code === 'EPERM';
54
+ }
55
+ }
56
+
57
+ function isStale(lock, now = Date.now()) {
58
+ if (!lock) return true;
59
+ if (!pidAlive(lock.pid)) return true;
60
+ const refreshed = Date.parse(lock.refreshed_at || lock.started_at || '');
61
+ if (!Number.isFinite(refreshed)) return true;
62
+ return now - refreshed > STALE_MS;
63
+ }
64
+
65
+ function writeLock(configDir, info, { overwrite = false } = {}) {
66
+ fs.mkdirSync(configDir, { recursive: true });
67
+ const body = JSON.stringify({
68
+ pid: process.pid,
69
+ worker_id: info.worker_id || '',
70
+ version: info.version || '',
71
+ started_at: info.started_at || new Date().toISOString(),
72
+ refreshed_at: new Date().toISOString(),
73
+ });
74
+ // 'wx' = tạo mới, thất bại nếu đã có → chính là phép thử nguyên tử.
75
+ const fd = fs.openSync(lockPath(configDir), overwrite ? 'w' : 'wx');
76
+ try {
77
+ fs.writeFileSync(fd, body);
78
+ } finally {
79
+ fs.closeSync(fd);
80
+ }
81
+ }
82
+
83
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
84
+
85
+ /**
86
+ * Giành khoá cho tiến trình hiện tại.
87
+ * @returns {{ok: true, stolen?: object} | {ok: false, holder: object}}
88
+ */
89
+ async function acquire(configDir, { waitMs = 0, force = false, info = {}, pollMs = 1000 } = {}) {
90
+ const started_at = new Date().toISOString();
91
+ const deadline = Date.now() + Math.max(0, waitMs);
92
+ for (;;) {
93
+ const lock = readLock(configDir);
94
+ if (!lock) {
95
+ try {
96
+ writeLock(configDir, { ...info, started_at });
97
+ return { ok: true };
98
+ } catch (err) {
99
+ if (err.code !== 'EEXIST') throw err;
100
+ continue; // đứa khác chen vào giữa hai nhịp — đọc lại rồi xử tiếp
101
+ }
102
+ }
103
+ if (lock.pid === process.pid) return { ok: true }; // đã là của mình
104
+ if (force || isStale(lock)) {
105
+ writeLock(configDir, { ...info, started_at }, { overwrite: true });
106
+ return { ok: true, stolen: lock };
107
+ }
108
+ if (Date.now() >= deadline) return { ok: false, holder: lock };
109
+ await sleep(pollMs);
110
+ }
111
+ }
112
+
113
+ function owns(configDir, pid = process.pid) {
114
+ const lock = readLock(configDir);
115
+ return !!lock && lock.pid === pid;
116
+ }
117
+
118
+ /** Làm tươi khoá. Trả false nếu khoá đã sang tay đứa khác. */
119
+ function refresh(configDir) {
120
+ const lock = readLock(configDir);
121
+ if (!lock || lock.pid !== process.pid) return false;
122
+ try {
123
+ writeLock(configDir, lock, { overwrite: true });
124
+ return true;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ /** Nhả khoá — CHỈ khi mình đang giữ, không bao giờ xoá khoá của đứa khác. */
131
+ function release(configDir, pid = process.pid) {
132
+ const lock = readLock(configDir);
133
+ if (!lock || lock.pid !== pid) return false;
134
+ try {
135
+ fs.unlinkSync(lockPath(configDir));
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ module.exports = {
143
+ LOCK_FILE, REFRESH_MS, STALE_MS,
144
+ lockPath, readLock, pidAlive, isStale, acquire, owns, refresh, release,
145
+ };
package/lib/updater.js CHANGED
@@ -27,7 +27,7 @@ function isNewer(remote, local) {
27
27
 
28
28
  function installUpdate(version) {
29
29
  console.log(`[updater] Installing ${PKG_NAME}@${version}...`);
30
- execSync(`npm install -g ${PKG_NAME}@${version}`, { stdio: 'inherit' });
30
+ execSync(`npm install -g ${PKG_NAME}@${version}`, { stdio: 'inherit', windowsHide: true });
31
31
  console.log(`[updater] Installed ${PKG_NAME}@${version}`);
32
32
  }
33
33
 
@@ -50,23 +50,34 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
50
50
  const os = require('os');
51
51
 
52
52
  const CONFIG_DIR = path.join(os.homedir(), '.channel-worker');
53
- const pidFile = path.join(CONFIG_DIR, 'daemon.pid');
54
53
  const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
55
54
  const logFd = fs.openSync(LOG_FILE, 'a');
56
55
 
56
+ // Nhả khoá TRƯỚC khi spawn: con mới phải giành được khoá mới chạy. Nếu
57
+ // tiến trình này là daemon THỪA (không giữ khoá) thì release trả false —
58
+ // lúc đó `channel-worker start` sẽ từ chối đẻ thêm con, và mình vẫn thoát.
59
+ // Nhờ vậy mỗi vòng cập nhật là một lần TỰ RÚT SĨ SỐ về đúng một daemon,
60
+ // thay vì N con cũ đẻ ra N con mới như sự cố relabs03 04/09/2026.
61
+ const singleton = require('./singleton');
62
+ const wasOwner = singleton.release(CONFIG_DIR);
63
+ if (!wasOwner) {
64
+ console.log('[updater] Tiến trình này không giữ khoá daemon (bản thừa) — '
65
+ + 'thoát, không spawn bản thay thế.');
66
+ }
67
+
57
68
  // Spawn new daemon using global binary (picks up new version)
58
69
  const isWindows = process.platform === 'win32';
59
70
  const binName = isWindows ? 'channel-worker.cmd' : 'channel-worker';
60
- const child = spawn(binName, ['start'], {
61
- detached: true,
62
- stdio: ['ignore', logFd, logFd],
63
- cwd: os.homedir(),
64
- shell: isWindows,
65
- });
66
- child.unref();
67
-
68
- // Update PID file
69
- fs.writeFileSync(pidFile, String(child.pid));
71
+ if (wasOwner) {
72
+ const child = spawn(binName, ['start'], {
73
+ detached: true,
74
+ stdio: ['ignore', logFd, logFd],
75
+ cwd: os.homedir(),
76
+ shell: isWindows,
77
+ windowsHide: true,
78
+ });
79
+ child.unref();
80
+ }
70
81
 
71
82
  process.exit(0);
72
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.72",
3
+ "version": "2.5.74",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {
@@ -641,8 +641,16 @@ async function hasVisibleReelComposer(page) {
641
641
  const aria = dlg.getAttribute('aria-label') || '';
642
642
  const text = (dlg.innerText || '').slice(0, 500);
643
643
  const signature = `${aria}\n${text}`;
644
- if (/Chỉnh sửa hình thu nhỏ|Edit thumbnail/i.test(signature)) continue;
644
+ // Dấu hiệu composer phải xét TRƯỚC dòng loại trừ editor bìa. Biến thể
645
+ // INLINE dựng editor "Chỉnh sửa hình thu nhỏ" HẲN BÊN TRONG dialog
646
+ // composer, nên innerText của composer chứa luôn marker đó: loại trừ
647
+ // trước là ăn mất chính composer → báo "composer closed after saving
648
+ // thumbnail" dù composer còn nguyên và thumb đã dán (6 lượt fail
649
+ // 03-04/09, ảnh dump composer-closed-after-thumb-save cho thấy panel
650
+ // bìa + nút "Gỡ" vẫn hiện trong composer). Modal bìa độc lập không có
651
+ // chữ "thước phim"/"reel" trong text nên vẫn bị loại đúng ở dòng sau.
645
652
  if (/Tạo thước phim|Chỉnh sửa thước phim|Cài đặt thước phim|Create (?:a )?reel|Edit reel|Reel settings/i.test(signature)) return true;
653
+ if (/Chỉnh sửa hình thu nhỏ|Edit thumbnail/i.test(signature)) continue;
646
654
  }
647
655
  return false;
648
656
  }).catch(() => false);