fix: MakerWorld haengender Puppeteer-Prozess - Lock-Cleanup + hartes Timeout
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user