channel-worker 2.5.73 → 2.5.75
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 +65 -13
- package/lib/cache-server.js +6 -6
- package/lib/command-poller.js +12 -4
- package/lib/daemon.js +52 -0
- package/lib/extension-updater.js +1 -1
- package/lib/nst-agent-locator.js +5 -1
- package/lib/singleton.js +145 -0
- package/lib/updater.js +23 -12
- package/package.json +1 -1
- package/scripts/upload_facebook.js +17 -19
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
|
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
|
-
|
|
236
|
+
cleanup();
|
|
194
237
|
console.log(`[channel-worker] Daemon stopped (PID: ${pid})`);
|
|
195
238
|
} catch (err) {
|
|
196
239
|
if (err.code === 'ESRCH') {
|
|
197
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
|
package/lib/cache-server.js
CHANGED
|
@@ -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 {
|
package/lib/command-poller.js
CHANGED
|
@@ -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
|
}
|
package/lib/extension-updater.js
CHANGED
|
@@ -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');
|
package/lib/nst-agent-locator.js
CHANGED
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
const { execSync } = require('child_process');
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// windowsHide: mặc định của child_process là 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
|
package/lib/singleton.js
ADDED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
@@ -1686,7 +1686,6 @@ async function runOnce({ page, payload, log }) {
|
|
|
1686
1686
|
const closeDeadline = Date.now() + 60_000;
|
|
1687
1687
|
let modalClosed = false;
|
|
1688
1688
|
let closedPolls = 0;
|
|
1689
|
-
let appliedPolls = 0;
|
|
1690
1689
|
let retryCount = 0;
|
|
1691
1690
|
const RETRY_AFTER_MS = 12_000;
|
|
1692
1691
|
let lastRetryAt = Date.now();
|
|
@@ -1696,23 +1695,21 @@ async function runOnce({ page, payload, log }) {
|
|
|
1696
1695
|
// nested form (no own dialog — its upload button, aria
|
|
1697
1696
|
// "Tải hình thu nhỏ tùy chỉnh lên…", only exists while the
|
|
1698
1697
|
// editor is showing).
|
|
1699
|
-
//
|
|
1700
|
-
//
|
|
1701
|
-
//
|
|
1702
|
-
//
|
|
1703
|
-
//
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
appliedPolls = 0;
|
|
1715
|
-
}
|
|
1698
|
+
// ĐÃ GỠ (2.5.75) — lối tắt "thấy nút Gỡ 2 nhịp thì coi như xong,
|
|
1699
|
+
// bấm Lưu chốt một cái rồi đi tiếp". Đo trên 20.000 dòng log
|
|
1700
|
+
// ngày 04/09/2026: lối tắt chạy 21 lượt thì 19 lượt kết thúc
|
|
1701
|
+
// bằng dump "composer closed after saving thumbnail", trong khi
|
|
1702
|
+
// đường thường (chờ editor đóng hẳn) chạy 98 lượt đăng ngon.
|
|
1703
|
+
// Chuỗi log lặp đúng một khuôn: NORMAL→PUBLISH, FAST→DUMP DUMP.
|
|
1704
|
+
//
|
|
1705
|
+
// Nút "Gỡ" hiện NGAY khi file vừa dán, lúc ảnh còn đang xử lý
|
|
1706
|
+
// (ảnh dump nào cũng thấy spinner), nên lối tắt cướp lượt của
|
|
1707
|
+
// những lần lẽ ra đóng bình thường sau 3-5s, rồi bấm Lưu lần
|
|
1708
|
+
// hai khi FB đang lưu dở — composer bị gỡ khỏi trang.
|
|
1709
|
+
//
|
|
1710
|
+
// Cohort inline thật (panel không bao giờ đóng) vẫn còn hai lớp
|
|
1711
|
+
// cứu phía dưới: nhánh hết-giờ-60s và cổng trước Đăng, cả hai
|
|
1712
|
+
// đều KHÔNG bấm thêm Lưu. Đó mới là đường đã nghiệm thu 03/09.
|
|
1716
1713
|
const still = await isThumbnailEditorVisible(page);
|
|
1717
1714
|
if (!still) {
|
|
1718
1715
|
// Require two consecutive closed samples. Facebook briefly
|
|
@@ -1726,7 +1723,8 @@ async function runOnce({ page, payload, log }) {
|
|
|
1726
1723
|
// Retry Lưu click after RETRY_AFTER_MS without progress —
|
|
1727
1724
|
// limit to 3 retries (so we still leave time for FB to
|
|
1728
1725
|
// finish processing on the last attempt).
|
|
1729
|
-
if (retryCount < 3 && Date.now() - lastRetryAt >= RETRY_AFTER_MS
|
|
1726
|
+
if (retryCount < 3 && Date.now() - lastRetryAt >= RETRY_AFTER_MS
|
|
1727
|
+
&& !(await isCustomThumbApplied(page))) {
|
|
1730
1728
|
retryCount++;
|
|
1731
1729
|
lastRetryAt = Date.now();
|
|
1732
1730
|
log('info', `[fb-pw] thumb modal still open after ${retryCount * (RETRY_AFTER_MS / 1000)}s — re-clicking Lưu (retry ${retryCount}/3)`);
|