fix: eliminate upload queue stalls under automation load
Move atomic configuration writes off the Electron main event loop and preserve concurrent queue and folder monitor state. Make finish-and-pause interrupt queued admission waits without aborting active uploads, then resume only persistently marked automation jobs across active, idle, restart, and finalization races. Reuse bounded automation evidence snapshots, refresh them during long drains, and cover high-load persistence, pause, resume, and hidden Electron behavior with regression tests.
This commit is contained in:
@@ -355,6 +355,96 @@ describe('ConfigStore', () => {
|
||||
assert.equal(config.globalSettings.alwaysOnTop, true);
|
||||
});
|
||||
|
||||
it('savePendingQueue does not block the event loop on slow synchronous filesystem methods', async () => {
|
||||
await store.save({
|
||||
hosters: { 'byse.sx': [{ id: 'non-blocking-account', enabled: true, authType: 'api', apiKey: 'test-key' }] },
|
||||
globalSettings: { alwaysOnTop: true }
|
||||
});
|
||||
store.load();
|
||||
|
||||
const syncMethods = ['openSync', 'writeSync', 'fsyncSync', 'readFileSync', 'writeFileSync', 'renameSync'];
|
||||
const originals = new Map(syncMethods.map(name => [name, fs[name]]));
|
||||
const waitBuffer = new Int32Array(new SharedArrayBuffer(4));
|
||||
const stallMs = 35;
|
||||
let eventLoopDelayMs;
|
||||
|
||||
for (const name of syncMethods) {
|
||||
fs[name] = (...args) => {
|
||||
Atomics.wait(waitBuffer, 0, 0, stallMs);
|
||||
return originals.get(name)(...args);
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const startedAt = performance.now();
|
||||
const eventLoopTick = new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
eventLoopDelayMs = performance.now() - startedAt;
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
const save = store.savePendingQueue({ savedAt: 5, queueJobs: [{ id: 'non-blocking' }] });
|
||||
|
||||
await eventLoopTick;
|
||||
await save;
|
||||
} finally {
|
||||
for (const [name, original] of originals) fs[name] = original;
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
eventLoopDelayMs < stallMs * 3,
|
||||
`queue save blocked the event loop for ${eventLoopDelayMs.toFixed(1)} ms`
|
||||
);
|
||||
});
|
||||
|
||||
it('folder monitor runtime saves preserve a concurrently queued pending queue snapshot', async () => {
|
||||
const pendingQueue = {
|
||||
savedAt: 1787712000000,
|
||||
queueJobs: [{ id: 'paused-job', automationPaused: true }]
|
||||
};
|
||||
|
||||
const queueSave = store.savePendingQueue(pendingQueue);
|
||||
const runtimeSave = store.saveFolderMonitorRuntimeState({ paused: false, pausedAt: null });
|
||||
await Promise.all([queueSave, runtimeSave]);
|
||||
|
||||
const current = store.load().globalSettings;
|
||||
assert.deepEqual(current.pendingQueue, pendingQueue);
|
||||
assert.equal(current.folderMonitor.paused, false);
|
||||
assert.equal(current.folderMonitor.pausedAt, null);
|
||||
});
|
||||
|
||||
it('folder monitor runtime saves cannot revert concurrently queued monitor settings', async () => {
|
||||
const stale = store.load().globalSettings.folderMonitor;
|
||||
const currentSettings = {
|
||||
...stale,
|
||||
folderPath: 'D:\\new-watch',
|
||||
hosters: ['byse.sx'],
|
||||
filterMode: 'exclude',
|
||||
autoStart: false
|
||||
};
|
||||
|
||||
const settingsSave = store.save({
|
||||
globalSettings: {
|
||||
...store.load().globalSettings,
|
||||
folderMonitor: currentSettings
|
||||
}
|
||||
});
|
||||
const runtimeSave = store.saveFolderMonitorRuntimeState({
|
||||
...stale,
|
||||
paused: true,
|
||||
pausedAt: 1787712000000
|
||||
});
|
||||
await Promise.all([settingsSave, runtimeSave]);
|
||||
|
||||
const folderMonitor = store.load().globalSettings.folderMonitor;
|
||||
assert.equal(folderMonitor.folderPath, 'D:\\new-watch');
|
||||
assert.deepEqual(folderMonitor.hosters, ['byse.sx']);
|
||||
assert.equal(folderMonitor.filterMode, 'exclude');
|
||||
assert.equal(folderMonitor.autoStart, false);
|
||||
assert.equal(folderMonitor.paused, true);
|
||||
assert.equal(folderMonitor.pausedAt, 1787712000000);
|
||||
});
|
||||
|
||||
it('drainWrites waits for config and history writes appended while draining', async () => {
|
||||
assert.equal(typeof store.drainWrites, 'function');
|
||||
await store.save({ globalSettings: { alwaysOnTop: false } });
|
||||
|
||||
@@ -175,6 +175,7 @@ test('runtime queue, account, toast, and shutdown copy translates completely', (
|
||||
const cases = [
|
||||
['Wartet', 'Waiting'],
|
||||
['Abgebrochen', 'Canceled'],
|
||||
['Warteschlange angehalten', 'Queue paused'],
|
||||
['Fehlgeschlagen: Verbindung verloren', 'Failed: Connection lost'],
|
||||
['Retry 2/3 · Primär nicht verfügbar', 'Retry 2/3 · Primary unavailable'],
|
||||
['Link kopiert', 'Link copied'],
|
||||
|
||||
@@ -48,6 +48,7 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
const resumeDeferred = createDeferred();
|
||||
const configuredSettings = [];
|
||||
const startedSettings = [];
|
||||
let scanError = null;
|
||||
let publishStatus = () => {};
|
||||
let state = {
|
||||
globalSettings: {
|
||||
@@ -98,13 +99,23 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
};
|
||||
folderMonitor.scan = async options => {
|
||||
order.push(`scan:${options.trigger}:${options.emitFiles}`);
|
||||
if (scanError) throw scanError;
|
||||
return { reachable: true, trigger: options.trigger };
|
||||
};
|
||||
const configStore = {
|
||||
load: () => structuredClone(state),
|
||||
save: config => {
|
||||
saveFolderMonitorRuntimeState: folderMonitor => {
|
||||
const deferred = createDeferred();
|
||||
const snapshot = structuredClone(config);
|
||||
const snapshot = structuredClone({
|
||||
...state,
|
||||
globalSettings: {
|
||||
...state.globalSettings,
|
||||
folderMonitor: {
|
||||
...state.globalSettings.folderMonitor,
|
||||
...folderMonitor
|
||||
}
|
||||
}
|
||||
});
|
||||
saves.push({ paused: snapshot.globalSettings.folderMonitor.paused, deferred });
|
||||
order.push(`save:${snapshot.globalSettings.folderMonitor.paused}`);
|
||||
return deferred.promise.then(() => { state = snapshot; });
|
||||
@@ -112,6 +123,7 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
};
|
||||
const uploadManager = {
|
||||
finishAfterActive: () => order.push('finish'),
|
||||
resumeAfterActive: () => order.push('resume-manager'),
|
||||
startBatch: () => order.push('startBatch')
|
||||
};
|
||||
const webContents = {};
|
||||
@@ -155,6 +167,9 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
resumeDeferred,
|
||||
saves,
|
||||
sent,
|
||||
setScanError(error) {
|
||||
scanError = error;
|
||||
},
|
||||
setFolderMonitorState(value) {
|
||||
state.globalSettings.folderMonitor = { ...state.globalSettings.folderMonitor, ...value };
|
||||
},
|
||||
@@ -165,6 +180,41 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
};
|
||||
}
|
||||
|
||||
function createAddJobsToBatchHarness(mainSource) {
|
||||
const blockStart = mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'");
|
||||
const blockEnd = mainSource.indexOf("\nipcMain.handle('finish-after-active'", blockStart);
|
||||
assert.notEqual(blockStart, -1, 'add-jobs-to-batch handler missing');
|
||||
assert.notEqual(blockEnd, -1, 'add-jobs-to-batch handler boundary missing');
|
||||
const handlers = new Map();
|
||||
const effects = [];
|
||||
const uploadManager = {
|
||||
running: true,
|
||||
isStoppingAfterActive: () => true,
|
||||
sourceFileCleanup: {
|
||||
registerGroups: async () => { effects.push('sourceCleanup'); return {}; },
|
||||
markSkipped: () => effects.push('markSkipped')
|
||||
},
|
||||
addJobs: tasks => { effects.push('addJobs'); return { added: tasks.length, alreadyInBatchJobIds: [] }; }
|
||||
};
|
||||
const config = { globalSettings: { folderMonitor: { paused: false } } };
|
||||
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), {
|
||||
appendUploadPlanAudit: async () => effects.push('audit'),
|
||||
buildUploadTasksFromJobs: (_config, jobs) => {
|
||||
effects.push('buildUploadTasks');
|
||||
return jobs.map(job => ({ ...job, jobId: job.id }));
|
||||
},
|
||||
closeFlushRequested: false,
|
||||
configStore: { load: () => config },
|
||||
debugLog: () => effects.push('debugLog'),
|
||||
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
|
||||
makeAccountPicker: () => { effects.push('makeAccountPicker'); return {}; },
|
||||
persistRotation: () => effects.push('persistRotation'),
|
||||
summarizeBatchPlan: value => value,
|
||||
uploadManager
|
||||
});
|
||||
return { effects, handlers };
|
||||
}
|
||||
|
||||
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-drop-target.js'));
|
||||
@@ -571,6 +621,47 @@ test('every batch start and extension IPC fails closed before account and cleanu
|
||||
}
|
||||
});
|
||||
|
||||
test('add-jobs-to-batch rejects a stopping manager before account and cleanup side effects', async () => {
|
||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||
const harness = createAddJobsToBatchHarness(mainSource);
|
||||
|
||||
const result = await harness.handlers.get('add-jobs-to-batch')(null, {
|
||||
jobs: [{ id: 'job-1', file: 'C:\\watch\\video.mp4', hoster: 'doodstream.com' }],
|
||||
sourceCleanupGroups: [{ id: 'group-1' }]
|
||||
});
|
||||
|
||||
assert.deepEqual({ ...result }, { error: 'Warteschlange angehalten' });
|
||||
assert.deepEqual(harness.effects, []);
|
||||
});
|
||||
|
||||
test('start-upload waits for a finalizing manager to release before deciding availability', async () => {
|
||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||
const helperStart = mainSource.indexOf('async function waitForUploadManagerRelease');
|
||||
const helperEnd = mainSource.indexOf('\nfunction requestUploadFinalization', helperStart);
|
||||
const handlerStart = mainSource.indexOf("ipcMain.handle('start-upload'");
|
||||
const configLoad = mainSource.indexOf(' const config = configStore.load();', handlerStart);
|
||||
const waitCall = mainSource.indexOf('await waitForUploadManagerRelease(existingManager)', handlerStart);
|
||||
assert.notEqual(helperStart, -1);
|
||||
assert.notEqual(helperEnd, -1);
|
||||
assert.notEqual(handlerStart, -1);
|
||||
assert.ok(waitCall > handlerStart && waitCall < configLoad);
|
||||
|
||||
const context = { manager: { running: false }, setTimeout, Date };
|
||||
vm.runInNewContext(`
|
||||
let uploadManager = manager;
|
||||
${mainSource.slice(helperStart, helperEnd)}
|
||||
globalThis.waitForRelease = timeout => waitForUploadManagerRelease(manager, timeout);
|
||||
globalThis.release = () => { uploadManager = null; };
|
||||
`, context);
|
||||
|
||||
const released = context.waitForRelease(500);
|
||||
setTimeout(context.release, 20);
|
||||
assert.equal(await released, true);
|
||||
|
||||
vm.runInNewContext('uploadManager = manager;', context);
|
||||
assert.equal(await context.waitForRelease(10), false);
|
||||
});
|
||||
|
||||
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 blockStart = Math.max(
|
||||
@@ -607,15 +698,19 @@ test('automation pause save commits before lifecycle effects and save failure is
|
||||
let rejectSave = false;
|
||||
const configStore = {
|
||||
load: () => structuredClone(state),
|
||||
save: async config => {
|
||||
const paused = config.globalSettings.folderMonitor.paused;
|
||||
saveFolderMonitorRuntimeState: async folderMonitor => {
|
||||
const paused = folderMonitor.paused;
|
||||
order.push(`save:${paused}`);
|
||||
if (rejectSave) throw new Error('save failed');
|
||||
state = structuredClone(config);
|
||||
state.globalSettings.folderMonitor = {
|
||||
...state.globalSettings.folderMonitor,
|
||||
...structuredClone(folderMonitor)
|
||||
};
|
||||
}
|
||||
};
|
||||
const uploadManager = {
|
||||
finishAfterActive: () => order.push('finish'),
|
||||
resumeAfterActive: () => order.push('resume-manager'),
|
||||
startBatch: () => order.push('startBatch')
|
||||
};
|
||||
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), {
|
||||
@@ -658,7 +753,7 @@ test('automation pause save commits before lifecycle effects and save failure is
|
||||
state.globalSettings.folderMonitor.pausedAt = 1;
|
||||
folderMonitor.running = false;
|
||||
await handlers.get('automation:resume')();
|
||||
assert.deepEqual(order, ['resume', 'save:false', 'scan:resume:true']);
|
||||
assert.deepEqual(order, ['resume', 'save:false', 'resume-manager', 'scan:resume:true']);
|
||||
assert.equal(order.includes('startBatch'), false);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0][1].paused, false);
|
||||
@@ -684,7 +779,7 @@ test('automation lifecycle serializes pause then resume so the newer intent wins
|
||||
harness.saves[1].deferred.resolve();
|
||||
await Promise.all([pause, resume]);
|
||||
|
||||
assert.deepEqual(harness.order, ['save:true', 'pause', 'finish', 'resume', 'save:false', 'scan:resume:true']);
|
||||
assert.deepEqual(harness.order, ['save:true', 'pause', 'finish', 'resume', 'save:false', 'resume-manager', 'scan:resume:true']);
|
||||
assert.equal(harness.state().globalSettings.folderMonitor.paused, false);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
assert.equal(harness.sent[0][1].paused, false);
|
||||
@@ -711,7 +806,7 @@ test('automation lifecycle serializes resume then pause so the newer intent wins
|
||||
harness.pauseDeferred.resolve();
|
||||
await Promise.all([resume, pause]);
|
||||
|
||||
assert.deepEqual(harness.order, ['resume', 'save:false', 'scan:resume:true', 'save:true', 'pause', 'finish']);
|
||||
assert.deepEqual(harness.order, ['resume', 'save:false', 'resume-manager', 'scan:resume:true', '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);
|
||||
@@ -808,12 +903,39 @@ test('resume keeps pause authoritative until monitor success and restores the pr
|
||||
assert.equal(result.value.paused, true);
|
||||
assert.equal(result.value.pausedAt, 1);
|
||||
assert.deepEqual(harness.saves.map(save => save.paused), [true]);
|
||||
assert.deepEqual(harness.order, ['resume', 'stop', 'configure', 'save:true']);
|
||||
assert.deepEqual(harness.order, ['resume', 'finish', 'stop', 'configure', 'save:true']);
|
||||
assert.equal(harness.state().globalSettings.folderMonitor.paused, true);
|
||||
assert.equal(harness.state().globalSettings.folderMonitor.pausedAt, 1);
|
||||
assert.equal(JSON.stringify(result.value).includes('resume-secret'), false);
|
||||
});
|
||||
|
||||
test('resume reopens the manager before scanning and relatches it when the scan fails', async () => {
|
||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||
const harness = createAutomationLifecycleHarness(mainSource);
|
||||
harness.setScanError(new Error('scan failed'));
|
||||
|
||||
const resume = harness.handlers.get('automation:resume')();
|
||||
harness.resumeDeferred.resolve();
|
||||
await waitForCondition(() => harness.saves.length === 1);
|
||||
harness.saves[0].deferred.resolve();
|
||||
await waitForCondition(() => harness.saves.length === 2);
|
||||
harness.saves[1].deferred.resolve();
|
||||
const result = await resume;
|
||||
|
||||
assert.deepEqual(harness.order, [
|
||||
'resume',
|
||||
'save:false',
|
||||
'resume-manager',
|
||||
'scan:resume:true',
|
||||
'finish',
|
||||
'stop',
|
||||
'configure',
|
||||
'save:true'
|
||||
]);
|
||||
assert.equal(result.error, 'Automatik konnte nicht fortgesetzt werden');
|
||||
assert.equal(result.paused, true);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -197,6 +197,10 @@ contextBridge.exposeInMainWorld('api', {
|
||||
savedSettings: []
|
||||
};
|
||||
},
|
||||
setAutomationEvidence(value = {}) {
|
||||
if (Array.isArray(value.history)) automationProbe.history = value.history;
|
||||
if (Array.isArray(value.uploadLog)) automationProbe.uploadLog = value.uploadLog;
|
||||
},
|
||||
getAutomationProbeState() {
|
||||
return {
|
||||
readCalls: { ...automationProbe.readCalls },
|
||||
@@ -327,7 +331,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
return Promise.resolve(automationProbe.addResult || { added: payload?.jobs?.length || 0 });
|
||||
},
|
||||
startUpload(payload) {
|
||||
automationProbe.mutationCalls.push(['start', payload?.jobs?.length || 0]);
|
||||
automationProbe.mutationCalls.push(['start', payload?.jobs?.length || 0, (payload?.jobs || []).map(job => job.id)]);
|
||||
if (automationProbe.startError) return Promise.reject(new Error(automationProbe.startError));
|
||||
return Promise.resolve(automationProbe.startResult || { started: true });
|
||||
},
|
||||
@@ -824,6 +828,73 @@ contextBridge.exposeInMainWorld('api', {
|
||||
.map(job => normalizeAutomationPath(job.file)))],
|
||||
queuedTelemetry: config.globalSettings.folderMonitor.telemetry.queued
|
||||
};
|
||||
configureAtomicState(0);
|
||||
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
|
||||
hosterSettings = {};
|
||||
handleBatchDone({ files: [] });
|
||||
const evidenceSnapshotFiles = Array.from({ length: 66 }, (_, index) => ({
|
||||
path: 'C:\\\\evidence-snapshot\\\\file-' + String(index).padStart(2, '0') + '.mkv',
|
||||
name: 'file-' + String(index).padStart(2, '0') + '.mkv',
|
||||
size: 1,
|
||||
mtimeMs: index
|
||||
}));
|
||||
await handleFolderMonitorFiles(evidenceSnapshotFiles);
|
||||
const evidenceSnapshotProbe = await window.api.getAutomationProbeState();
|
||||
const evidenceSnapshotDrain = {
|
||||
historyCalls: evidenceSnapshotProbe.readCalls.history,
|
||||
uploadLogCalls: evidenceSnapshotProbe.readCalls.uploadLog,
|
||||
inspectCalls: evidenceSnapshotProbe.readCalls.inspect,
|
||||
batchSizes: evidenceSnapshotProbe.logs
|
||||
.filter(message => message.startsWith('folder-monitor: received '))
|
||||
.map(message => Number(message.split(' ')[2] || 0)),
|
||||
queuedFiles: new Set(queueJobs.filter(job => job.file.startsWith('C:\\\\evidence-snapshot\\\\')).map(job => job.file)).size
|
||||
};
|
||||
configureAtomicState(0);
|
||||
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
|
||||
hosterSettings = {};
|
||||
handleBatchDone({ files: [] });
|
||||
const separatedEventFiles = Array.from({ length: 66 }, (_, index) => ({
|
||||
path: 'C:\\\\separated-events\\\\file-' + String(index).padStart(2, '0') + '.mkv',
|
||||
name: 'file-' + String(index).padStart(2, '0') + '.mkv',
|
||||
size: 1,
|
||||
mtimeMs: index
|
||||
}));
|
||||
for (const file of separatedEventFiles) {
|
||||
await handleFolderMonitorFiles([file]);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
const separatedBurstProbe = await window.api.getAutomationProbeState();
|
||||
const invalidatedEvidenceFile = { path: 'C:\\\\separated-events\\\\invalidated.mkv', name: 'invalidated.mkv', size: 1, mtimeMs: 100 };
|
||||
window.api.setAutomationEvidence({
|
||||
history: [{ files: [{ ...invalidatedEvidenceFile, results: [{ hoster: 'doodstream.com', status: 'done' }] }] }]
|
||||
});
|
||||
handleBatchDone({ files: [] });
|
||||
await handleFolderMonitorFiles([invalidatedEvidenceFile]);
|
||||
const invalidatedProbe = await window.api.getAutomationProbeState();
|
||||
const expiredEvidenceFile = { path: 'C:\\\\separated-events\\\\expired.mkv', name: 'expired.mkv', size: 1, mtimeMs: 101 };
|
||||
window.api.setAutomationEvidence({
|
||||
history: [{ files: [{ ...expiredEvidenceFile, results: [{ hoster: 'doodstream.com', status: 'done' }] }] }]
|
||||
});
|
||||
automationEvidenceSnapshotCache.expiresAt = 0;
|
||||
await handleFolderMonitorFiles([expiredEvidenceFile]);
|
||||
const expiredProbe = await window.api.getAutomationProbeState();
|
||||
const separatedEventEvidence = {
|
||||
afterBurst: {
|
||||
historyCalls: separatedBurstProbe.readCalls.history,
|
||||
uploadLogCalls: separatedBurstProbe.readCalls.uploadLog,
|
||||
queuedFiles: new Set(queueJobs.filter(job => job.file.startsWith('C:\\\\separated-events\\\\file-')).map(job => job.file)).size
|
||||
},
|
||||
afterInvalidation: {
|
||||
historyCalls: invalidatedProbe.readCalls.history,
|
||||
uploadLogCalls: invalidatedProbe.readCalls.uploadLog,
|
||||
queued: queueJobs.some(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(invalidatedEvidenceFile.path))
|
||||
},
|
||||
afterExpiry: {
|
||||
historyCalls: expiredProbe.readCalls.history,
|
||||
uploadLogCalls: expiredProbe.readCalls.uploadLog,
|
||||
queued: queueJobs.some(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(expiredEvidenceFile.path))
|
||||
}
|
||||
};
|
||||
configureAtomicState(18);
|
||||
config.globalSettings.folderMonitor.queueLimitJobs = 20;
|
||||
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
|
||||
@@ -1838,6 +1909,76 @@ contextBridge.exposeInMainWorld('api', {
|
||||
secretExposed: JSON.stringify({ pausedInjection, unconfirmedInjection, exceptionInjection, telemetryFailure }).includes('secret')
|
||||
};
|
||||
|
||||
configureAtomicState(0);
|
||||
const pauseMarkerJob = makePauseRaceJob('pause-marker.mkv');
|
||||
pauseMarkerJob.status = 'queued';
|
||||
queueJobs = [pauseMarkerJob];
|
||||
uploading = true;
|
||||
rebuildJobIndex();
|
||||
window.api.configureAutomationProbe({ paused: false });
|
||||
applyAutomationRuntimeStatus({ paused: false });
|
||||
await toggleAutomationPauseResume();
|
||||
const pauseMarkerPersisted = buildPersistedQueueState()?.queueJobs.find(entry => entry.id === pauseMarkerJob.id);
|
||||
const pauseMarker = {
|
||||
marked: pauseMarkerJob.automationPaused === true,
|
||||
persisted: pauseMarkerPersisted?.automationPaused === true,
|
||||
persistedStatus: pauseMarkerPersisted?.status || null
|
||||
};
|
||||
|
||||
const runResumeQueueCase = async ({ active, resumeError = '' }) => {
|
||||
configureAtomicState(0);
|
||||
const job = makePauseRaceJob(active ? 'resume-active.mkv' : 'resume-idle.mkv');
|
||||
job.id = active ? 'resume-active' : 'resume-idle';
|
||||
job.status = 'aborted';
|
||||
job.error = 'Warteschlange angehalten';
|
||||
job.automationPaused = true;
|
||||
queueJobs = [
|
||||
job,
|
||||
{ ...makePauseRaceJob('manual-preview.mkv'), id: 'manual-preview', status: 'preview' },
|
||||
{ ...makePauseRaceJob('manual-queued.mkv'), id: 'manual-queued', status: 'queued' },
|
||||
{ ...makePauseRaceJob('manual-error.mkv'), id: 'manual-error', status: 'error' },
|
||||
{ ...makePauseRaceJob('manual-skipped.mkv'), id: 'manual-skipped', status: 'skipped' }
|
||||
];
|
||||
selectedFiles = [];
|
||||
selectedUploadHosters = ['doodstream.com'];
|
||||
config.globalSettings.folderMonitor.paused = true;
|
||||
uploading = active;
|
||||
rebuildJobIndex();
|
||||
window.api.configureAutomationProbe({
|
||||
paused: true,
|
||||
runtimeStatus: resumeError ? { error: resumeError } : {},
|
||||
addResult: { added: 1 },
|
||||
startResult: { started: true }
|
||||
});
|
||||
applyAutomationRuntimeStatus({ paused: true });
|
||||
await toggleAutomationPauseResume();
|
||||
const probe = await window.api.getAutomationProbeState();
|
||||
const acceptedStatus = job.status;
|
||||
const persistedJob = buildPersistedQueueState()?.queueJobs.find(entry => entry.id === job.id);
|
||||
if (!resumeError) {
|
||||
handleProgress({
|
||||
jobId: job.id,
|
||||
fileName: job.fileName,
|
||||
hoster: job.hoster,
|
||||
status: 'getting-server',
|
||||
bytesUploaded: 0,
|
||||
bytesTotal: job.bytesTotal
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: acceptedStatus,
|
||||
uploading,
|
||||
markerPersisted: persistedJob?.automationPaused === true,
|
||||
markerAfterProgress: job.automationPaused === true,
|
||||
mutations: probe.mutationCalls.map(call => ({ kind: call[0], count: call[1] || 0, ids: call[2] || [] }))
|
||||
};
|
||||
};
|
||||
const resumeQueue = {
|
||||
active: await runResumeQueueCase({ active: true }),
|
||||
idle: await runResumeQueueCase({ active: false }),
|
||||
rollback: await runResumeQueueCase({ active: false, resumeError: 'Automatik konnte nicht fortgesetzt werden.' })
|
||||
};
|
||||
|
||||
configureAtomicState(0);
|
||||
config.globalSettings.folderMonitor.paused = true;
|
||||
window.api.configureAutomationProbe({ paused: true });
|
||||
@@ -1879,7 +2020,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length,
|
||||
injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length
|
||||
};
|
||||
return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, distinctParallel, disjointClassification, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused };
|
||||
return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, evidenceSnapshotDrain, separatedEventEvidence, distinctParallel, disjointClassification, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, pauseMarker, resumeQueue, paused };
|
||||
})()`;
|
||||
const automationControlCenterScript = `(async () => {
|
||||
const waitFor = async predicate => {
|
||||
@@ -2670,6 +2811,52 @@ app.whenReady().then(async () => {
|
||||
matchingPaths: ['c:/watch/parallel.mkv'],
|
||||
queuedTelemetry: 1
|
||||
});
|
||||
assert.deepEqual(result.automationPipeline.evidenceSnapshotDrain, {
|
||||
historyCalls: 1,
|
||||
uploadLogCalls: 1,
|
||||
inspectCalls: 9,
|
||||
batchSizes: [8, 8, 8, 8, 8, 8, 8, 8, 2],
|
||||
queuedFiles: 66
|
||||
});
|
||||
assert.deepEqual(result.automationPipeline.separatedEventEvidence, {
|
||||
afterBurst: { historyCalls: 1, uploadLogCalls: 1, queuedFiles: 66 },
|
||||
afterInvalidation: { historyCalls: 2, uploadLogCalls: 2, queued: false },
|
||||
afterExpiry: { historyCalls: 3, uploadLogCalls: 3, queued: false }
|
||||
});
|
||||
assert.deepEqual(result.automationPipeline.resumeQueue, {
|
||||
active: {
|
||||
status: 'queued',
|
||||
uploading: true,
|
||||
markerPersisted: true,
|
||||
markerAfterProgress: false,
|
||||
mutations: [
|
||||
{ kind: 'resume', count: 0, ids: [] },
|
||||
{ kind: 'inject', count: 1, ids: ['resume-active'] }
|
||||
]
|
||||
},
|
||||
idle: {
|
||||
status: 'queued',
|
||||
uploading: true,
|
||||
markerPersisted: true,
|
||||
markerAfterProgress: false,
|
||||
mutations: [
|
||||
{ kind: 'resume', count: 0, ids: [] },
|
||||
{ kind: 'start', count: 1, ids: ['resume-idle'] }
|
||||
]
|
||||
},
|
||||
rollback: {
|
||||
status: 'aborted',
|
||||
uploading: false,
|
||||
markerPersisted: true,
|
||||
markerAfterProgress: true,
|
||||
mutations: [{ kind: 'resume', count: 0, ids: [] }]
|
||||
}
|
||||
});
|
||||
assert.deepEqual(result.automationPipeline.pauseMarker, {
|
||||
marked: true,
|
||||
persisted: true,
|
||||
persistedStatus: 'queued'
|
||||
});
|
||||
assert.deepEqual(result.automationPipeline.distinctParallel, {
|
||||
inspectCalls: 3,
|
||||
maxConcurrentInspections: 1,
|
||||
@@ -3670,6 +3857,7 @@ let walkCalls = 0;
|
||||
let addJobsCalls = 0;
|
||||
let startBatchCalls = 0;
|
||||
let finishCalls = 0;
|
||||
let stoppingAfterActive = true;
|
||||
let cleanupRelease;
|
||||
let cleanupStartedResolve;
|
||||
const cleanupStarted = new Promise(resolve => { cleanupStartedResolve = resolve; });
|
||||
@@ -3727,11 +3915,16 @@ let uploadManager = {
|
||||
addJobsCalls++;
|
||||
return { added: tasks.length, alreadyInBatchJobIds: [] };
|
||||
},
|
||||
isStoppingAfterActive: () => stoppingAfterActive,
|
||||
resumeAfterActive: () => { stoppingAfterActive = false; },
|
||||
startBatch: () => {
|
||||
startBatchCalls++;
|
||||
return Promise.resolve();
|
||||
},
|
||||
finishAfterActive: () => { finishCalls++; }
|
||||
finishAfterActive: () => {
|
||||
finishCalls++;
|
||||
stoppingAfterActive = true;
|
||||
}
|
||||
};
|
||||
${productionHandlers}
|
||||
${automationHandlers}
|
||||
|
||||
@@ -648,6 +648,231 @@ describe('UploadManager', () => {
|
||||
assert.equal(batchDoneEvents.length, 1);
|
||||
});
|
||||
|
||||
it('addJobs rejects new work while stopping and accepts it after resume', async () => {
|
||||
let releaseActive;
|
||||
const started = [];
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||
started.push(filePath);
|
||||
if (filePath.endsWith('/active.mp4')) {
|
||||
await new Promise(resolve => { releaseActive = resolve; });
|
||||
}
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
});
|
||||
const batch = mgr.startBatch([
|
||||
{ jobId: 'active', file: '/test/active.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
for (let attempt = 0; attempt < 50 && !releaseActive; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(typeof releaseActive, 'function');
|
||||
|
||||
mgr.finishAfterActive();
|
||||
const stopping = typeof mgr.isStoppingAfterActive === 'function'
|
||||
? mgr.isStoppingAfterActive()
|
||||
: undefined;
|
||||
const result = mgr.addJobs([
|
||||
{ jobId: 'rejected', file: '/test/rejected.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
await mgr.resumeAfterActive();
|
||||
const resumedResult = mgr.addJobs([
|
||||
{ jobId: 'resumed', file: '/test/resumed.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
releaseActive();
|
||||
await batch;
|
||||
|
||||
assert.equal(stopping, true);
|
||||
assert.deepEqual(result, { added: 0, alreadyInBatchJobIds: [] });
|
||||
assert.deepEqual(resumedResult, { added: 1, alreadyInBatchJobIds: [] });
|
||||
assert.equal(mgr.isStoppingAfterActive(), false);
|
||||
assert.deepEqual(started, ['/test/active.mp4', '/test/resumed.mp4']);
|
||||
});
|
||||
|
||||
it('finishAfterActive bypasses queued interval waits', async () => {
|
||||
let releaseActive;
|
||||
const started = [];
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||
started.push(filePath);
|
||||
if (filePath.endsWith('/active.mp4')) {
|
||||
await new Promise(resolve => { releaseActive = resolve; });
|
||||
}
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 1, maxSizeMb: 0 }
|
||||
});
|
||||
const batch = mgr.startBatch([
|
||||
{ jobId: 'active', file: '/test/active.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||
{ jobId: 'queued-1', file: '/test/queued-1.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||
{ jobId: 'queued-2', file: '/test/queued-2.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||
{ jobId: 'queued-3', file: '/test/queued-3.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
for (let attempt = 0; attempt < 50 && !releaseActive; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(typeof releaseActive, 'function');
|
||||
|
||||
mgr.finishAfterActive();
|
||||
const stoppedAt = Date.now();
|
||||
releaseActive();
|
||||
await batch;
|
||||
|
||||
assert.ok(Date.now() - stoppedAt < 500, `queued jobs took ${Date.now() - stoppedAt} ms to stop`);
|
||||
assert.deepEqual(started, ['/test/active.mp4']);
|
||||
});
|
||||
|
||||
it('finishAfterActive interrupts a job already waiting inside the upload interval', async () => {
|
||||
let releaseActive;
|
||||
let intervalEnteredResolve;
|
||||
const intervalEntered = new Promise(resolve => { intervalEnteredResolve = resolve; });
|
||||
let intervalCalls = 0;
|
||||
const started = [];
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||
started.push(filePath);
|
||||
if (filePath.endsWith('/active.mp4')) {
|
||||
await new Promise(resolve => { releaseActive = resolve; });
|
||||
}
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 2, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 2, maxSizeMb: 0 }
|
||||
});
|
||||
mgr._waitForInterval = (hoster, intervalMs, signal) => new Promise((resolve, reject) => {
|
||||
intervalCalls++;
|
||||
if (intervalCalls === 1) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
intervalEnteredResolve();
|
||||
if (signal.aborted) reject(new Error('Aborted'));
|
||||
else signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
|
||||
});
|
||||
const batch = mgr.startBatch([
|
||||
{ jobId: 'active', file: '/test/active.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
for (let attempt = 0; attempt < 50 && !releaseActive; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(typeof releaseActive, 'function');
|
||||
assert.deepEqual(mgr.addJobs([
|
||||
{ jobId: 'interval-waiter', file: '/test/interval-waiter.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]), { added: 1, alreadyInBatchJobIds: [] });
|
||||
await intervalEntered;
|
||||
|
||||
const stoppedAt = Date.now();
|
||||
mgr.finishAfterActive();
|
||||
releaseActive();
|
||||
await batch;
|
||||
|
||||
assert.ok(Date.now() - stoppedAt < 500, `interval waiter took ${Date.now() - stoppedAt} ms to stop`);
|
||||
assert.deepEqual(started, ['/test/active.mp4']);
|
||||
});
|
||||
|
||||
it('finishAfterActive interrupts a job already waiting for the global upload slot', async () => {
|
||||
let releaseActive;
|
||||
const started = [];
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||
started.push(filePath);
|
||||
if (filePath.endsWith('/active.mp4')) {
|
||||
await new Promise(resolve => { releaseActive = resolve; });
|
||||
}
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 },
|
||||
'byse.sx': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
}, { parallelUploadCount: 1 });
|
||||
const batch = mgr.startBatch([
|
||||
{ jobId: 'active', file: '/test/active.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
for (let attempt = 0; attempt < 50 && !releaseActive; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(typeof releaseActive, 'function');
|
||||
assert.deepEqual(mgr.addJobs([
|
||||
{ jobId: 'global-waiter', file: '/test/global-waiter.mp4', hoster: 'byse.sx', apiKey: 'key2' }
|
||||
]), { added: 1, alreadyInBatchJobIds: [] });
|
||||
for (let attempt = 0; attempt < 50 && mgr.globalSemaphore.pending === 0; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(mgr.globalSemaphore.pending, 1);
|
||||
|
||||
const stoppedAt = Date.now();
|
||||
mgr.finishAfterActive();
|
||||
releaseActive();
|
||||
await batch;
|
||||
|
||||
assert.ok(Date.now() - stoppedAt < 500, `global waiter took ${Date.now() - stoppedAt} ms to stop`);
|
||||
assert.deepEqual(started, ['/test/active.mp4']);
|
||||
});
|
||||
|
||||
it('resumeAfterActive waits for stopped admission jobs before reopening the queue', async () => {
|
||||
let releaseActive;
|
||||
let intervalEnteredResolve;
|
||||
const intervalEntered = new Promise(resolve => { intervalEnteredResolve = resolve; });
|
||||
let intervalCalls = 0;
|
||||
const started = [];
|
||||
const terminal = [];
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||
started.push(filePath);
|
||||
if (filePath.endsWith('/active.mp4')) {
|
||||
await new Promise(resolve => { releaseActive = resolve; });
|
||||
}
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 2, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 2, maxSizeMb: 0 }
|
||||
});
|
||||
mgr._waitForInterval = (hoster, intervalMs, signal) => new Promise((resolve, reject) => {
|
||||
intervalCalls++;
|
||||
if (intervalCalls === 1) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
intervalEnteredResolve();
|
||||
if (signal.aborted) reject(new Error('Aborted'));
|
||||
else signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
|
||||
});
|
||||
mgr.on('progress', value => {
|
||||
if (value.jobId === 'interval-waiter' && ['aborted', 'error'].includes(value.status)) terminal.push(value.status);
|
||||
});
|
||||
const batch = mgr.startBatch([
|
||||
{ jobId: 'active', file: '/test/active.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
for (let attempt = 0; attempt < 50 && !releaseActive; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(typeof releaseActive, 'function');
|
||||
assert.deepEqual(mgr.addJobs([
|
||||
{ jobId: 'interval-waiter', file: '/test/interval-waiter.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]), { added: 1, alreadyInBatchJobIds: [] });
|
||||
await intervalEntered;
|
||||
|
||||
const resumedAt = Date.now();
|
||||
mgr.finishAfterActive();
|
||||
await mgr.resumeAfterActive();
|
||||
|
||||
assert.ok(Date.now() - resumedAt < 500, `resume waited ${Date.now() - resumedAt} ms for admission shutdown`);
|
||||
assert.equal(mgr.isStoppingAfterActive(), false);
|
||||
assert.deepEqual(terminal, ['aborted']);
|
||||
releaseActive();
|
||||
await batch;
|
||||
assert.deepEqual(started, ['/test/active.mp4']);
|
||||
});
|
||||
|
||||
it('_combineSignals propagates abort from either source', () => {
|
||||
const mgr = new UploadManager({});
|
||||
const ac1 = new AbortController();
|
||||
|
||||
Reference in New Issue
Block a user