fix(backend): IMAP ECONNRESET crash-loop + dm-push ESM interop
- mail/scan{,-internal}.post.ts + imap-idle: attach imap.on('error')
+ targeted uncaughtException/unhandledRejection guards so a
connection-level IMAP error (ECONNRESET / TLS disconnect) can no
longer propagate to a process-level uncaughtException and kill the
Nitro API (root cause of the staging 502 crash-loop)
- services/push.ts: lazy dynamic-import expo-server-sdk (singleton,
like voip-push.ts) to fix "Class extends value [object Module]"
(ESM/CJS undici interop) that broke DM push notifications;
+ nitro.config externals safety net
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1f20056ef3
commit
1493752634
@ -601,6 +601,19 @@ async function runSession(conn) {
|
||||
disableCompression: conn.imapHost.includes("office365"),
|
||||
});
|
||||
|
||||
// ImapFlow kann socket-level Errors (ECONNRESET, TLS-disconnect, ETIMEDOUT)
|
||||
// als EventEmitter-'error'-Event feuern — ZUSÄTZLICH zu oder STATT eines
|
||||
// rejected Promise. Ohne diesen Handler eskaliert Node zu uncaughtException
|
||||
// → Prozess-Exit → alle 44+ Sessions fallen gleichzeitig aus.
|
||||
// Dieser Handler absorbiert das Event account-lokal; der bestehende catch-Block
|
||||
// weiter unten greift für den Promise-Rejection-Pfad.
|
||||
imap.on("error", (err) => {
|
||||
logError(conn.email, "imap socket error (absorbed, reconnect-loop handles it)", err);
|
||||
// imap.close() hier ist safe: idempotent, löst idlePromise-rejection aus
|
||||
// → IDLE-Loop verlässt await idlePromise → runSession-catch greift.
|
||||
try { imap.close(); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// Referenz ablegen damit shutdown() darauf zugreifen kann
|
||||
const handle = sessions.get(conn.id);
|
||||
if (handle) handle.imap = imap;
|
||||
@ -876,6 +889,63 @@ async function shutdown(signal) {
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
|
||||
// ─── Letztes Netz: bekannte IMAP-Socket-Errors als uncaughtException ──────────
|
||||
// Obwohl per-Account imap.on('error') und try/catch die primären Abfanggräben
|
||||
// sind, kann ein Timing-Fenster (z.B. Error-Event feuert zwischen close() und
|
||||
// neuem imap-Objekt) doch auf Prozessebene ankommen.
|
||||
// Dieser Guard schluckt NUR bekannte IMAP/Socket-Fehler (ECONNRESET, ETIMEDOUT,
|
||||
// ECONNREFUSED, EPIPE, EHOSTUNREACH, TLS-Fehler). Alles andere (Bug im Daemon-
|
||||
// Code selbst) wird NICHT geschluckt — normaler uncaughtException-Exit bleibt.
|
||||
const IMAP_SOCKET_ERROR_CODES = new Set([
|
||||
"ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "EPIPE",
|
||||
"EHOSTUNREACH", "ENOTFOUND", "ENETUNREACH",
|
||||
]);
|
||||
|
||||
process.on("uncaughtException", (err, origin) => {
|
||||
const code = err?.code;
|
||||
const msg = err?.message ?? "";
|
||||
|
||||
const isKnownImapError =
|
||||
(code && IMAP_SOCKET_ERROR_CODES.has(code)) ||
|
||||
msg.includes("Client network socket disconnected") ||
|
||||
msg.includes("before secure TLS connection") ||
|
||||
msg.includes("ECONNRESET") ||
|
||||
msg.includes("socket hang up");
|
||||
|
||||
if (isKnownImapError) {
|
||||
console.error(
|
||||
`[idle] uncaughtException absorbed (known IMAP/socket error) — origin=${origin} code=${code ?? "?"}: ${msg}`,
|
||||
);
|
||||
// Daemon läuft weiter — Reconnect-Loop der betroffenen Session übernimmt
|
||||
return;
|
||||
}
|
||||
|
||||
// Unbekannter Fehler: normal crashen (kein stummes Schlucken von Bugs)
|
||||
console.error(`[idle] uncaughtException (non-IMAP, crashing) — origin=${origin}:`, err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
const msg = (reason instanceof Error ? reason.message : String(reason)) ?? "";
|
||||
const code = (reason instanceof Error ? /** @type {any} */ (reason).code : undefined);
|
||||
|
||||
const isKnownImapError =
|
||||
(code && IMAP_SOCKET_ERROR_CODES.has(code)) ||
|
||||
msg.includes("Client network socket disconnected") ||
|
||||
msg.includes("before secure TLS connection") ||
|
||||
msg.includes("ECONNRESET") ||
|
||||
msg.includes("socket hang up");
|
||||
|
||||
if (isKnownImapError) {
|
||||
console.error(
|
||||
`[idle] unhandledRejection absorbed (known IMAP/socket error): ${msg}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("[idle] unhandledRejection (non-IMAP):", reason);
|
||||
});
|
||||
|
||||
// ─── Startup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function sleep(ms) {
|
||||
|
||||
@ -10,11 +10,15 @@ export default defineNitroConfig({
|
||||
// Default-publicAssets greift nicht zuverlässig wenn srcDir auf "server" zeigt.
|
||||
publicAssets: [{ baseURL: "/", dir: "../public", maxAge: 60 * 60 }],
|
||||
|
||||
// Supabase + imapflow als external deps — nicht bundlen.
|
||||
// Supabase + imapflow + expo-server-sdk als external deps — nicht bundlen.
|
||||
// imapflow nutzt CJS-inherits-Pattern, bricht beim Bundlen zu ESM
|
||||
// ("superCtor.prototype must be of type object"). @parse/node-apn wird
|
||||
// in services/voip-push.ts via dynamic import geladen (vermeidet das gleiche
|
||||
// Problem ohne Externalize-Eintrag).
|
||||
// expo-server-sdk v6+ ist type:"module" und importiert undici (CJS) intern via
|
||||
// ESM-Syntax. Nitro/Rollup kann diesen ESM→CJS-Interop nicht korrekt bundlen:
|
||||
// → `Class extends value [object Module] is not a constructor` in push.mjs.
|
||||
// Fix: external halten, läuft zur Laufzeit aus node_modules korrekt als ESM.
|
||||
externals: {
|
||||
// imapflow MUSS external bleiben — nutzt CJS-inherits-Pattern, bricht beim
|
||||
// Bundlen zu ESM ("superCtor.prototype must be of type object", util.inherits).
|
||||
@ -22,8 +26,14 @@ export default defineNitroConfig({
|
||||
// Specifier "imapflow", nicht auf aufgelöste node_modules-Pfade. Bei Module-Graph-
|
||||
// Shifts (z.B. neue Prisma-Felder) wurde imapflow doch inlined → scan-internal 500
|
||||
// (Incident 2026-06-05). Expliziter external-Eintrag mit Pfad-Regex erzwingt es robust.
|
||||
external: [/(^|[\\/]node_modules[\\/])imapflow([\\/]|$)/],
|
||||
inline: [/^(?!@supabase\/supabase-js)(?!imapflow)/],
|
||||
//
|
||||
// expo-server-sdk: dasselbe Robustness-Prinzip — Pfad-Regex statt reiner Lookahead,
|
||||
// weil Rollup bei transitivem Import-Graph den nackten Specifier nicht immer sieht.
|
||||
external: [
|
||||
/(^|[\\/]node_modules[\\/])imapflow([\\/]|$)/,
|
||||
/(^|[\\/]node_modules[\\/])expo-server-sdk([\\/]|$)/,
|
||||
],
|
||||
inline: [/^(?!@supabase\/supabase-js)(?!imapflow)(?!expo-server-sdk)/],
|
||||
},
|
||||
|
||||
imports: {
|
||||
|
||||
@ -94,6 +94,17 @@ export default defineEventHandler(async (event) => {
|
||||
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
|
||||
});
|
||||
|
||||
// ImapFlow kann socket-level Errors (ECONNRESET, TLS-disconnect) als EventEmitter-
|
||||
// 'error'-Event feuern — ZUSÄTZLICH zu oder STATT eines rejected Promise.
|
||||
// Ohne diesen Handler eskaliert Node das zu einem uncaughtException, das den
|
||||
// gesamten Prozess killt. Dieser Handler absorbiert es account-lokal.
|
||||
imap.on("error", (err: Error) => {
|
||||
console.error(
|
||||
`[scan-internal] imap socket error for ${connection.email} (absorbed, non-fatal):`,
|
||||
err?.message ?? err,
|
||||
);
|
||||
});
|
||||
|
||||
let scanned = 0;
|
||||
let newlyBlocked = 0;
|
||||
|
||||
|
||||
@ -82,6 +82,16 @@ export default defineEventHandler(async (event) => {
|
||||
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
|
||||
});
|
||||
|
||||
// Socket-level Errors (ECONNRESET, TLS-disconnect) können als EventEmitter-
|
||||
// 'error'-Event kommen statt als rejected Promise → uncaughtException ohne
|
||||
// diesen Handler.
|
||||
imap.on("error", (err: Error) => {
|
||||
console.error(
|
||||
`[scan] imap socket error for ${connection.email} (absorbed, non-fatal):`,
|
||||
err?.message ?? err,
|
||||
);
|
||||
});
|
||||
|
||||
let scanned = 0;
|
||||
let newlyBlocked = 0;
|
||||
|
||||
|
||||
@ -11,12 +11,33 @@
|
||||
* Token-Cleanup: Bei DeviceNotRegistered Receipts werden Tokens automatisch
|
||||
* disabled (PushToken.enabled = false) — Re-Enable nur durch Re-Registrierung
|
||||
* vom Client.
|
||||
*
|
||||
* IMPORT-HINWEIS: expo-server-sdk v6+ ist type:"module" und importiert undici (CJS)
|
||||
* intern via ESM-Syntax. Nitro/Rollup kann diesen ESM→CJS-Interop beim statischen
|
||||
* Bundlen nicht korrekt auflösen → `Class extends value [object Module] is not a
|
||||
* constructor` zur Laufzeit. Workaround: dynamic import (analog @parse/node-apn in
|
||||
* voip-push.ts), sodass das Modul erst zur Laufzeit aus node_modules geladen wird.
|
||||
*/
|
||||
import { Expo, type ExpoPushMessage } from "expo-server-sdk";
|
||||
import { usePrisma } from "../utils/prisma";
|
||||
import { sendVoIPPush } from "./voip-push";
|
||||
|
||||
const expo = new Expo();
|
||||
// expo-server-sdk lazy-geladen via dynamic import — verhindert Rollup-ESM/CJS-Interop-Bug.
|
||||
type ExpoModule = typeof import("expo-server-sdk");
|
||||
type ExpoInstance = InstanceType<ExpoModule["Expo"]>;
|
||||
type ExpoPushMessage = ExpoModule["ExpoPushMessage"];
|
||||
|
||||
let _expoMod: ExpoModule | null = null;
|
||||
let _expo: ExpoInstance | null = null;
|
||||
|
||||
async function ensureExpo(): Promise<{ expo: ExpoInstance; Expo: ExpoModule["Expo"] }> {
|
||||
if (_expo && _expoMod) return { expo: _expo, Expo: _expoMod.Expo };
|
||||
const mod = (await import("expo-server-sdk")) as unknown as ExpoModule & {
|
||||
default?: ExpoModule;
|
||||
};
|
||||
_expoMod = mod.default ?? mod;
|
||||
_expo = new _expoMod.Expo();
|
||||
return { expo: _expo, Expo: _expoMod.Expo };
|
||||
}
|
||||
|
||||
export interface ChatPushPayload {
|
||||
/** Empfänger-User-ID (kann mehrere Tokens haben) */
|
||||
@ -37,6 +58,7 @@ export interface ChatPushPayload {
|
||||
export async function sendChatPush(payload: ChatPushPayload): Promise<void> {
|
||||
try {
|
||||
const db = usePrisma();
|
||||
const { expo, Expo } = await ensureExpo();
|
||||
|
||||
// 1) Profile-Opt-out prüfen
|
||||
const profile = await db.profile.findUnique({
|
||||
@ -161,6 +183,7 @@ export async function sendDeviceAddedPush(
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = usePrisma();
|
||||
const { expo, Expo } = await ensureExpo();
|
||||
|
||||
const profile = await db.profile.findUnique({
|
||||
where: { id: payload.userId },
|
||||
@ -250,6 +273,7 @@ export interface CallRingPushPayload {
|
||||
export async function sendCallRingPush(payload: CallRingPushPayload): Promise<void> {
|
||||
try {
|
||||
const db = usePrisma();
|
||||
const { expo, Expo } = await ensureExpo();
|
||||
|
||||
const profile = await db.profile.findUnique({
|
||||
where: { id: payload.receiverId },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user