channel-worker 2.5.89 → 2.5.91
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 +4 -0
- package/lib/command-poller.js +63 -6
- package/lib/shopee-scraper.js +61 -14
- package/lib/singleton.js +14 -2
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -159,6 +159,10 @@ if (cmd === 'pair') {
|
|
|
159
159
|
console.log(`[channel-worker] Tiếp quản khoá của PID ${res.stolen.pid} `
|
|
160
160
|
+ `(${args.force ? 'ép bằng --force' : 'đã chết hoặc treo quá lâu'}).`);
|
|
161
161
|
}
|
|
162
|
+
if (res.corrupted) {
|
|
163
|
+
console.log('[channel-worker] daemon.lock cũ hỏng (không đọc ra nội dung — '
|
|
164
|
+
+ 'dấu hiệu điển hình của mất điện giữa chừng) — đã ghi đè và chạy tiếp.');
|
|
165
|
+
}
|
|
162
166
|
// Ghi ĐÚNG pid của daemon thật — updater/restart trước đây ghi pid của
|
|
163
167
|
// tiến trình bọc, nên "stop" bắn trượt và để lại mồ côi.
|
|
164
168
|
fs.writeFileSync(path.join(CONFIG_DIR, 'daemon.pid'), String(process.pid));
|
package/lib/command-poller.js
CHANGED
|
@@ -2,6 +2,11 @@ const { NstManager } = require('./nst-manager');
|
|
|
2
2
|
const { checkAndUpdateExtension } = require('./extension-updater');
|
|
3
3
|
const { StatsSyncer } = require('./stats-syncer');
|
|
4
4
|
|
|
5
|
+
// Quãng RẢNH trước khi đóng profile của luồng đăng bài. Phải đủ dài để lượt
|
|
6
|
+
// upload nền tảng kế tiếp (facebook → youtube → tiktok của cùng một idea) kịp
|
|
7
|
+
// tới và dùng lại browser, nhưng không được để mở qua đêm.
|
|
8
|
+
const PW_IDLE_CLOSE_MS = Number(process.env.PW_IDLE_CLOSE_MS || 5 * 60 * 1000);
|
|
9
|
+
|
|
5
10
|
class CommandPoller {
|
|
6
11
|
constructor(api, config) {
|
|
7
12
|
this.api = api;
|
|
@@ -149,11 +154,20 @@ class CommandPoller {
|
|
|
149
154
|
return true;
|
|
150
155
|
}
|
|
151
156
|
|
|
152
|
-
// Ensure a profile (by name) is running and return a
|
|
157
|
+
// Ensure a profile (by name) is running and return a playwright BrowserContext
|
|
153
158
|
// attached over CDP via its local debug port. Worker runs ON the same box as
|
|
154
159
|
// Nstbrowser, so localhost:<remoteDebuggingPort> is reachable directly.
|
|
160
|
+
//
|
|
161
|
+
// Dùng playwright-core, KHÔNG puppeteer-core: gói này chưa bao giờ nằm trong
|
|
162
|
+
// dependencies của worker (07/09: chạy thật lần đầu → MODULE_NOT_FOUND, hai
|
|
163
|
+
// lệnh Shopee chết ngay tại đây). Cả worker đã chạy playwright rồi.
|
|
164
|
+
//
|
|
165
|
+
// Trả CONTEXT chứ không phải browser, và người gọi phải dùng context đó:
|
|
166
|
+
// `browser.newPage()` của playwright đẻ ra một context MỚI — trắng cookie,
|
|
167
|
+
// nghĩa là mất sạch phiên đăng nhập Shopee của profile. Chỉ context sẵn có
|
|
168
|
+
// (contexts()[0]) mới mang cookie thật.
|
|
155
169
|
async _connectNstProfileByName(name) {
|
|
156
|
-
const
|
|
170
|
+
const { chromium } = require('playwright-core');
|
|
157
171
|
const profileId = await this.nst.findProfile(name);
|
|
158
172
|
if (!profileId) throw new Error(`NST profile "${name}" not found`);
|
|
159
173
|
let running = (await this.nst.getRunningBrowsers()).find(b => b.profileId === profileId);
|
|
@@ -167,8 +181,11 @@ class CommandPoller {
|
|
|
167
181
|
}
|
|
168
182
|
const port = running?.remoteDebuggingPort;
|
|
169
183
|
if (!port) throw new Error(`No debug port for profile "${name}" (launch failed?)`);
|
|
170
|
-
const browser = await
|
|
171
|
-
|
|
184
|
+
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
|
|
185
|
+
const context = browser.contexts()[0] || await browser.newContext();
|
|
186
|
+
// browser.close() trên kết nối CDP chỉ CẮT kết nối, không tắt Chrome của
|
|
187
|
+
// Nstbrowser (đo thật trên relabs03 07/09: profile vẫn chạy sau khi đóng).
|
|
188
|
+
return { browser, context, disconnect: () => browser.close().catch(() => {}) };
|
|
172
189
|
}
|
|
173
190
|
|
|
174
191
|
// Flow 1 — quét affiliate offer list (sort theo hoa hồng) → upsert vào kho.
|
|
@@ -180,7 +197,7 @@ class CommandPoller {
|
|
|
180
197
|
let conn;
|
|
181
198
|
try {
|
|
182
199
|
conn = await this._connectNstProfileByName(profileName);
|
|
183
|
-
const res = await scraper.scrapeOffers(conn.
|
|
200
|
+
const res = await scraper.scrapeOffers(conn.context, {
|
|
184
201
|
category: payload.category || 'women_clothes', // Tier-1 default: quần áo nữ
|
|
185
202
|
match_id: payload.match_id || null,
|
|
186
203
|
sort_types: payload.sort_types || [2, 3], // commission + sales, merged
|
|
@@ -223,7 +240,7 @@ class CommandPoller {
|
|
|
223
240
|
let conn;
|
|
224
241
|
try {
|
|
225
242
|
conn = await this._connectNstProfileByName(profileName);
|
|
226
|
-
const product = await scraper.ingestProduct(conn.
|
|
243
|
+
const product = await scraper.ingestProduct(conn.context, ids);
|
|
227
244
|
console.log(`[shopee] ingested: ${product.name} (${product.images.length} imgs)`);
|
|
228
245
|
await this.api.ingestShopeeResult(product, { user_id: command.user_id, idea_id: payload.idea_id || null });
|
|
229
246
|
await this.api.updateCommand(command._id, { status: 'done', result: { name: product.name, images: product.images.length } });
|
|
@@ -253,6 +270,11 @@ class CommandPoller {
|
|
|
253
270
|
// publish click). Different profiles still run in parallel — this lock
|
|
254
271
|
// only serializes within a profile. Wait up to 30 min with 5s polling
|
|
255
272
|
// (upload typically 1-3 min); stale-cleanup picks up zombies separately.
|
|
273
|
+
// Lượt mới của cùng profile → huỷ hẹn đóng, giữ browser cho nó dùng lại.
|
|
274
|
+
if (this._pwCloseTimers && this._pwCloseTimers.has(profileId)) {
|
|
275
|
+
clearTimeout(this._pwCloseTimers.get(profileId));
|
|
276
|
+
this._pwCloseTimers.delete(profileId);
|
|
277
|
+
}
|
|
256
278
|
if (!this._pwInFlight) this._pwInFlight = new Map();
|
|
257
279
|
const MUTEX_MAX_WAIT_MS = 30 * 60 * 1000;
|
|
258
280
|
const waitStart = Date.now();
|
|
@@ -341,10 +363,45 @@ class CommandPoller {
|
|
|
341
363
|
} catch (e) {
|
|
342
364
|
console.warn(`[commands/pw] close after ${scriptName} failed: ${e.message}`);
|
|
343
365
|
}
|
|
366
|
+
} else if (this.nst) {
|
|
367
|
+
// ĐĂNG BÀI: giữ profile mở để lượt upload nền tảng kế tiếp dùng lại,
|
|
368
|
+
// nhưng phải có hạn. Trước 2026-09-07 KHÔNG AI đóng nó cả — cả ba đường
|
|
369
|
+
// đều hụt: không nằm trong CLOSE_AFTER, lệnh close_profile của API chỉ
|
|
370
|
+
// phát trong nhánh nuôi acc, còn bộ quét profile rảnh thì bỏ qua vì
|
|
371
|
+
// luồng _pw không ghi _profileLastActivity. Đo 24h: 78 lượt đăng, 0
|
|
372
|
+
// lệnh đóng, 141 tiến trình Nstbrowser còn sống trên win-worker.
|
|
373
|
+
//
|
|
374
|
+
// KHÔNG dùng bộ quét rảnh sẵn có: nó thoát sớm khi hàng đợi render còn
|
|
375
|
+
// việc, mà win-worker vừa đăng bài vừa render — profile đăng bài sẽ kẹt
|
|
376
|
+
// mở vô hạn đúng vào lúc máy bận nhất.
|
|
377
|
+
this._schedulePwClose(profileId, scriptName);
|
|
344
378
|
}
|
|
345
379
|
}
|
|
346
380
|
}
|
|
347
381
|
|
|
382
|
+
// Hẹn đóng profile sau một quãng RẢNH. Lượt _pw kế tiếp của cùng profile huỷ
|
|
383
|
+
// hẹn (xem đầu handlePlaywrightCommand), nên chuỗi upload nhiều nền tảng liên
|
|
384
|
+
// tiếp vẫn dùng chung một browser như thiết kế cũ.
|
|
385
|
+
_schedulePwClose(profileId, scriptName) {
|
|
386
|
+
if (!this._pwCloseTimers) this._pwCloseTimers = new Map();
|
|
387
|
+
const prev = this._pwCloseTimers.get(profileId);
|
|
388
|
+
if (prev) clearTimeout(prev);
|
|
389
|
+
const t = setTimeout(async () => {
|
|
390
|
+
this._pwCloseTimers.delete(profileId);
|
|
391
|
+
// Có lệnh khác vừa chiếm profile này → để yên, lượt đó sẽ tự hẹn lại.
|
|
392
|
+
if (this._pwInFlight && this._pwInFlight.has(profileId)) return;
|
|
393
|
+
try {
|
|
394
|
+
await this.nst.stopProfile(profileId);
|
|
395
|
+
console.log(`[commands/pw] đóng profile ${profileId} sau ${PW_IDLE_CLOSE_MS / 1000}s rảnh (${scriptName})`);
|
|
396
|
+
} catch (e) {
|
|
397
|
+
console.warn(`[commands/pw] đóng trễ ${profileId} lỗi: ${e.message}`);
|
|
398
|
+
}
|
|
399
|
+
}, PW_IDLE_CLOSE_MS);
|
|
400
|
+
// Đừng giữ tiến trình sống chỉ vì một cái hẹn đóng browser.
|
|
401
|
+
if (typeof t.unref === 'function') t.unref();
|
|
402
|
+
this._pwCloseTimers.set(profileId, t);
|
|
403
|
+
}
|
|
404
|
+
|
|
348
405
|
async handleLaunchProfile(command) {
|
|
349
406
|
const { profile_id, channel_id } = command.payload || {};
|
|
350
407
|
console.log(`[commands] Launching Nstbrowser profile: ${profile_id}`);
|
package/lib/shopee-scraper.js
CHANGED
|
@@ -65,9 +65,13 @@ function parsePdpItem(item) {
|
|
|
65
65
|
|
|
66
66
|
// Open the PDP and resolve with the get_pc payload the page fires. We listen at
|
|
67
67
|
// the response layer because a hand-rolled fetch gets error 90309999 (no sig).
|
|
68
|
-
|
|
68
|
+
//
|
|
69
|
+
// `ctx` là một playwright BrowserContext (KHÔNG phải Browser): trang phải mở
|
|
70
|
+
// trong đúng context của profile thì mới mang cookie đăng nhập Shopee — mở từ
|
|
71
|
+
// Browser là playwright đẻ context mới, trắng phiên.
|
|
72
|
+
async function ingestProduct(ctx, { shop_id, item_id, timeoutMs = 60000 } = {}) {
|
|
69
73
|
if (!shop_id || !item_id) throw new Error('ingestProduct needs shop_id + item_id');
|
|
70
|
-
const page = await
|
|
74
|
+
const page = await ctx.newPage();
|
|
71
75
|
try {
|
|
72
76
|
const captured = new Promise((resolve) => {
|
|
73
77
|
page.on('response', async (res) => {
|
|
@@ -93,6 +97,13 @@ function parseOfferRow(row) {
|
|
|
93
97
|
const card = row.batch_item_for_item_card_full || row.item_card_displayed_asset || {};
|
|
94
98
|
const shopId = String(card.shopid || card.shop_id || '');
|
|
95
99
|
const itemId = String(row.item_id || card.itemid || card.item_id || '');
|
|
100
|
+
// Đo thật 08/09/2026 trên cả tab XTRA lẫn tab ngành hàng:
|
|
101
|
+
// default_commission_rate = TỔNG hoa hồng affiliate (đã gồm Xtra)
|
|
102
|
+
// seller_commission_rate = phần NGƯỜI BÁN tài trợ, tức chính là Xtra
|
|
103
|
+
// default − seller = hoa hồng nền Shopee theo ngành (thời trang 7%,
|
|
104
|
+
// hoá phẩm / điện tử nhỏ 2,5%)
|
|
105
|
+
// max_commission_rate = 0% ở MỌI dòng đo được → đừng tin field này,
|
|
106
|
+
// nó chỉ đứng làm đường lui khi thiếu default.
|
|
96
107
|
const rate = pctToNumber(row.default_commission_rate ?? row.max_commission_rate ?? row.commission_rate);
|
|
97
108
|
const sellerRate = pctToNumber(row.seller_commission_rate);
|
|
98
109
|
const price = (Number(card.price || card.price_min) || 0) / 100000;
|
|
@@ -123,11 +134,26 @@ function parseOfferRow(row) {
|
|
|
123
134
|
// Affiliate offer-page category tabs → match_id (read from rc-tabs node keys,
|
|
124
135
|
// confirmed live). Tier-1 fashion focus first; the offer page has NO dedicated
|
|
125
136
|
// Shoes/Bags/Accessories tab → those come later via keyword search.
|
|
137
|
+
// Đọc thẳng từ dãy tab trên affiliate.shopee.vn/offer/product_offer, đối chiếu
|
|
138
|
+
// lại ngày 08/09/2026 — dãy này ĐỔI theo thời gian: 'grocery' (100629) và
|
|
139
|
+
// Moms/Kids có hồi 06/2026 nay không còn tab, đổi lại có thêm Đồ gia dụng và
|
|
140
|
+
// Máy tính & phụ kiện. Thấy quét ra rỗng thì việc đầu tiên là mở trang xem tab
|
|
141
|
+
// còn không, đừng đổ tại phiên đăng nhập.
|
|
142
|
+
//
|
|
143
|
+
// Hai kiểu danh sách KHÁC NHAU, đừng trộn (đo thật 08/09: truyền XTRA như một
|
|
144
|
+
// ngành hàng thì trả về 0 dòng, http 200 code 0 — hỏng im lặng):
|
|
145
|
+
// • ngành hàng → list_type=3&match_type=2&match_id=<id>
|
|
146
|
+
// • Commissions XTRA → list_type=7, KHÔNG có match_id
|
|
126
147
|
const AFFILIATE_CATEGORIES = {
|
|
127
|
-
women_clothes:
|
|
128
|
-
beauty:
|
|
129
|
-
home_living:
|
|
130
|
-
|
|
148
|
+
women_clothes: { match_id: 100017, label: 'Quần áo nữ', group: 'fashion' },
|
|
149
|
+
beauty: { match_id: 100630, label: 'Làm đẹp', group: 'beauty' },
|
|
150
|
+
home_living: { match_id: 100636, label: 'Nhà cửa & đời sống', group: 'other' },
|
|
151
|
+
home_appliances: { match_id: 100010, label: 'Đồ gia dụng', group: 'other' },
|
|
152
|
+
computer: { match_id: 100644, label: 'Máy tính & phụ kiện', group: 'other' },
|
|
153
|
+
// Không phải ngành hàng mà là "sản phẩm có hoa hồng Xtra" — cắt ngang mọi
|
|
154
|
+
// ngành. Không đặt group để khỏi tự bật lọc nhóm và vứt mất hàng ngoài
|
|
155
|
+
// fashion, vì chính cái tab này mới là chỗ hoa hồng cao nhất.
|
|
156
|
+
xtra: { list_type: 7, label: 'Hoa hồng XTRA' },
|
|
131
157
|
};
|
|
132
158
|
|
|
133
159
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -147,10 +173,24 @@ function applyFilters(rows, f = {}) {
|
|
|
147
173
|
return rows.filter((p) => {
|
|
148
174
|
if (f.category_groups?.length && !f.category_groups.includes(p.category_group)) return false;
|
|
149
175
|
if (f.min_commission != null && (p.commission_rate || 0) < f.min_commission) return false;
|
|
176
|
+
// Lọc theo phần NGƯỜI BÁN tài trợ (Xtra). Cần cửa riêng vì quét tab XTRA
|
|
177
|
+
// không đồng nghĩa với Xtra cao: đo 08/09 thấy tab ngành hàng thường cũng
|
|
178
|
+
// có dòng tài trợ 10%, còn trong chính tab XTRA vẫn có dòng chỉ 8%.
|
|
179
|
+
if (f.min_seller_commission != null && (p.commission_seller_rate || 0) < f.min_seller_commission) return false;
|
|
180
|
+
// Cổng theo TIỀN mỗi đơn. Ngưỡng phần trăm không so được giữa các ngành:
|
|
181
|
+
// hoa hồng nền của thời trang là 7% còn gia dụng/điện tử chỉ 2,5%, nên bộ
|
|
182
|
+
// Tier-1 (≥12%) vốn chỉnh cho thời trang đem áp lên gia dụng thì quét gần
|
|
183
|
+
// như trắng — đo 08/09/2026 trên tab Đồ gia dụng: 19/20 món rớt ĐÚNG ở
|
|
184
|
+
// cổng 12%, trong khi không món nào rớt vì doanh số, sao hay ảnh.
|
|
185
|
+
if (f.min_commission_value != null && (p.commission_value || 0) < f.min_commission_value) return false;
|
|
150
186
|
if (f.price_min != null && (p.price || 0) < f.price_min) return false;
|
|
151
187
|
if (f.price_max != null && (p.price || 0) > f.price_max) return false;
|
|
152
188
|
if (f.min_sold != null && (p.sold_count || 0) < f.min_sold) return false;
|
|
153
|
-
|
|
189
|
+
// Hàng CHƯA có đánh giá cũng rớt, không được đi cửa sau. Điều kiện cũ là
|
|
190
|
+
// "có rating VÀ rating < ngưỡng thì loại" — nghĩa là sản phẩm rating rỗng
|
|
191
|
+
// lọt thẳng qua cổng chất lượng, đúng loại hàng mới đăng chưa ai mua mà
|
|
192
|
+
// mình không muốn đem đi làm video.
|
|
193
|
+
if (f.min_rating != null && !(Number(p.rating) >= f.min_rating)) return false;
|
|
154
194
|
if (f.min_images != null && (p.images?.length || 0) < f.min_images) return false;
|
|
155
195
|
return true;
|
|
156
196
|
});
|
|
@@ -162,7 +202,9 @@ function applyFilters(rows, f = {}) {
|
|
|
162
202
|
// { status: 'ok'|'needs_verify', products: [...], raw_count }
|
|
163
203
|
// Never throws on a blocked session — returns status:'needs_verify' so the
|
|
164
204
|
// worker reports it cleanly instead of failing + retrying into more captchas.
|
|
165
|
-
|
|
205
|
+
// `ctx` = playwright BrowserContext của profile affiliate đã đăng nhập — xem
|
|
206
|
+
// ghi chú ở ingestProduct về việc vì sao không nhận Browser.
|
|
207
|
+
async function scrapeOffers(ctx, {
|
|
166
208
|
category = null, // key in AFFILIATE_CATEGORIES (e.g. 'women_clothes')
|
|
167
209
|
match_id = null, // explicit category id override
|
|
168
210
|
sort_types = [2], // [2]=commission; pass [2,3] to merge commission+sales
|
|
@@ -180,15 +222,18 @@ async function scrapeOffers(browser, {
|
|
|
180
222
|
// Default the category_groups filter to the category's own group (so a
|
|
181
223
|
// Women-Clothes scrape keeps only fashion rows even though the tab mixes in
|
|
182
224
|
// a few accessories), unless the caller overrides.
|
|
183
|
-
if (cat && !filters.category_groups) filters = { ...filters, category_groups: [cat.group] };
|
|
225
|
+
if (cat?.group && !filters.category_groups) filters = { ...filters, category_groups: [cat.group] };
|
|
226
|
+
const listType = cat?.list_type || null;
|
|
184
227
|
|
|
185
|
-
const pagesOpen =
|
|
228
|
+
const pagesOpen = ctx.pages();
|
|
186
229
|
let page = pagesOpen.find((p) => p.url().includes('affiliate.shopee.vn'));
|
|
187
230
|
let opened = false;
|
|
188
|
-
if (!page) { page = await
|
|
231
|
+
if (!page) { page = await ctx.newPage(); opened = true; }
|
|
189
232
|
try {
|
|
190
233
|
if (!page.url().includes('affiliate.shopee.vn/offer')) {
|
|
191
|
-
|
|
234
|
+
// 'networkidle' (playwright), KHÔNG 'networkidle2' (tên của puppeteer —
|
|
235
|
+
// playwright coi là giá trị lạ và ném lỗi).
|
|
236
|
+
await page.goto('https://affiliate.shopee.vn/offer/product_offer', { waitUntil: 'networkidle', timeout: timeoutMs }).catch(() => {});
|
|
192
237
|
await sleep(1500);
|
|
193
238
|
}
|
|
194
239
|
if (isBlockedState(page.url(), null)) {
|
|
@@ -201,13 +246,15 @@ async function scrapeOffers(browser, {
|
|
|
201
246
|
for (let i = 0; i < pages; i++) {
|
|
202
247
|
const offset = i * page_limit;
|
|
203
248
|
const res = await page.evaluate(async (q) => {
|
|
204
|
-
const cat = q.
|
|
249
|
+
const cat = q.listType
|
|
250
|
+
? `&list_type=${q.listType}`
|
|
251
|
+
: (q.mid ? `&list_type=3&match_type=2&match_id=${q.mid}` : '&list_type=0');
|
|
205
252
|
const url = `/api/v3/offer/product/list?sort_type=${q.st}&page_offset=${q.offset}&page_limit=${q.page_limit}&client_type=1${cat}`;
|
|
206
253
|
try {
|
|
207
254
|
const r = await fetch(url, { credentials: 'include' });
|
|
208
255
|
return { status: r.status, json: await r.json().catch(() => null) };
|
|
209
256
|
} catch (e) { return { status: 0, error: String(e.message) }; }
|
|
210
|
-
}, { st, offset, page_limit, mid });
|
|
257
|
+
}, { st, offset, page_limit, mid, listType });
|
|
211
258
|
|
|
212
259
|
if (isBlockedState(page.url(), res) || res.json?.code === 90309999) {
|
|
213
260
|
return { status: 'needs_verify', products: [...byId.values()], raw_count: rawCount, reason: `blocked mid-scrape (http ${res.status}, code ${res.json?.code})` };
|
package/lib/singleton.js
CHANGED
|
@@ -84,7 +84,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
86
|
* Giành khoá cho tiến trình hiện tại.
|
|
87
|
-
* @returns {{ok: true, stolen?: object} | {ok: false, holder: object}}
|
|
87
|
+
* @returns {{ok: true, stolen?: object, corrupted?: boolean} | {ok: false, holder: object}}
|
|
88
88
|
*/
|
|
89
89
|
async function acquire(configDir, { waitMs = 0, force = false, info = {}, pollMs = 1000 } = {}) {
|
|
90
90
|
const started_at = new Date().toISOString();
|
|
@@ -97,7 +97,19 @@ async function acquire(configDir, { waitMs = 0, force = false, info = {}, pollMs
|
|
|
97
97
|
return { ok: true };
|
|
98
98
|
} catch (err) {
|
|
99
99
|
if (err.code !== 'EEXIST') throw err;
|
|
100
|
-
|
|
100
|
+
// File có thật mà readLock không đọc ra. Hai ca khác hẳn nhau:
|
|
101
|
+
// a) đứa khác vừa chen vào giữa hai nhịp → đọc lại ra khoá hợp lệ,
|
|
102
|
+
// quay vòng xử như bình thường (chờ / cướp nếu đã rác);
|
|
103
|
+
// b) file HỎNG, đọc lại vẫn không ra gì → không có chủ nào để chờ.
|
|
104
|
+
// Ca (b) mới là cái giết máy: mất điện 07/09/2026 để lại daemon.lock
|
|
105
|
+
// dài 139 byte TOÀN NUL (NTFS đã cấp cỡ, chưa kịp ghi nội dung).
|
|
106
|
+
// `continue` trần khi ấy thành vòng lặp KHÔNG sleep, không hạn giờ:
|
|
107
|
+
// 3 daemon trên win-worker quay tít 100% CPU suốt 4 giờ, không ghi nổi
|
|
108
|
+
// một dòng log, không bao giờ tới Daemon.start() — cả đàn video ngừng
|
|
109
|
+
// đăng mà nhìn từ ngoài y như "daemon không tự chạy sau khi bật máy".
|
|
110
|
+
if (readLock(configDir)) continue;
|
|
111
|
+
writeLock(configDir, { ...info, started_at }, { overwrite: true });
|
|
112
|
+
return { ok: true, corrupted: true };
|
|
101
113
|
}
|
|
102
114
|
}
|
|
103
115
|
if (lock.pid === process.pid) return { ok: true }; // đã là của mình
|