fix: harden diagnostics persistence and update downloads

This commit is contained in:
Sucukdeluxe
2026-08-31 01:52:11 +02:00
parent 9fa979fb4d
commit 50f04b2761
8 changed files with 1718 additions and 248 deletions
+100 -43
View File
@@ -76,6 +76,9 @@ let runtimeBaseDir = "";
let allowlist: string[] = [];
let requestLimits = new Map<string, { startedAt: number; count: number }>();
let notificationStatusProvider: (() => NotificationSupportPayload) | null = null;
let serverLifecycleQueue: Promise<void> = Promise.resolve();
let serverLifecycleGeneration = 0;
let debugServerEnabled = false;
export interface DebugServerRuntimeStatus {
running: boolean;
@@ -1171,13 +1174,19 @@ function openServerSocket(): Promise<void> {
}
const srv = http.createServer(handleRequest);
let settled = false;
const settle = (): void => {
if (!settled) {
settled = true;
resolve();
}
};
srv.on("error", (err: NodeJS.ErrnoException) => {
const settle = (): void => {
if (!settled) {
settled = true;
resolve();
}
};
srv.once("close", () => {
if (server === srv) {
server = null;
}
settle();
});
srv.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
logger.warn(`Debug-Server: Port ${bindPort} belegt (EADDRINUSE) - Server nicht gestartet`);
} else {
@@ -1193,9 +1202,49 @@ function openServerSocket(): Promise<void> {
settle();
});
server = srv;
});
}
});
}
function enqueueServerLifecycle(operation: () => Promise<void>): Promise<void> {
const pending = serverLifecycleQueue.then(operation, operation);
serverLifecycleQueue = pending.catch(() => undefined);
return pending;
}
function closeServerSocket(): Promise<void> {
const current = server;
if (!current) {
return Promise.resolve();
}
server = null;
return new Promise((resolve) => {
let settled = false;
let timeout: NodeJS.Timeout | null = null;
const done = (): void => {
if (settled) {
return;
}
settled = true;
if (timeout) {
clearTimeout(timeout);
}
resolve();
};
try {
current.close(() => done());
} catch {
done();
}
try {
current.closeAllConnections?.();
} catch {
}
if (!settled) {
timeout = setTimeout(done, 1500);
}
});
}
export function startDebugServer(
mgr: DownloadManager,
baseDir: string,
@@ -1204,32 +1253,34 @@ export function startDebugServer(
runtimeBaseDir = baseDir;
manager = mgr;
notificationStatusProvider = readCurrentNotificationStatus || null;
void openServerSocket();
debugServerEnabled = true;
const generation = ++serverLifecycleGeneration;
void enqueueServerLifecycle(async () => {
if (!debugServerEnabled || generation !== serverLifecycleGeneration) {
return;
}
await closeServerSocket();
if (!debugServerEnabled || generation !== serverLifecycleGeneration) {
return;
}
await openServerSocket();
});
}
export async function restartDebugServer(): Promise<DebugServerRuntimeStatus> {
const generation = serverLifecycleGeneration;
await enqueueServerLifecycle(async () => {
if (!debugServerEnabled || generation !== serverLifecycleGeneration) {
return;
}
await closeServerSocket();
if (!debugServerEnabled || generation !== serverLifecycleGeneration) {
return;
}
await openServerSocket();
});
return getDebugServerRuntimeStatus();
}
export async function restartDebugServer(): Promise<DebugServerRuntimeStatus> {
const old = server;
if (old) {
server = null;
await new Promise<void>((resolve) => {
let settled = false;
const done = (): void => {
if (!settled) {
settled = true;
resolve();
}
};
old.close(() => done());
try {
old.closeAllConnections?.();
} catch {
}
setTimeout(done, 1500);
});
}
await openServerSocket();
return getDebugServerRuntimeStatus();
}
export function getDebugServerRuntimeStatus(): DebugServerRuntimeStatus {
return {
@@ -1273,15 +1324,21 @@ export function clearDebugToken(): void {
}
export function stopDebugServer(): void {
debugServerEnabled = false;
serverLifecycleGeneration += 1;
notificationStatusProvider = null;
if (server) {
server.close();
try {
server.closeAllConnections?.();
} catch {
}
server = null;
requestLimits = new Map();
const current = server;
server = null;
requestLimits = new Map();
if (current) {
try {
current.close();
} catch {
}
try {
current.closeAllConnections?.();
} catch {
}
logger.info("Debug-Server gestoppt");
}
}
+321 -112
View File
@@ -75,16 +75,30 @@ function isPathInsideDir(filePath: string, dirPath: string): boolean {
}
}
function normalizeSessionTargetPath(value: unknown, packageOutputDir: string): string {
const targetPath = asText(value);
if (!targetPath || !packageOutputDir || !path.isAbsolute(targetPath)) {
function normalizeSessionTargetPath(value: unknown, packageOutputDir: string): string {
const targetPath = asText(value);
if (!targetPath || !packageOutputDir || !path.isAbsolute(targetPath)) {
return "";
}
if (!isPathInsideDir(targetPath, packageOutputDir)) {
return "";
}
return path.resolve(targetPath);
}
return path.resolve(targetPath);
}
function normalizeSessionMetadataRenameTargetPath(value: unknown, packageOutputDir: string): string {
if (!path.isAbsolute(packageOutputDir)) {
return "";
}
const targetPath = normalizeSessionTargetPath(value, packageOutputDir);
if (!targetPath) {
return "";
}
const resolvedOutputDir = path.resolve(packageOutputDir);
const normalizedTargetPath = process.platform === "win32" ? targetPath.toLowerCase() : targetPath;
const normalizedOutputDir = process.platform === "win32" ? resolvedOutputDir.toLowerCase() : resolvedOutputDir;
return normalizedTargetPath === normalizedOutputDir ? "" : targetPath;
}
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
const num = Number(value);
@@ -907,9 +921,16 @@ function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
return parsed;
}
const preferApi = parsed.megaDebridPreferApi !== undefined ? Boolean(parsed.megaDebridPreferApi) : true;
return { ...parsed, megaDebridApiEnabled: preferApi, megaDebridWebEnabled: !preferApi };
}
return { ...parsed, megaDebridApiEnabled: preferApi, megaDebridWebEnabled: !preferApi };
}
const PERSISTED_SETTINGS_KEYS = new Set(Object.keys(defaultSettings()));
function isPersistedSettingsEnvelope(value: unknown): boolean {
const parsed = asRecord(value);
return Boolean(parsed && Object.keys(parsed).some((key) => PERSISTED_SETTINGS_KEYS.has(key)));
}
interface LoadedSettingsFile {
settings: AppSettings;
needsCredentialRewrite: boolean;
@@ -917,7 +938,12 @@ interface LoadedSettingsFile {
function readSettingsFile(filePath: string): LoadedSettingsFile | null {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings;
const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
if (!isPersistedSettingsEnvelope(raw)) {
logger.error(`Settings-Datei beschädigt (Struktur ungültig): ${filePath}`);
return null;
}
const parsed = raw as AppSettings;
const needsCredentialRewrite = needsPersistedSettingsRewrite(parsed);
const restored = restorePersistedSettings(parsed);
const migratedLanguage = (restored as Partial<AppSettings>).language === undefined ? "de" : restored.language;
@@ -1001,10 +1027,11 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
speedBps: clampNumber(item.speedBps, 0, 0, 10_000_000_000),
downloadedBytes: clampNumber(item.downloadedBytes, 0, 0, 10_000_000_000_000),
totalBytes: item.totalBytes == null ? null : clampNumber(item.totalBytes, 0, 0, 10_000_000_000_000),
progressPercent: clampNumber(item.progressPercent, 0, 0, 100),
fileName: asText(item.fileName) || "download.bin",
targetPath: asText(item.targetPath),
resumable: item.resumable === undefined ? true : Boolean(item.resumable),
progressPercent: clampNumber(item.progressPercent, 0, 0, 100),
fileName: asText(item.fileName) || "download.bin",
targetPath: asText(item.targetPath),
metadataRenameTargetPath: asText(item.metadataRenameTargetPath) || undefined,
resumable: item.resumable === undefined ? true : Boolean(item.resumable),
attempts: clampNumber(item.attempts, 0, 0, 10_000),
lastError: asText(item.lastError),
fullStatus: asText(item.fullStatus),
@@ -1083,21 +1110,35 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`);
}
let droppedUnsafeTargetPathCount = 0;
for (const item of Object.values(itemsById)) {
const pkg = packagesById[item.packageId];
if (!pkg) {
let droppedUnsafeTargetPathCount = 0;
let droppedUnsafeMetadataRenameTargetPathCount = 0;
for (const item of Object.values(itemsById)) {
const pkg = packagesById[item.packageId];
if (!pkg) {
continue;
}
const safeTargetPath = normalizeSessionTargetPath(item.targetPath, pkg.outputDir);
if (!safeTargetPath && asText(item.targetPath)) {
droppedUnsafeTargetPathCount += 1;
}
item.targetPath = safeTargetPath;
}
if (droppedUnsafeTargetPathCount > 0) {
logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`);
}
droppedUnsafeTargetPathCount += 1;
}
item.targetPath = safeTargetPath;
const metadataRenameTargetPath = asText(item.metadataRenameTargetPath);
const safeMetadataRenameTargetPath = normalizeSessionMetadataRenameTargetPath(metadataRenameTargetPath, pkg.outputDir);
if (metadataRenameTargetPath && !safeMetadataRenameTargetPath) {
droppedUnsafeMetadataRenameTargetPathCount += 1;
}
if (safeMetadataRenameTargetPath) {
item.metadataRenameTargetPath = safeMetadataRenameTargetPath;
} else {
delete item.metadataRenameTargetPath;
}
}
if (droppedUnsafeTargetPathCount > 0) {
logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`);
}
if (droppedUnsafeMetadataRenameTargetPathCount > 0) {
logger.warn(`normalizeLoadedSession: ${droppedUnsafeMetadataRenameTargetPathCount} unsichere metadataRenameTargetPath-Einträge verworfen`);
}
for (const pkg of Object.values(packagesById)) {
pkg.itemIds = pkg.itemIds.filter((itemId) => {
@@ -1142,14 +1183,13 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
}
export function loadSettings(paths: StoragePaths): AppSettings {
ensureBaseDir(paths.baseDir);
if (!fs.existsSync(paths.configFile)) {
return defaultSettings();
}
const loaded = readSettingsFile(paths.configFile);
ensureBaseDir(paths.baseDir);
const primaryExists = fs.existsSync(paths.configFile);
const backupFile = `${paths.configFile}.bak`;
const backupExists = fs.existsSync(backupFile);
const loaded = primaryExists ? readSettingsFile(paths.configFile) : null;
if (loaded) {
const backupFile = `${paths.configFile}.bak`;
const backupNeedsCredentialRewrite = fs.existsSync(backupFile)
const backupNeedsCredentialRewrite = backupExists
? needsSettingsFileCredentialRewrite(backupFile)
: false;
if (loaded.needsCredentialRewrite || backupNeedsCredentialRewrite) {
@@ -1158,17 +1198,20 @@ export function loadSettings(paths: StoragePaths): AppSettings {
return loaded.settings;
}
const backupFile = `${paths.configFile}.bak`;
const backupLoaded = fs.existsSync(backupFile) ? readSettingsFile(backupFile) : null;
const backupLoaded = backupExists ? readSettingsFile(backupFile) : null;
if (backupLoaded) {
logger.warn("Konfiguration defekt, Backup-Datei wird verwendet");
logger.warn(primaryExists
? "Konfiguration defekt, Backup-Datei wird verwendet"
: "Konfiguration fehlt, Backup-Datei wird verwendet");
rewriteProtectedSettings(paths, backupLoaded.settings);
return backupLoaded.settings;
}
logger.error("Konfiguration konnte nicht geladen werden (auch Backup fehlgeschlagen)");
return defaultSettings();
}
}
if (primaryExists || backupExists) {
logger.error("Konfiguration konnte nicht geladen werden (auch Backup fehlgeschlagen)");
}
return defaultSettings();
}
function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void {
try {
@@ -1282,6 +1325,16 @@ interface LoadedSessionFile {
wasRunning: boolean;
}
function isPersistedSessionEnvelope(value: unknown): boolean {
const parsed = asRecord(value);
return Boolean(
parsed
&& Array.isArray(parsed.packageOrder)
&& asRecord(parsed.packages)
&& asRecord(parsed.items)
);
}
function readSessionFile(filePath: string): LoadedSessionFile | null {
let raw: string | null = null;
const maxAttempts = 5;
@@ -1307,9 +1360,13 @@ function readSessionFile(filePath: string): LoadedSessionFile | null {
}
if (raw === null) {
return null;
}
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!isPersistedSessionEnvelope(parsed)) {
logger.error(`Session-Datei beschädigt (Struktur ungültig): ${filePath}`);
return null;
}
const normalized = normalizeLoadedSession(parsed);
const wasRunning = normalized.running;
const session = normalizeLoadedSessionTransientFields(normalized);
@@ -1331,8 +1388,19 @@ export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
writeSettingsFileAtomically(paths.configFile, payloads.primary);
}
let asyncSettingsSaveRunning = false;
let asyncSettingsSaveQueued: { paths: StoragePaths; settings: AppSettings; generation: number } | null = null;
interface AsyncSaveWaiter {
resolve: () => void;
reject: (error: unknown) => void;
}
interface QueuedSettingsSave {
paths: StoragePaths;
settings: AppSettings;
generation: number;
waiters: AsyncSaveWaiter[];
}
let asyncSettingsSaveQueued: QueuedSettingsSave | null = null;
let syncSettingsSaveGeneration = 0;
let activeSettingsSave: Promise<void> | null = null;
@@ -1364,27 +1432,52 @@ async function writeSettingsPayload(paths: StoragePaths, settings: AppSettings,
}
}
async function saveSettingsPayloadAsync(paths: StoragePaths, settings: AppSettings, generation: number): Promise<void> {
if (asyncSettingsSaveRunning) {
asyncSettingsSaveQueued = { paths, settings, generation };
function startSettingsSaveDrain(): void {
if (activeSettingsSave) {
return;
}
asyncSettingsSaveRunning = true;
const operation = writeSettingsPayload(paths, settings, generation).catch((error) => {
logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`);
}).finally(() => {
asyncSettingsSaveRunning = false;
let operation: Promise<void>;
operation = (async () => {
while (asyncSettingsSaveQueued) {
const current = asyncSettingsSaveQueued;
asyncSettingsSaveQueued = null;
try {
await writeSettingsPayload(current.paths, current.settings, current.generation);
current.waiters.forEach((waiter) => waiter.resolve());
} catch (error) {
current.waiters.forEach((waiter) => waiter.reject(error));
const pending = asyncSettingsSaveQueued as QueuedSettingsSave | null;
asyncSettingsSaveQueued = null;
pending?.waiters.forEach((waiter) => waiter.reject(error));
logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`);
throw error;
}
}
})().finally(() => {
if (activeSettingsSave === operation) {
activeSettingsSave = null;
}
if (asyncSettingsSaveQueued) {
const queued = asyncSettingsSaveQueued;
asyncSettingsSaveQueued = null;
void saveSettingsPayloadAsync(queued.paths, queued.settings, queued.generation);
startSettingsSaveDrain();
}
});
activeSettingsSave = operation;
await operation;
void operation.catch(() => {});
}
function saveSettingsPayloadAsync(paths: StoragePaths, settings: AppSettings, generation: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const waiter = { resolve, reject };
if (asyncSettingsSaveQueued) {
asyncSettingsSaveQueued.paths = paths;
asyncSettingsSaveQueued.settings = settings;
asyncSettingsSaveQueued.generation = generation;
asyncSettingsSaveQueued.waiters.push(waiter);
} else {
asyncSettingsSaveQueued = { paths, settings, generation, waiters: [waiter] };
}
startSettingsSaveDrain();
});
}
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
@@ -1446,25 +1539,8 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
const primary = primaryExists ? readSessionFile(paths.sessionFile) : null;
if (primary) {
const primaryPkgCount = Object.keys(primary.session.packages).length;
if (primaryPkgCount === 0 && backupExists) {
const backup = readSessionFile(backupFile);
if (backup) {
const backupPkgCount = Object.keys(backup.session.packages).length;
if (backupPkgCount > 0) {
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
try {
const payload = JSON.stringify({ ...backup.session, updatedAt: Date.now() }, safeJsonReplacer);
fs.writeFileSync(syncTempFile, payload, "utf8");
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
} catch {
}
return { session: backup.session, status: "recovered-backup", wasRunning: backup.wasRunning };
}
}
}
return { session: primary.session, status: "ok", wasRunning: primary.wasRunning };
}
}
const backup = backupExists ? readSessionFile(backupFile) : null;
if (backup) {
@@ -1532,8 +1608,14 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
}
}
let asyncSaveRunning = false;
let asyncSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null;
interface QueuedSessionSave {
paths: StoragePaths;
payload: string;
generation: number;
waiters: AsyncSaveWaiter[];
}
let asyncSaveQueued: QueuedSessionSave | null = null;
let syncSaveGeneration = 0;
let activeSessionSave: Promise<void> | null = null;
@@ -1568,27 +1650,52 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
}
}
async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
if (asyncSaveRunning) {
asyncSaveQueued = { paths, payload, generation };
function startSessionSaveDrain(): void {
if (activeSessionSave) {
return;
}
asyncSaveRunning = true;
const operation = writeSessionPayload(paths, payload, generation).catch((error) => {
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
}).finally(() => {
asyncSaveRunning = false;
let operation: Promise<void>;
operation = (async () => {
while (asyncSaveQueued) {
const current = asyncSaveQueued;
asyncSaveQueued = null;
try {
await writeSessionPayload(current.paths, current.payload, current.generation);
current.waiters.forEach((waiter) => waiter.resolve());
} catch (error) {
current.waiters.forEach((waiter) => waiter.reject(error));
const pending = asyncSaveQueued as QueuedSessionSave | null;
asyncSaveQueued = null;
pending?.waiters.forEach((waiter) => waiter.reject(error));
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
throw error;
}
}
})().finally(() => {
if (activeSessionSave === operation) {
activeSessionSave = null;
}
if (asyncSaveQueued) {
const queued = asyncSaveQueued;
asyncSaveQueued = null;
void saveSessionPayloadAsync(queued.paths, queued.payload, queued.generation);
startSessionSaveDrain();
}
});
activeSessionSave = operation;
await operation;
void operation.catch(() => {});
}
function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const waiter = { resolve, reject };
if (asyncSaveQueued) {
asyncSaveQueued.paths = paths;
asyncSaveQueued.payload = payload;
asyncSaveQueued.generation = generation;
asyncSaveQueued.waiters.push(waiter);
} else {
asyncSaveQueued = { paths, payload, generation, waiters: [waiter] };
}
startSessionSaveDrain();
});
}
interface BlockedSettingsSave {
@@ -1616,6 +1723,47 @@ export interface PersistenceBarrier {
let activePersistenceBarrier: PersistenceBarrierState | null = null;
interface DetachedAsyncSaves {
settings: QueuedSettingsSave | null;
session: QueuedSessionSave | null;
}
function detachPendingAsyncSaves(): DetachedAsyncSaves {
const detached = {
settings: asyncSettingsSaveQueued,
session: asyncSaveQueued
};
asyncSettingsSaveQueued = null;
asyncSaveQueued = null;
return detached;
}
function rejectDetachedAsyncSaves(detached: DetachedAsyncSaves, error: unknown): void {
detached.settings?.waiters.forEach((waiter) => waiter.reject(error));
detached.session?.waiters.forEach((waiter) => waiter.reject(error));
}
function requeueDetachedAsyncSaves(detached: DetachedAsyncSaves): void {
if (detached.settings) {
detached.settings.generation = syncSettingsSaveGeneration;
if (asyncSettingsSaveQueued) {
asyncSettingsSaveQueued.waiters.unshift(...detached.settings.waiters);
} else {
asyncSettingsSaveQueued = detached.settings;
}
startSettingsSaveDrain();
}
if (detached.session) {
detached.session.generation = syncSaveGeneration;
if (asyncSaveQueued) {
asyncSaveQueued.waiters.unshift(...detached.session.waiters);
} else {
asyncSaveQueued = detached.session;
}
startSessionSaveDrain();
}
}
function blockSettingsSave(state: PersistenceBarrierState, paths: StoragePaths, settings: AppSettings): Promise<void> {
return new Promise<void>((resolve, reject) => {
const waiter = { resolve, reject };
@@ -1643,28 +1791,61 @@ function blockSessionSave(state: PersistenceBarrierState, paths: StoragePaths, p
}
async function replayBlockedSaves(state: PersistenceBarrierState): Promise<void> {
let firstError: unknown;
let failed = false;
while (state.blockedSettings || state.blockedSession) {
const blockedSettings = state.blockedSettings;
const blockedSession = state.blockedSession;
state.blockedSettings = null;
state.blockedSession = null;
try {
await Promise.all([
blockedSettings
? saveSettingsPayloadAsync(blockedSettings.paths, blockedSettings.settings, syncSettingsSaveGeneration)
: Promise.resolve(),
blockedSession
? saveSessionPayloadAsync(blockedSession.paths, blockedSession.payload, syncSaveGeneration)
: Promise.resolve()
]);
blockedSettings?.waiters.forEach((waiter) => waiter.resolve());
blockedSession?.waiters.forEach((waiter) => waiter.resolve());
} catch (error) {
blockedSettings?.waiters.forEach((waiter) => waiter.reject(error));
blockedSession?.waiters.forEach((waiter) => waiter.reject(error));
throw error;
const [settingsResult, sessionResult] = await Promise.allSettled([
blockedSettings
? saveSettingsPayloadAsync(blockedSettings.paths, blockedSettings.settings, syncSettingsSaveGeneration)
: Promise.resolve(),
blockedSession
? saveSessionPayloadAsync(blockedSession.paths, blockedSession.payload, syncSaveGeneration)
: Promise.resolve()
]);
if (blockedSettings) {
if (settingsResult.status === "fulfilled") {
blockedSettings.waiters.forEach((waiter) => waiter.resolve());
} else {
blockedSettings.waiters.forEach((waiter) => waiter.reject(settingsResult.reason));
if (!failed) {
firstError = settingsResult.reason;
failed = true;
}
}
}
if (blockedSession) {
if (sessionResult.status === "fulfilled") {
blockedSession.waiters.forEach((waiter) => waiter.resolve());
} else {
blockedSession.waiters.forEach((waiter) => waiter.reject(sessionResult.reason));
if (!failed) {
firstError = sessionResult.reason;
failed = true;
}
}
}
}
if (failed) {
throw firstError;
}
}
function rejectBlockedSaves(state: PersistenceBarrierState, error: unknown): void {
state.blockedSettings?.waiters.forEach((waiter) => waiter.reject(error));
state.blockedSession?.waiters.forEach((waiter) => waiter.reject(error));
state.blockedSettings = null;
state.blockedSession = null;
}
function resolveBlockedSaves(state: PersistenceBarrierState): void {
state.blockedSettings?.waiters.forEach((waiter) => waiter.resolve());
state.blockedSession?.waiters.forEach((waiter) => waiter.resolve());
state.blockedSettings = null;
state.blockedSession = null;
}
export async function acquirePersistenceBarrier(): Promise<PersistenceBarrier> {
@@ -1680,19 +1861,46 @@ export async function acquirePersistenceBarrier(): Promise<PersistenceBarrier> {
resolveReleased
};
activePersistenceBarrier = state;
cancelPendingAsyncSaves();
await Promise.all([activeSettingsSave ?? Promise.resolve(), activeSessionSave ?? Promise.resolve()]);
const detachedSaves = detachPendingAsyncSaves();
const activeWriteResults = await Promise.allSettled([
activeSettingsSave ?? Promise.resolve(),
activeSessionSave ?? Promise.resolve()
]);
const activeWriteFailure = activeWriteResults.find((result) => result.status === "rejected");
if (activeWriteFailure?.status === "rejected") {
rejectBlockedSaves(state, activeWriteFailure.reason);
if (activePersistenceBarrier === state) {
activePersistenceBarrier = null;
}
requeueDetachedAsyncSaves(detachedSaves);
state.resolveReleased();
throw activeWriteFailure.reason;
}
rejectDetachedAsyncSaves(detachedSaves, new Error("Persistenzbarriere hat ausstehenden Save verworfen"));
let finished = false;
return {
release: async ({ replayBlocked }) => {
if (finished) return;
finished = true;
let replayError: unknown;
let replayFailed = false;
try {
if (replayBlocked) {
await replayBlockedSaves(state);
do {
try {
await replayBlockedSaves(state);
} catch (error) {
if (!replayFailed) {
replayError = error;
replayFailed = true;
}
}
} while (state.blockedSettings || state.blockedSession);
} else {
state.blockedSettings?.waiters.forEach((waiter) => waiter.resolve());
state.blockedSession?.waiters.forEach((waiter) => waiter.resolve());
resolveBlockedSaves(state);
}
if (replayFailed) {
throw replayError;
}
} finally {
if (activePersistenceBarrier === state) {
@@ -1704,12 +1912,13 @@ export async function acquirePersistenceBarrier(): Promise<PersistenceBarrier> {
};
}
export function cancelPendingAsyncSaves(): void {
asyncSaveQueued = null;
asyncSettingsSaveQueued = null;
syncSaveGeneration += 1;
syncSettingsSaveGeneration += 1;
}
export function cancelPendingAsyncSaves(): void {
syncSettingsSaveGeneration += 1;
syncSaveGeneration += 1;
const detached = detachPendingAsyncSaves();
detached.session?.waiters.forEach((waiter) => waiter.resolve());
detached.settings?.waiters.forEach((waiter) => waiter.resolve());
}
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
+175 -49
View File
@@ -698,14 +698,138 @@ async function downloadFile(
reportProgress(true);
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
const tempPath = `${targetPath}.tmp`;
const writeStream = fs.createWriteStream(tempPath);
const reader = response.body.getReader();
const idleMs = getBodyIdleTimeout();
let idleTimer: NodeJS.Timeout | null = null;
let idleTimedOut = false;
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
const tempPath = `${targetPath}.tmp`;
const writeStream = fs.createWriteStream(tempPath);
const reader = response.body.getReader();
let writeError: unknown = null;
const idleMs = getBodyIdleTimeout();
const idleAbortController = new AbortController();
let idleTimer: NodeJS.Timeout | null = null;
let idleTimedOut = false;
const idleTimeoutError = (): Error => new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`);
const onWriteError = (error: unknown): void => {
writeError = error;
void reader.cancel().catch(() => undefined);
};
const onWriteClose = (): void => {
writeStream.removeListener("error", onWriteError);
};
writeStream.on("error", onWriteError);
writeStream.once("close", onWriteClose);
const waitForDrain = async (): Promise<void> => {
if (writeError !== null) throw writeError;
if (idleTimedOut) throw idleTimeoutError();
if (shutdown?.aborted) throw new Error("aborted:update_shutdown");
await new Promise<void>((resolve, reject) => {
let settled = false;
const cleanup = (): void => {
writeStream.removeListener("drain", onDrain);
writeStream.removeListener("error", onError);
idleAbortController.signal.removeEventListener("abort", onIdleTimeout);
shutdown?.removeEventListener("abort", onAbort);
};
const settle = (error?: unknown): void => {
if (settled) return;
settled = true;
cleanup();
if (error !== undefined) {
reject(error);
} else {
resolve();
}
};
const onDrain = (): void => settle();
const onError = (error: unknown): void => settle(error);
const onIdleTimeout = (): void => settle(idleTimeoutError());
const onAbort = (): void => settle(new Error("aborted:update_shutdown"));
writeStream.once("drain", onDrain);
writeStream.once("error", onError);
idleAbortController.signal.addEventListener("abort", onIdleTimeout, { once: true });
shutdown?.addEventListener("abort", onAbort, { once: true });
if (writeError !== null) {
settle(writeError);
} else if (idleTimedOut) {
onIdleTimeout();
} else if (shutdown?.aborted) {
onAbort();
}
});
};
const finishWriting = async (): Promise<void> => {
if (writeError !== null) throw writeError;
if (idleTimedOut) throw idleTimeoutError();
if (shutdown?.aborted) throw new Error("aborted:update_shutdown");
await new Promise<void>((resolve, reject) => {
let settled = false;
let endSucceeded = false;
let closed = false;
const cleanup = (): void => {
writeStream.removeListener("error", onError);
writeStream.removeListener("close", onClose);
idleAbortController.signal.removeEventListener("abort", onIdleTimeout);
shutdown?.removeEventListener("abort", onAbort);
};
const settle = (error?: unknown): void => {
if (settled) return;
settled = true;
cleanup();
if (error !== undefined) {
reject(error);
} else {
resolve();
}
};
const onError = (error: unknown): void => settle(error);
const settleIfComplete = (): void => {
if (endSucceeded && closed) settle();
};
const onClose = (): void => {
closed = true;
if (writeError !== null) {
settle(writeError);
return;
}
settleIfComplete();
};
const onIdleTimeout = (): void => settle(idleTimeoutError());
const onAbort = (): void => settle(new Error("aborted:update_shutdown"));
writeStream.once("error", onError);
writeStream.once("close", onClose);
idleAbortController.signal.addEventListener("abort", onIdleTimeout, { once: true });
shutdown?.addEventListener("abort", onAbort, { once: true });
if (writeError !== null) {
settle(writeError);
} else if (idleTimedOut) {
onIdleTimeout();
} else if (shutdown?.aborted) {
onAbort();
} else {
try {
writeStream.end((error?: unknown) => {
if (error !== undefined && error !== null) {
settle(error);
} else {
endSucceeded = true;
settleIfComplete();
}
});
} catch (error) {
settle(error);
}
}
});
};
const clearIdle = (): void => {
if (idleTimer) {
@@ -717,51 +841,53 @@ async function downloadFile(
const resetIdle = (): void => {
clearIdle();
if (idleMs > 0) {
idleTimer = setTimeout(() => {
idleTimedOut = true;
reader.cancel().catch(() => undefined);
}, idleMs);
idleTimer = setTimeout(() => {
idleTimedOut = true;
idleAbortController.abort();
void reader.cancel().catch(() => undefined);
}, idleMs);
}
};
try {
resetIdle();
for (;;) {
if (shutdown?.aborted) {
await reader.cancel().catch(() => undefined);
throw new Error("aborted:update_shutdown");
}
const { done, value } = await reader.read();
if (done) break;
const buf = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
if (!writeStream.write(buf)) {
await new Promise<void>((resolve) => writeStream.once("drain", resolve));
}
downloadedBytes += buf.byteLength;
resetIdle();
reportProgress(false);
}
} catch (error) {
writeStream.destroy();
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
if (idleTimedOut) {
throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`);
}
throw error;
} finally {
clearIdle();
}
await new Promise<void>((resolve, reject) => {
writeStream.end(() => resolve());
writeStream.on("error", reject);
});
if (idleTimedOut) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`);
}
resetIdle();
for (;;) {
if (shutdown?.aborted) {
void reader.cancel().catch(() => undefined);
throw new Error("aborted:update_shutdown");
}
const { done, value } = await reader.read();
if (writeError !== null) throw writeError;
if (done) break;
const buf = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
if (!writeStream.write(buf)) {
await waitForDrain();
}
if (writeError !== null) throw writeError;
downloadedBytes += buf.byteLength;
resetIdle();
reportProgress(false);
}
await finishWriting();
if (writeError !== null) throw writeError;
} catch (error) {
writeStream.destroy();
void reader.cancel().catch(() => undefined);
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
if (idleTimedOut) {
throw idleTimeoutError();
}
throw error;
} finally {
clearIdle();
}
if (idleTimedOut) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
throw idleTimeoutError();
}
if (totalBytes && downloadedBytes !== totalBytes) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});