fix: MakerWorld haengender Puppeteer-Prozess - Lock-Cleanup + hartes Timeout

This commit is contained in:
2026-07-11 07:03:21 +02:00
parent ae13d1b239
commit 98f21a4b05

View File

@@ -32,6 +32,7 @@ const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
const cacheByUser = new Map(); // userId -> { data, fetchedAt, error }
const pendingLoginByUser = new Map(); // userId -> { browser, page, codeField, startedAt }
const queueByUser = new Map(); // userId -> Promise (Serialisierung je Nutzer)
const activeBrowserByUser = new Map(); // userId -> zuletzt gestarteter Browser (für Force-Kill bei Timeout)
function getCache(userId) {
if (!cacheByUser.has(userId)) cacheByUser.set(userId, { data: null, fetchedAt: 0, error: null });
@@ -110,18 +111,51 @@ function mapStatisticalData(parsed) {
}
// ── Browser-Helfer ────────────────────────────────────────────────────────────
// Nach einem abgebrochenen Vorgang (Container-Neustart mitten im Betrieb,
// Absturz o.ä.) kann Chromium veraltete Sperrdateien im Profil hinterlassen —
// dann hängt der nächste Start ewig, statt einen Fehler zu werfen. Vor jedem
// Start räumen wir das defensiv weg.
function cleanupStaleLocks(profileDir) {
for (const name of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
try { fs.rmSync(`${profileDir}/${name}`, { force: true }); } catch {}
}
}
async function launchBrowser(userId) {
let puppeteer;
try { puppeteer = require('puppeteer-core'); }
catch { throw new Error('puppeteer-core nicht installiert'); }
const profileDir = `${PROFILE_BASE_DIR}/${userId}`;
fs.mkdirSync(profileDir, { recursive: true });
return puppeteer.launch({
cleanupStaleLocks(profileDir);
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium-browser',
userDataDir: profileDir,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
headless: true,
});
activeBrowserByUser.set(userId, browser);
return browser;
}
function forceKillBrowser(userId) {
const b = activeBrowserByUser.get(userId);
if (!b) return;
try { b.process()?.kill('SIGKILL'); } catch {}
}
// Harte Zeitgrenze um einen kompletten Browser-Vorgang — falls irgendwo (Launch,
// Navigation, Screenshot, Close) etwas hängt, wird der Prozess hart beendet statt
// die komplette Warteschlange dieses Nutzers für immer zu blockieren.
function withHardTimeout(promise, ms, onTimeout) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(async () => {
try { await onTimeout?.(); } catch {}
reject(new Error(`Zeitüberschreitung nach ${Math.round(ms / 1000)}s — Vorgang wurde abgebrochen`));
}, ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
async function getNextDataFromPage(page) {
@@ -309,9 +343,12 @@ async function injectCookiesInner(userId, cookieString) {
}
}
const fetchStatsViaBrowser = (userId, forceRefresh) => serialized(userId, () => fetchStatsViaBrowserInner(userId, forceRefresh));
const submitLoginCode = (userId, code) => serialized(userId, () => submitLoginCodeInner(userId, code));
const injectCookies = (userId, cookieString) => serialized(userId, () => injectCookiesInner(userId, cookieString));
const fetchStatsViaBrowser = (userId, forceRefresh) =>
serialized(userId, () => withHardTimeout(fetchStatsViaBrowserInner(userId, forceRefresh), 60000, () => forceKillBrowser(userId)));
const submitLoginCode = (userId, code) =>
serialized(userId, () => withHardTimeout(submitLoginCodeInner(userId, code), 60000, () => forceKillBrowser(userId)));
const injectCookies = (userId, cookieString) =>
serialized(userId, () => withHardTimeout(injectCookiesInner(userId, cookieString), 45000, () => forceKillBrowser(userId)));
// ── Routes (alle nur "authenticate" — jeder verwaltet ausschließlich sich selbst) ──
@@ -322,20 +359,39 @@ router.get('/stats', authenticate, async (req, res) => {
if (!configured) {
return res.json({ configured: false, data: null, error: null, fetchedAt: null, needsCode: false });
}
const result = await fetchStatsViaBrowser(userId, false);
res.json({ configured: true, ...result });
try {
const result = await fetchStatsViaBrowser(userId, false);
res.json({ configured: true, ...result });
} catch (e) {
cache.error = e.message;
res.json({ configured: true, data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false });
}
});
router.post('/refresh', authenticate, async (req, res) => {
const result = await fetchStatsViaBrowser(req.user.id, true);
res.json(result);
const userId = req.user.id;
const cache = getCache(userId);
try {
const result = await fetchStatsViaBrowser(userId, true);
res.json(result);
} catch (e) {
cache.error = e.message;
res.json({ data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false });
}
});
router.post('/verify-code', authenticate, async (req, res) => {
const { code } = req.body;
if (!code || typeof code !== 'string') return res.status(400).json({ error: 'Code fehlt' });
const result = await submitLoginCode(req.user.id, code.trim());
res.json(result);
const userId = req.user.id;
const cache = getCache(userId);
try {
const result = await submitLoginCode(userId, code.trim());
res.json(result);
} catch (e) {
cache.error = e.message;
res.json({ data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false });
}
});
router.get('/login-status', authenticate, (req, res) => {
@@ -365,9 +421,16 @@ router.get('/debug-screenshot', authenticate, (req, res) => {
router.post('/credentials', authenticate, async (req, res) => {
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
saveCredentials(req.user.id, username.trim(), password);
const result = await fetchStatsViaBrowser(req.user.id, true);
res.json(result);
const userId = req.user.id;
saveCredentials(userId, username.trim(), password);
const cache = getCache(userId);
try {
const result = await fetchStatsViaBrowser(userId, true);
res.json(result);
} catch (e) {
cache.error = e.message;
res.json({ data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false });
}
});
// Eigene Zugangsdaten entfernen (Profil/Cookies bleiben unangetastet liegen)