fix: close automation lifecycle races
This commit is contained in:
@@ -2113,6 +2113,97 @@ ipcMain.handle('inspect-import-files', async (_event, payload) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function rejectPreparedUploadStart(manager, producerTracker, error, clearRecovery) {
|
||||||
|
try { manager.cancel(); } catch {}
|
||||||
|
if (uploadManager === manager) {
|
||||||
|
uploadManager = null;
|
||||||
|
globalThis._mhuUploadManagerRef = null;
|
||||||
|
}
|
||||||
|
producerTracker.finish();
|
||||||
|
if (clearRecovery) {
|
||||||
|
try { await configStore.saveUploadRecovery(null); } catch (cleanupError) {
|
||||||
|
debugLog(`upload recovery state could not be cleared after rejected start: ${cleanupError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
function preparedUploadStartGate(manager) {
|
||||||
|
if (uploadManager !== manager) return 'Upload-Start wurde verworfen';
|
||||||
|
if (closeFlushRequested) return 'Die Anwendung wird gerade beendet';
|
||||||
|
if (configStore.load().globalSettings?.folderMonitor?.paused === true) return 'Automatik ist pausiert';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function observePreparedUploadAcceptance(batchPromise) {
|
||||||
|
const settled = batchPromise && typeof batchPromise.then === 'function'
|
||||||
|
? batchPromise.then(
|
||||||
|
() => ({ settled: true }),
|
||||||
|
error => ({ rejected: true, error })
|
||||||
|
)
|
||||||
|
: Promise.resolve({ settled: true });
|
||||||
|
return Promise.race([
|
||||||
|
settled,
|
||||||
|
new Promise(resolve => queueMicrotask(() => resolve({ accepted: true })))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPreparedUploadBatch({ manager, tasks, producerTracker, recovery, isAutoRetry }) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setImmediate(() => {
|
||||||
|
void (async () => {
|
||||||
|
let gateError = preparedUploadStartGate(manager);
|
||||||
|
if (gateError) return rejectPreparedUploadStart(manager, producerTracker, gateError, false);
|
||||||
|
try {
|
||||||
|
await configStore.saveUploadRecovery(recovery);
|
||||||
|
} catch {
|
||||||
|
return rejectPreparedUploadStart(manager, producerTracker, 'Upload-Wiederherstellung konnte nicht vorbereitet werden', true);
|
||||||
|
}
|
||||||
|
gateError = preparedUploadStartGate(manager);
|
||||||
|
if (gateError) return rejectPreparedUploadStart(manager, producerTracker, gateError, true);
|
||||||
|
_accountCooldowns.releaseExpired();
|
||||||
|
const pausedAccounts = _accountCooldowns.activeKeys();
|
||||||
|
let batchPromise;
|
||||||
|
try {
|
||||||
|
batchPromise = manager.startBatch(tasks, {
|
||||||
|
primeFailedAccounts: pausedAccounts,
|
||||||
|
primeOverrides: Array.from(_sessionAccountOverrides.entries())
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return rejectPreparedUploadStart(manager, producerTracker, 'Upload konnte nicht gestartet werden', true);
|
||||||
|
}
|
||||||
|
const acceptance = await observePreparedUploadAcceptance(batchPromise);
|
||||||
|
if (acceptance.rejected) {
|
||||||
|
return rejectPreparedUploadStart(manager, producerTracker, 'Upload konnte nicht gestartet werden', true);
|
||||||
|
}
|
||||||
|
if (acceptance.accepted) {
|
||||||
|
Promise.resolve(batchPromise).catch((err) => {
|
||||||
|
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
|
||||||
|
const errorSummary = {
|
||||||
|
id: 'error',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
total: tasks.length,
|
||||||
|
succeeded: 0,
|
||||||
|
failed: tasks.length,
|
||||||
|
files: [],
|
||||||
|
error: err ? err.message : 'Unbekannter Fehler'
|
||||||
|
};
|
||||||
|
safeSend('upload-batch-done', errorSummary);
|
||||||
|
configStore.saveUploadRecovery(null).catch(error => debugLog(`upload recovery state could not be cleared after start failure: ${error.message}`));
|
||||||
|
producerTracker.finish();
|
||||||
|
if (!isAutoRetry) sendBatchWebhook(errorSummary, 0);
|
||||||
|
if (uploadManager === manager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logMemorySnapshot('batch-start');
|
||||||
|
return { started: true };
|
||||||
|
})().then(resolve, async () => {
|
||||||
|
resolve(await rejectPreparedUploadStart(manager, producerTracker, 'Upload konnte nicht gestartet werden', true));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ipcMain.handle('start-upload', async (_event, payload) => {
|
ipcMain.handle('start-upload', async (_event, payload) => {
|
||||||
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
|
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
|
||||||
return { error: 'Automatik ist pausiert' };
|
return { error: 'Automatik ist pausiert' };
|
||||||
@@ -2191,12 +2282,6 @@ ipcMain.handle('start-upload', async (_event, payload) => {
|
|||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
jobIds: tasks.map(task => task.jobId).filter(Boolean)
|
jobIds: tasks.map(task => task.jobId).filter(Boolean)
|
||||||
};
|
};
|
||||||
try { await configStore.saveUploadRecovery(recovery); } catch (error) { debugLog(`upload recovery state could not be saved: ${error.message}`); }
|
|
||||||
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
|
|
||||||
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
|
|
||||||
try { await configStore.saveUploadRecovery(null); } catch (error) { debugLog(`upload recovery state could not be cleared after automation pause: ${error.message}`); }
|
|
||||||
return { error: 'Automatik ist pausiert' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-resolve a fallback for every hoster that has one. Lets the upload
|
// Pre-resolve a fallback for every hoster that has one. Lets the upload
|
||||||
// manager break out of the retry loop after a single generic failure and
|
// manager break out of the retry loop after a single generic failure and
|
||||||
@@ -2425,55 +2510,16 @@ ipcMain.handle('start-upload', async (_event, payload) => {
|
|||||||
else debugLog('batch-done: skipping uploadManager null-out — a newer manager replaced this one mid-await');
|
else debugLog('batch-done: skipping uploadManager null-out — a newer manager replaced this one mid-await');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Defer startBatch to next tick so the IPC response is sent first.
|
const startResult = await startPreparedUploadBatch({
|
||||||
// This ensures webContents.send() calls from upload events
|
manager: _thisManager,
|
||||||
// are not interleaved with the handle() response.
|
tasks,
|
||||||
setImmediate(() => {
|
producerTracker: _producerTracker,
|
||||||
if (uploadManager !== _thisManager) {
|
recovery,
|
||||||
debugLog('setImmediate: uploadManager was replaced before startBatch');
|
isAutoRetry
|
||||||
_producerTracker.finish();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (closeFlushRequested) {
|
|
||||||
try { _thisManager.cancel(); } catch {}
|
|
||||||
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
|
|
||||||
_producerTracker.finish();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
|
|
||||||
try { _thisManager.cancel(); } catch {}
|
|
||||||
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
|
|
||||||
_producerTracker.finish();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_accountCooldowns.releaseExpired();
|
|
||||||
const pausedAccounts = _accountCooldowns.activeKeys();
|
|
||||||
debugLog(`setImmediate: calling startBatch now (priming ${pausedAccounts.length} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`);
|
|
||||||
_thisManager.startBatch(tasks, {
|
|
||||||
primeFailedAccounts: pausedAccounts,
|
|
||||||
primeOverrides: Array.from(_sessionAccountOverrides.entries())
|
|
||||||
}).catch((err) => {
|
|
||||||
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
|
|
||||||
const errorSummary = {
|
|
||||||
id: 'error',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
total: tasks.length,
|
|
||||||
succeeded: 0,
|
|
||||||
failed: tasks.length,
|
|
||||||
files: [],
|
|
||||||
error: err ? err.message : 'Unbekannter Fehler'
|
|
||||||
};
|
|
||||||
safeSend('upload-batch-done', errorSummary);
|
|
||||||
configStore.saveUploadRecovery(null).catch(error => debugLog(`upload recovery state could not be cleared after start failure: ${error.message}`));
|
|
||||||
_producerTracker.finish();
|
|
||||||
if (!isAutoRetry) sendBatchWebhook(errorSummary, 0);
|
|
||||||
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
if (!startResult.started) return startResult;
|
||||||
logMemorySnapshot('batch-start');
|
debugLog('start-upload returning started=true after startBatch acceptance');
|
||||||
debugLog(`start-upload returning started=true (startBatch deferred to nextTick)`);
|
return { ...startResult, taskCount: tasks.length, skippedJobs, sourceCleanupFingerprints };
|
||||||
return { started: true, taskCount: tasks.length, skippedJobs, sourceCleanupFingerprints };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logged at batch boundaries so we can spot memory growth between batches
|
// Logged at batch boundaries so we can spot memory growth between batches
|
||||||
@@ -3190,7 +3236,44 @@ function _sweepOrphanConfigTmps() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let suppressAutomationStatusEvents = false;
|
const automationLifecycleQueue = [];
|
||||||
|
let automationLifecycleRunning = false;
|
||||||
|
let automationLifecycleGeneration = 0;
|
||||||
|
let automationStatusSuppressionDepth = 0;
|
||||||
|
|
||||||
|
function drainAutomationLifecycleQueue() {
|
||||||
|
if (automationLifecycleRunning) return;
|
||||||
|
const next = automationLifecycleQueue.shift();
|
||||||
|
if (!next) return;
|
||||||
|
automationLifecycleRunning = true;
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = next.operation(next.generation);
|
||||||
|
} catch (error) {
|
||||||
|
result = Promise.reject(error);
|
||||||
|
}
|
||||||
|
Promise.resolve(result).then(next.resolve, next.reject).finally(() => {
|
||||||
|
automationLifecycleRunning = false;
|
||||||
|
drainAutomationLifecycleQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueAutomationLifecycle(operation) {
|
||||||
|
const generation = ++automationLifecycleGeneration;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
automationLifecycleQueue.push({ generation, operation, resolve, reject });
|
||||||
|
drainAutomationLifecycleQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withAutomationStatusSuppressed(operation) {
|
||||||
|
automationStatusSuppressionDepth++;
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
automationStatusSuppressionDepth--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function automationStatusSnapshot() {
|
function automationStatusSnapshot() {
|
||||||
const settings = configStore.load().globalSettings?.folderMonitor || {};
|
const settings = configStore.load().globalSettings?.folderMonitor || {};
|
||||||
@@ -3205,9 +3288,10 @@ function automationStatusSnapshot() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function publishAutomationStatus() {
|
function publishAutomationStatus(extra = null, generation = null) {
|
||||||
const snapshot = automationStatusSnapshot();
|
const snapshot = Object.freeze({ ...automationStatusSnapshot(), ...(extra || {}) });
|
||||||
if (!suppressAutomationStatusEvents) safeSend('automation:status', snapshot);
|
const currentGeneration = generation === null || generation === automationLifecycleGeneration;
|
||||||
|
if (automationStatusSuppressionDepth === 0 && currentGeneration) safeSend('automation:status', snapshot);
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3220,7 +3304,7 @@ function bindFolderMonitorEvents(settings) {
|
|||||||
folderMonitor.on('error', (err) => {
|
folderMonitor.on('error', (err) => {
|
||||||
debugLog(`folder-monitor error: ${err.message}`);
|
debugLog(`folder-monitor error: ${err.message}`);
|
||||||
});
|
});
|
||||||
folderMonitor.on('status', publishAutomationStatus);
|
folderMonitor.on('status', () => publishAutomationStatus());
|
||||||
folderMonitor.on('initial-scan-complete', async () => {
|
folderMonitor.on('initial-scan-complete', async () => {
|
||||||
try {
|
try {
|
||||||
const latest = configStore.load();
|
const latest = configStore.load();
|
||||||
@@ -3280,45 +3364,45 @@ ipcMain.handle('automation:get-status', () => {
|
|||||||
return automationStatusSnapshot();
|
return automationStatusSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('automation:pause-after-active', async () => {
|
ipcMain.handle('automation:pause-after-active', () => enqueueAutomationLifecycle(async generation => {
|
||||||
const latest = configStore.load();
|
let monitorError = '';
|
||||||
const settings = latest.globalSettings?.folderMonitor || {};
|
await withAutomationStatusSuppressed(async () => {
|
||||||
await configStore.save({
|
const latest = configStore.load();
|
||||||
globalSettings: {
|
const settings = latest.globalSettings?.folderMonitor || {};
|
||||||
...latest.globalSettings,
|
await configStore.save({
|
||||||
folderMonitor: { ...settings, paused: true, pausedAt: Date.now() }
|
globalSettings: {
|
||||||
|
...latest.globalSettings,
|
||||||
|
folderMonitor: { ...settings, paused: true, pausedAt: Date.now() }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await folderMonitor.pause();
|
||||||
|
} catch {
|
||||||
|
monitorError = 'Ordnerüberwachung konnte nicht pausiert werden';
|
||||||
|
} finally {
|
||||||
|
if (uploadManager) uploadManager.finishAfterActive();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
suppressAutomationStatusEvents = true;
|
return publishAutomationStatus(monitorError ? { monitorError } : null, generation);
|
||||||
try {
|
}));
|
||||||
await folderMonitor.pause();
|
|
||||||
} finally {
|
|
||||||
suppressAutomationStatusEvents = false;
|
|
||||||
}
|
|
||||||
if (uploadManager) uploadManager.finishAfterActive();
|
|
||||||
return publishAutomationStatus();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('automation:resume', async () => {
|
ipcMain.handle('automation:resume', () => enqueueAutomationLifecycle(async generation => {
|
||||||
const latest = configStore.load();
|
await withAutomationStatusSuppressed(async () => {
|
||||||
const settings = latest.globalSettings?.folderMonitor || {};
|
const latest = configStore.load();
|
||||||
const resumedSettings = { ...settings, paused: false, pausedAt: null };
|
const settings = latest.globalSettings?.folderMonitor || {};
|
||||||
await configStore.save({
|
const resumedSettings = { ...settings, paused: false, pausedAt: null };
|
||||||
globalSettings: {
|
await configStore.save({
|
||||||
...latest.globalSettings,
|
globalSettings: {
|
||||||
folderMonitor: resumedSettings
|
...latest.globalSettings,
|
||||||
}
|
folderMonitor: resumedSettings
|
||||||
});
|
}
|
||||||
suppressAutomationStatusEvents = true;
|
});
|
||||||
try {
|
|
||||||
if (resumedSettings.enabled && resumedSettings.folderPath) {
|
if (resumedSettings.enabled && resumedSettings.folderPath) {
|
||||||
await resumeFolderMonitor(resumedSettings);
|
await resumeFolderMonitor(resumedSettings);
|
||||||
}
|
}
|
||||||
} finally {
|
});
|
||||||
suppressAutomationStatusEvents = false;
|
return publishAutomationStatus(null, generation);
|
||||||
}
|
}));
|
||||||
return publishAutomationStatus();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('folder-monitor:test-scan', () => {
|
ipcMain.handle('folder-monitor:test-scan', () => {
|
||||||
return folderMonitor.scan({ emitFiles: false, trigger: 'test' });
|
return folderMonitor.scan({ emitFiles: false, trigger: 'test' });
|
||||||
|
|||||||
@@ -9,6 +9,116 @@ const packageJson = require('../package.json');
|
|||||||
|
|
||||||
const projectRoot = path.join(__dirname, '..');
|
const projectRoot = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
function createDeferred() {
|
||||||
|
let resolve;
|
||||||
|
let reject;
|
||||||
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCondition(predicate) {
|
||||||
|
for (let attempt = 0; attempt < 50; attempt++) {
|
||||||
|
if (predicate()) return;
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
assert.fail('condition did not become true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAutomationLifecycleHarness(mainSource) {
|
||||||
|
const currentMarker = 'let suppressAutomationStatusEvents = false;';
|
||||||
|
const queuedMarker = 'const automationLifecycleQueue = [];';
|
||||||
|
const blockStart = Math.max(mainSource.indexOf(currentMarker), mainSource.indexOf(queuedMarker));
|
||||||
|
const blockEnd = mainSource.indexOf('\n// --- Remote Control ---', blockStart);
|
||||||
|
assert.notEqual(blockStart, -1, 'automation lifecycle block missing');
|
||||||
|
assert.notEqual(blockEnd, -1, 'automation lifecycle block boundary missing');
|
||||||
|
const handlers = new Map();
|
||||||
|
const order = [];
|
||||||
|
const sent = [];
|
||||||
|
const saves = [];
|
||||||
|
const pauseDeferred = createDeferred();
|
||||||
|
const resumeDeferred = createDeferred();
|
||||||
|
let publishStatus = () => {};
|
||||||
|
let state = {
|
||||||
|
globalSettings: {
|
||||||
|
folderMonitor: {
|
||||||
|
enabled: true,
|
||||||
|
folderPath: 'C:\\watch',
|
||||||
|
paused: true,
|
||||||
|
pausedAt: 1,
|
||||||
|
queueLimitJobs: 15000,
|
||||||
|
reconcileIntervalMinutes: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const folderMonitor = new (require('node:events').EventEmitter)();
|
||||||
|
folderMonitor.running = false;
|
||||||
|
folderMonitor.status = () => ({ running: folderMonitor.running, reachable: true });
|
||||||
|
folderMonitor.stop = () => { folderMonitor.running = false; order.push('stop'); };
|
||||||
|
folderMonitor.start = () => { folderMonitor.running = true; order.push('start'); publishStatus(); return {}; };
|
||||||
|
folderMonitor.pause = () => {
|
||||||
|
order.push('pause');
|
||||||
|
publishStatus();
|
||||||
|
return pauseDeferred.promise.then(() => {
|
||||||
|
folderMonitor.running = false;
|
||||||
|
publishStatus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
folderMonitor.resume = () => {
|
||||||
|
order.push('resume');
|
||||||
|
publishStatus();
|
||||||
|
return resumeDeferred.promise.then(() => {
|
||||||
|
folderMonitor.running = true;
|
||||||
|
publishStatus();
|
||||||
|
return { reachable: true };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
folderMonitor.scan = async () => ({ reachable: true });
|
||||||
|
const configStore = {
|
||||||
|
load: () => structuredClone(state),
|
||||||
|
save: config => {
|
||||||
|
const deferred = createDeferred();
|
||||||
|
const snapshot = structuredClone(config);
|
||||||
|
saves.push({ paused: snapshot.globalSettings.folderMonitor.paused, deferred });
|
||||||
|
order.push(`save:${snapshot.globalSettings.folderMonitor.paused}`);
|
||||||
|
return deferred.promise.then(() => { state = snapshot; });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const uploadManager = {
|
||||||
|
finishAfterActive: () => order.push('finish'),
|
||||||
|
startBatch: () => order.push('startBatch')
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
configStore,
|
||||||
|
debugLog: () => {},
|
||||||
|
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
|
||||||
|
folderMonitor,
|
||||||
|
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
|
||||||
|
path,
|
||||||
|
safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; },
|
||||||
|
uploadManager
|
||||||
|
};
|
||||||
|
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), context);
|
||||||
|
publishStatus = () => context.publishAutomationStatus();
|
||||||
|
return {
|
||||||
|
handlers,
|
||||||
|
order,
|
||||||
|
pauseDeferred,
|
||||||
|
resumeDeferred,
|
||||||
|
saves,
|
||||||
|
sent,
|
||||||
|
state: () => structuredClone(state),
|
||||||
|
publishStatus
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test('packages every Electron preload referenced by the main process', () => {
|
test('packages every Electron preload referenced by the main process', () => {
|
||||||
assert.ok(packageJson.build.files.includes('preload.js'));
|
assert.ok(packageJson.build.files.includes('preload.js'));
|
||||||
assert.ok(packageJson.build.files.includes('preload-drop-target.js'));
|
assert.ok(packageJson.build.files.includes('preload-drop-target.js'));
|
||||||
@@ -277,12 +387,12 @@ test('every batch start and extension IPC fails closed before account and cleanu
|
|||||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||||
const gate = /configStore\.load\(\)\.globalSettings\?\.folderMonitor\?\.paused\s*===\s*true/u;
|
const gate = /configStore\.load\(\)\.globalSettings\?\.folderMonitor\?\.paused\s*===\s*true/u;
|
||||||
const cases = [
|
const cases = [
|
||||||
['debug-test-upload', "ipcMain.handle('debug-test-upload'", "ipcMain.handle('select-folder'", 'fs.writeFileSync', 1],
|
['debug-test-upload', "ipcMain.handle('debug-test-upload'", "ipcMain.handle('select-folder'", 'fs.writeFileSync'],
|
||||||
['start-upload', "ipcMain.handle('start-upload'", '\n// Logged at batch boundaries', 'makeAccountPicker', 6],
|
['start-upload', "ipcMain.handle('start-upload'", '\n// Logged at batch boundaries', 'makeAccountPicker'],
|
||||||
['add-jobs-to-batch', "ipcMain.handle('add-jobs-to-batch'", "ipcMain.handle('finish-after-active'", 'makeAccountPicker', 2]
|
['add-jobs-to-batch', "ipcMain.handle('add-jobs-to-batch'", "ipcMain.handle('finish-after-active'", 'makeAccountPicker']
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [channel, startMarker, endMarker, sideEffectMarker, expectedGateCount] of cases) {
|
for (const [channel, startMarker, endMarker, sideEffectMarker] of cases) {
|
||||||
const start = mainSource.indexOf(startMarker);
|
const start = mainSource.indexOf(startMarker);
|
||||||
const end = mainSource.indexOf(endMarker, start);
|
const end = mainSource.indexOf(endMarker, start);
|
||||||
assert.notEqual(start, -1, `${channel} handler missing`);
|
assert.notEqual(start, -1, `${channel} handler missing`);
|
||||||
@@ -293,13 +403,15 @@ test('every batch start and extension IPC fails closed before account and cleanu
|
|||||||
assert.notEqual(gateIndex, -1, `${channel} pause gate missing`);
|
assert.notEqual(gateIndex, -1, `${channel} pause gate missing`);
|
||||||
assert.notEqual(sideEffectIndex, -1, `${channel} side-effect marker missing`);
|
assert.notEqual(sideEffectIndex, -1, `${channel} side-effect marker missing`);
|
||||||
assert.ok(gateIndex < sideEffectIndex, `${channel} pause gate runs after side effects`);
|
assert.ok(gateIndex < sideEffectIndex, `${channel} pause gate runs after side effects`);
|
||||||
assert.equal([...handler.matchAll(new RegExp(gate.source, 'gu'))].length, expectedGateCount, `${channel} does not recheck every asynchronous race boundary`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('automation pause and resume commit state before lifecycle effects', async () => {
|
test('automation pause save commits before lifecycle effects and save failure is inert', async () => {
|
||||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||||
const blockStart = mainSource.indexOf('let suppressAutomationStatusEvents = false;');
|
const blockStart = Math.max(
|
||||||
|
mainSource.indexOf('let suppressAutomationStatusEvents = false;'),
|
||||||
|
mainSource.indexOf('const automationLifecycleQueue = [];')
|
||||||
|
);
|
||||||
const blockEnd = mainSource.indexOf('\n// --- Remote Control ---', blockStart);
|
const blockEnd = mainSource.indexOf('\n// --- Remote Control ---', blockStart);
|
||||||
assert.notEqual(blockStart, -1, 'automation lifecycle block missing');
|
assert.notEqual(blockStart, -1, 'automation lifecycle block missing');
|
||||||
assert.notEqual(blockEnd, -1, 'automation lifecycle block boundary missing');
|
assert.notEqual(blockEnd, -1, 'automation lifecycle block boundary missing');
|
||||||
@@ -380,6 +492,234 @@ test('automation pause and resume commit state before lifecycle effects', async
|
|||||||
assert.equal(sent[0][1].paused, false);
|
assert.equal(sent[0][1].paused, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('automation lifecycle serializes pause then resume so the newer intent wins', async () => {
|
||||||
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||||
|
const harness = createAutomationLifecycleHarness(mainSource);
|
||||||
|
|
||||||
|
const pause = harness.handlers.get('automation:pause-after-active')();
|
||||||
|
const resume = harness.handlers.get('automation:resume')();
|
||||||
|
|
||||||
|
assert.equal(harness.saves.length, 1);
|
||||||
|
assert.equal(harness.saves[0].paused, true);
|
||||||
|
harness.saves[0].deferred.resolve();
|
||||||
|
await flushMicrotasks();
|
||||||
|
harness.pauseDeferred.resolve();
|
||||||
|
await waitForCondition(() => harness.saves.length === 2);
|
||||||
|
assert.equal(harness.saves.length, 2);
|
||||||
|
assert.equal(harness.saves[1].paused, false);
|
||||||
|
harness.saves[1].deferred.resolve();
|
||||||
|
await flushMicrotasks();
|
||||||
|
harness.resumeDeferred.resolve();
|
||||||
|
await Promise.all([pause, resume]);
|
||||||
|
|
||||||
|
assert.deepEqual(harness.order, ['save:true', 'pause', 'finish', 'save:false', 'resume']);
|
||||||
|
assert.equal(harness.state().globalSettings.folderMonitor.paused, false);
|
||||||
|
assert.equal(harness.sent.length, 1);
|
||||||
|
assert.equal(harness.sent[0][1].paused, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('automation lifecycle serializes resume then pause so the newer intent wins', async () => {
|
||||||
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||||
|
const harness = createAutomationLifecycleHarness(mainSource);
|
||||||
|
|
||||||
|
const resume = harness.handlers.get('automation:resume')();
|
||||||
|
const pause = harness.handlers.get('automation:pause-after-active')();
|
||||||
|
|
||||||
|
assert.equal(harness.saves.length, 1);
|
||||||
|
assert.equal(harness.saves[0].paused, false);
|
||||||
|
harness.saves[0].deferred.resolve();
|
||||||
|
await flushMicrotasks();
|
||||||
|
harness.resumeDeferred.resolve();
|
||||||
|
await waitForCondition(() => harness.saves.length === 2);
|
||||||
|
assert.equal(harness.saves.length, 2);
|
||||||
|
assert.equal(harness.saves[1].paused, true);
|
||||||
|
harness.saves[1].deferred.resolve();
|
||||||
|
await flushMicrotasks();
|
||||||
|
harness.pauseDeferred.resolve();
|
||||||
|
await Promise.all([resume, pause]);
|
||||||
|
|
||||||
|
assert.deepEqual(harness.order, ['save:false', 'resume', 'save:true', 'pause', 'finish']);
|
||||||
|
assert.equal(harness.state().globalSettings.folderMonitor.paused, true);
|
||||||
|
assert.equal(harness.sent.length, 1);
|
||||||
|
assert.equal(harness.sent[0][1].paused, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('automation status suppression remains active until the serialized operation ends', async () => {
|
||||||
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||||
|
const harness = createAutomationLifecycleHarness(mainSource);
|
||||||
|
|
||||||
|
const pause = harness.handlers.get('automation:pause-after-active')();
|
||||||
|
const resume = harness.handlers.get('automation:resume')();
|
||||||
|
harness.saves[0].deferred.resolve();
|
||||||
|
await waitForCondition(() => harness.order.includes('pause'));
|
||||||
|
harness.pauseDeferred.resolve();
|
||||||
|
await waitForCondition(() => harness.saves.length === 2);
|
||||||
|
harness.publishStatus();
|
||||||
|
|
||||||
|
assert.equal(harness.sent.length, 0);
|
||||||
|
|
||||||
|
harness.saves[1].deferred.resolve();
|
||||||
|
await waitForCondition(() => harness.order.includes('resume'));
|
||||||
|
harness.resumeDeferred.resolve();
|
||||||
|
await Promise.all([pause, resume]);
|
||||||
|
|
||||||
|
assert.equal(harness.sent.length, 1);
|
||||||
|
assert.equal(harness.sent[0][1].paused, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('automation pause rejection still finishes active uploads and returns a sanitized monitor error', async () => {
|
||||||
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||||
|
const harness = createAutomationLifecycleHarness(mainSource);
|
||||||
|
|
||||||
|
const pause = harness.handlers.get('automation:pause-after-active')();
|
||||||
|
harness.saves[0].deferred.resolve();
|
||||||
|
await flushMicrotasks();
|
||||||
|
harness.pauseDeferred.reject(new Error('token=secret-value'));
|
||||||
|
const result = await pause;
|
||||||
|
|
||||||
|
assert.equal(result.paused, true);
|
||||||
|
assert.equal(result.monitorError, 'Ordnerüberwachung konnte nicht pausiert werden');
|
||||||
|
assert.equal(JSON.stringify(result).includes('secret-value'), false);
|
||||||
|
assert.equal(harness.order.includes('finish'), true);
|
||||||
|
assert.equal(harness.sent.length, 1);
|
||||||
|
assert.equal(JSON.stringify(harness.sent[0][1]).includes('secret-value'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepared upload start waits for the final tick and clears recovery when pause wins', async () => {
|
||||||
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||||
|
const blockStart = mainSource.indexOf('async function rejectPreparedUploadStart');
|
||||||
|
const blockEnd = mainSource.indexOf("\nipcMain.handle('start-upload'", blockStart);
|
||||||
|
assert.notEqual(blockStart, -1, 'prepared upload start block missing');
|
||||||
|
assert.notEqual(blockEnd, -1, 'prepared upload start block boundary missing');
|
||||||
|
const ticks = [];
|
||||||
|
const recoverySave = createDeferred();
|
||||||
|
const writes = [];
|
||||||
|
let paused = false;
|
||||||
|
let cancelled = 0;
|
||||||
|
let finished = 0;
|
||||||
|
let started = 0;
|
||||||
|
const manager = {
|
||||||
|
cancel: () => { cancelled++; },
|
||||||
|
startBatch: () => { started++; return new Promise(() => {}); }
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
_accountCooldowns: { activeKeys: () => [], releaseExpired: () => {} },
|
||||||
|
_sessionAccountOverrides: new Map(),
|
||||||
|
closeFlushRequested: false,
|
||||||
|
configStore: {
|
||||||
|
load: () => ({ globalSettings: { folderMonitor: { paused } } }),
|
||||||
|
saveUploadRecovery: value => {
|
||||||
|
writes.push(value);
|
||||||
|
return value === null ? Promise.resolve() : recoverySave.promise;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
debugLog: () => {},
|
||||||
|
globalThis: {},
|
||||||
|
isAllAborted: () => false,
|
||||||
|
logMemorySnapshot: () => {},
|
||||||
|
queueMicrotask,
|
||||||
|
safeSend: () => {},
|
||||||
|
sendBatchWebhook: () => {},
|
||||||
|
setImmediate: callback => { ticks.push(callback); },
|
||||||
|
uploadManager: manager
|
||||||
|
};
|
||||||
|
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), context);
|
||||||
|
const producerTracker = { finish: () => { finished++; } };
|
||||||
|
const start = context.startPreparedUploadBatch({
|
||||||
|
manager,
|
||||||
|
tasks: [{ jobId: 'job-1' }],
|
||||||
|
producerTracker,
|
||||||
|
recovery: { id: 'recovery-1' },
|
||||||
|
isAutoRetry: false
|
||||||
|
});
|
||||||
|
let settled = false;
|
||||||
|
start.then(() => { settled = true; });
|
||||||
|
|
||||||
|
assert.equal(settled, false);
|
||||||
|
assert.equal(ticks.length, 1);
|
||||||
|
ticks.shift()();
|
||||||
|
await flushMicrotasks();
|
||||||
|
assert.deepEqual(writes, [{ id: 'recovery-1' }]);
|
||||||
|
paused = true;
|
||||||
|
recoverySave.resolve();
|
||||||
|
const result = await start;
|
||||||
|
|
||||||
|
assert.equal(result.error, 'Automatik ist pausiert');
|
||||||
|
assert.equal(Object.hasOwn(result, 'started'), false);
|
||||||
|
assert.deepEqual(writes, [{ id: 'recovery-1' }, null]);
|
||||||
|
assert.equal(cancelled, 1);
|
||||||
|
assert.equal(finished, 1);
|
||||||
|
assert.equal(started, 0);
|
||||||
|
assert.equal(context.uploadManager, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepared upload start returns success only after synchronous acceptance and cleans immediate rejection', async () => {
|
||||||
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||||
|
const blockStart = mainSource.indexOf('async function rejectPreparedUploadStart');
|
||||||
|
const blockEnd = mainSource.indexOf("\nipcMain.handle('start-upload'", blockStart);
|
||||||
|
assert.notEqual(blockStart, -1, 'prepared upload start block missing');
|
||||||
|
assert.notEqual(blockEnd, -1, 'prepared upload start block boundary missing');
|
||||||
|
|
||||||
|
async function run(mode) {
|
||||||
|
const ticks = [];
|
||||||
|
const writes = [];
|
||||||
|
let cancelled = 0;
|
||||||
|
let finished = 0;
|
||||||
|
const manager = { cancel: () => { cancelled++; } };
|
||||||
|
const context = {
|
||||||
|
_accountCooldowns: { activeKeys: () => [], releaseExpired: () => {} },
|
||||||
|
_sessionAccountOverrides: new Map(),
|
||||||
|
closeFlushRequested: false,
|
||||||
|
configStore: {
|
||||||
|
load: () => ({ globalSettings: { folderMonitor: { paused: false } } }),
|
||||||
|
saveUploadRecovery: value => { writes.push(value); return Promise.resolve(); }
|
||||||
|
},
|
||||||
|
debugLog: () => {},
|
||||||
|
globalThis: {},
|
||||||
|
isAllAborted: () => false,
|
||||||
|
logMemorySnapshot: () => {},
|
||||||
|
queueMicrotask,
|
||||||
|
safeSend: () => {},
|
||||||
|
sendBatchWebhook: () => {},
|
||||||
|
setImmediate: callback => { ticks.push(callback); },
|
||||||
|
uploadManager: manager
|
||||||
|
};
|
||||||
|
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), context);
|
||||||
|
vm.runInNewContext(
|
||||||
|
mode === 'rejected'
|
||||||
|
? "uploadManager.startBatch = () => Promise.reject(new Error('apiKey=secret-value'));"
|
||||||
|
: 'uploadManager.startBatch = () => new Promise(() => {});',
|
||||||
|
context
|
||||||
|
);
|
||||||
|
const promise = context.startPreparedUploadBatch({
|
||||||
|
manager,
|
||||||
|
tasks: [{ jobId: 'job-1' }],
|
||||||
|
producerTracker: { finish: () => { finished++; } },
|
||||||
|
recovery: { id: 'recovery-1' },
|
||||||
|
isAutoRetry: false
|
||||||
|
});
|
||||||
|
ticks.shift()();
|
||||||
|
const result = await promise;
|
||||||
|
return { cancelled, context, finished, result, writes };
|
||||||
|
}
|
||||||
|
|
||||||
|
const accepted = await run('pending');
|
||||||
|
assert.equal(accepted.result.started, true);
|
||||||
|
assert.equal(Object.hasOwn(accepted.result, 'error'), false);
|
||||||
|
assert.deepEqual(accepted.writes, [{ id: 'recovery-1' }]);
|
||||||
|
assert.equal(accepted.cancelled, 0);
|
||||||
|
assert.equal(accepted.finished, 0);
|
||||||
|
|
||||||
|
const rejected = await run('rejected');
|
||||||
|
assert.equal(rejected.result.error, 'Upload konnte nicht gestartet werden');
|
||||||
|
assert.equal(Object.hasOwn(rejected.result, 'started'), false);
|
||||||
|
assert.deepEqual(rejected.writes, [{ id: 'recovery-1' }, null]);
|
||||||
|
assert.equal(rejected.cancelled, 1);
|
||||||
|
assert.equal(rejected.finished, 1);
|
||||||
|
assert.equal(rejected.context.uploadManager, null);
|
||||||
|
assert.equal(JSON.stringify(rejected.result).includes('secret-value'), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('startup keeps a missing configured folder disconnected without disabling automation', () => {
|
test('startup keeps a missing configured folder disconnected without disabling automation', () => {
|
||||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||||
const startupStart = mainSource.indexOf('app.whenReady().then(async () => {');
|
const startupStart = mainSource.indexOf('app.whenReady().then(async () => {');
|
||||||
|
|||||||
Reference in New Issue
Block a user