diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 2dfb1da..cebe1d0 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -31,6 +31,21 @@ ENV NODE_ENV=production ENV DATABASE_PATH=/data/launchpad.db ENV PORT=3001 ENV HOST=0.0.0.0 +# node:dns/promises (lookup/reverse, siehe scanner/dns.ts) läuft NICHT über +# normales nicht-blockierendes I/O, sondern über getaddrinfo() im +# Betriebssystem - Node schickt das über den libuv-Threadpool ab, der +# standardmäßig nur 4 Threads hat. Beim Sammel-Scan mit bis zu 10 parallel +# gescannten Geräten (siehe routes/scan.ts CONCURRENT_DEVICES) und je bis zu +# 4 DNS-Abfragen pro Gerät reichte das nicht annähernd und führte zu einem +# Rückstau, der wie ein hängender Sammel-Scan aussah (siehe Bugreport - Scan +# blieb nach erfolgreichem masscan-/httpx-Vorabscan minutenlang in der +# eigentlichen Geräte-Schleife stehen). MUSS hier als Umgebungsvariable +# gesetzt werden, nicht im JS-Code selbst: das Projekt läuft als natives ESM +# ("type": "module"), dort werden alle import-Anweisungen VOR jeglichem +# sonstigen Code im Modul ausgeführt (auch wenn dieser Code textuell davor +# steht) - ein Setzen von process.env.UV_THREADPOOL_SIZE am Dateianfang von +# index.ts käme damit zu spät. +ENV UV_THREADPOOL_SIZE=32 # iputils-ping stellt einen "echten" ping-Befehl mit ICMP-Unterstützung # bereit (nicht nur die BusyBox-Variante) - für den optionalen Live-Status- diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index c9366a1..f1e7bbd 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -372,7 +372,12 @@ export async function scanRoutes(app: FastifyInstance): Promise { break; } const batch = devices.slice(i, i + CONCURRENT_DEVICES); - updateJobProgress("devices", scannedCount, batch.map((d) => d.hostname).join(", ")); + const stillRunning = new Set(batch.map((d) => d.hostname)); + + const renderBatchLabel = () => + `${batch.map((d) => (stillRunning.has(d.hostname) ? d.hostname : `✓ ${d.hostname}`)).join(", ")}`; + + updateJobProgress("devices", scannedCount, renderBatchLabel()); await Promise.all( batch.map(async (device) => { @@ -382,6 +387,7 @@ export async function scanRoutes(app: FastifyInstance): Promise { // Geräte-Seite) - überspringen statt zu überlappen, sonst // könnten beide gleichzeitig denselben neuen Dienst anlegen // wollen. + stillRunning.delete(device.hostname); return; } try { @@ -428,6 +434,15 @@ export async function scanRoutes(app: FastifyInstance): Promise { }); } finally { unlockDevice(device.id); + // Live sichtbar machen, WELCHES Gerät im aktuellen Batch + // schon fertig ist ("✓ Name") und welche noch laufen - + // bisher aktualisierte sich die Anzeige erst wieder, wenn + // ALLE 10 Geräte des Batches fertig waren. Blieb eines davon + // hängen, sah der komplette Batch wie eingefroren aus, ohne + // erkennbar zu machen, WELCHES Gerät das eigentlich betraf + // (siehe Bugreport). + stillRunning.delete(device.hostname); + updateJobProgress("devices", scannedCount, renderBatchLabel()); } }) ); diff --git a/apps/backend/src/scanner/dns.ts b/apps/backend/src/scanner/dns.ts index caa89e4..601aefc 100644 --- a/apps/backend/src/scanner/dns.ts +++ b/apps/backend/src/scanner/dns.ts @@ -5,19 +5,57 @@ export interface DnsResolution { ip: string; } +// GEFUNDENER FLASCHENHALS (round55): dns.lookup()/dns.reverse() aus +// node:dns/promises laufen NICHT direkt asynchron über den Netzwerk-Stack, +// sondern über getaddrinfo() im Betriebssystem - Node schickt das über den +// libuv-Threadpool ab, der standardmäßig nur 4 Threads hat +// (UV_THREADPOOL_SIZE=4), UNABHÄNGIG von Node's eigentlich nicht-blockierendem +// I/O-Modell. Ohne eigenen Timeout kann eine einzelne langsame/hängende +// Abfrage (z. B. für Handys/Smart-Geräte mit FritzBox-Spitznamen wie +// "S24-Ultra-von-Sebastian", die weder per normaler DNS noch per .home/.local +// auflösbar sind, aber je nach Resolver/Musl-libc im Alpine-Image erst nach +// langer Zeit "nicht gefunden" zurückmelden) einen der 4 Threads über +// Sekunden bis Minuten blockieren. Bei bis zu 10 Geräten gleichzeitig (siehe +// routes/scan.ts CONCURRENT_DEVICES) mit je bis zu 4 DNS-Abfragen (3x +// resolveHostname-Varianten + 1x reverseLookup) stauen sich die restlichen +// Abfragen dann VOR dem 4er-Nadelöhr - das erklärt einen Sammel-Scan, der +// nach einem erfolgreichen masscan-/httpx-Vorabscan plötzlich minutenlang in +// der eigentlichen Geräte-Schleife hängt, obwohl dort eigentlich nur noch +// leichte HTTP-Detailabfragen laufen sollten (siehe Bugreport). +const DNS_TIMEOUT_MS = 1500; + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("DNS-Zeitüberschreitung")), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + } + ); + }); +} + /** * Versucht der Spezifikation folgend: hostname, hostname.home, hostname.local. - * Gibt die erste erfolgreich aufgelöste Variante zurück, sonst null. + * Gibt die erste erfolgreich aufgelöste Variante zurück, sonst null. Jeder + * einzelne Versuch ist auf DNS_TIMEOUT_MS begrenzt (siehe Kommentar oben) - + * eine hängende Abfrage blockiert damit höchstens 3 × 1,5s statt potenziell + * unbegrenzt. */ export async function resolveHostname(shortName: string): Promise { const candidates = [shortName, `${shortName}.home`, `${shortName}.local`]; for (const hostname of candidates) { try { - const { address } = await lookup(hostname); + const { address } = await withTimeout(lookup(hostname), DNS_TIMEOUT_MS); return { hostname, ip: address }; } catch { - // nächste Variante versuchen + // nächste Variante versuchen (Fehler ODER Timeout) } } @@ -29,11 +67,12 @@ export async function resolveHostname(shortName: string): Promise IP) nichts findet. Nützlich als - * zusätzliche Quelle für Gerätenamen, siehe scanner/networkScanner.ts. + * zusätzliche Quelle für Gerätenamen, siehe scanner/networkScanner.ts. Mit + * demselben Timeout abgesichert wie resolveHostname() oben. */ export async function reverseLookup(ip: string): Promise { try { - const names = await reverse(ip); + const names = await withTimeout(reverse(ip), DNS_TIMEOUT_MS); return names[0] ?? null; } catch { return null;