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 cacheByUser = new Map(); // userId -> { data, fetchedAt, error }
const pendingLoginByUser = new Map(); // userId -> { browser, page, codeField, startedAt } const pendingLoginByUser = new Map(); // userId -> { browser, page, codeField, startedAt }
const queueByUser = new Map(); // userId -> Promise (Serialisierung je Nutzer) 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) { function getCache(userId) {
if (!cacheByUser.has(userId)) cacheByUser.set(userId, { data: null, fetchedAt: 0, error: null }); if (!cacheByUser.has(userId)) cacheByUser.set(userId, { data: null, fetchedAt: 0, error: null });
@@ -110,18 +111,51 @@ function mapStatisticalData(parsed) {
} }
// ── Browser-Helfer ──────────────────────────────────────────────────────────── // ── 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) { async function launchBrowser(userId) {
let puppeteer; let puppeteer;
try { puppeteer = require('puppeteer-core'); } try { puppeteer = require('puppeteer-core'); }
catch { throw new Error('puppeteer-core nicht installiert'); } catch { throw new Error('puppeteer-core nicht installiert'); }
const profileDir = `${PROFILE_BASE_DIR}/${userId}`; const profileDir = `${PROFILE_BASE_DIR}/${userId}`;
fs.mkdirSync(profileDir, { recursive: true }); fs.mkdirSync(profileDir, { recursive: true });
return puppeteer.launch({ cleanupStaleLocks(profileDir);
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium-browser', executablePath: '/usr/bin/chromium-browser',
userDataDir: profileDir, userDataDir: profileDir,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
headless: true, 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) { async function getNextDataFromPage(page) {
@@ -309,9 +343,12 @@ async function injectCookiesInner(userId, cookieString) {
} }
} }
const fetchStatsViaBrowser = (userId, forceRefresh) => serialized(userId, () => fetchStatsViaBrowserInner(userId, forceRefresh)); const fetchStatsViaBrowser = (userId, forceRefresh) =>
const submitLoginCode = (userId, code) => serialized(userId, () => submitLoginCodeInner(userId, code)); serialized(userId, () => withHardTimeout(fetchStatsViaBrowserInner(userId, forceRefresh), 60000, () => forceKillBrowser(userId)));
const injectCookies = (userId, cookieString) => serialized(userId, () => injectCookiesInner(userId, cookieString)); 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) ── // ── Routes (alle nur "authenticate" — jeder verwaltet ausschließlich sich selbst) ──
@@ -322,20 +359,39 @@ router.get('/stats', authenticate, async (req, res) => {
if (!configured) { if (!configured) {
return res.json({ configured: false, data: null, error: null, fetchedAt: null, needsCode: false }); return res.json({ configured: false, data: null, error: null, fetchedAt: null, needsCode: false });
} }
try {
const result = await fetchStatsViaBrowser(userId, false); const result = await fetchStatsViaBrowser(userId, false);
res.json({ configured: true, ...result }); 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) => { router.post('/refresh', authenticate, async (req, res) => {
const result = await fetchStatsViaBrowser(req.user.id, true); const userId = req.user.id;
const cache = getCache(userId);
try {
const result = await fetchStatsViaBrowser(userId, true);
res.json(result); 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) => { router.post('/verify-code', authenticate, async (req, res) => {
const { code } = req.body; const { code } = req.body;
if (!code || typeof code !== 'string') return res.status(400).json({ error: 'Code fehlt' }); if (!code || typeof code !== 'string') return res.status(400).json({ error: 'Code fehlt' });
const result = await submitLoginCode(req.user.id, code.trim()); const userId = req.user.id;
const cache = getCache(userId);
try {
const result = await submitLoginCode(userId, code.trim());
res.json(result); 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) => { 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) => { router.post('/credentials', authenticate, async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' }); if (!username || !password) return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
saveCredentials(req.user.id, username.trim(), password); const userId = req.user.id;
const result = await fetchStatsViaBrowser(req.user.id, true); saveCredentials(userId, username.trim(), password);
const cache = getCache(userId);
try {
const result = await fetchStatsViaBrowser(userId, true);
res.json(result); 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) // Eigene Zugangsdaten entfernen (Profil/Cookies bleiben unangetastet liegen)