Harden batch-wide recovery identity claims
Scope remote code ownership to normalized hoster and account identities while retaining title-only recovery serialization and canonical Unicode title matching. Mark post-upload ambiguity and duplicate identities as uncertain so retries, account fallback, and later same-title jobs fail closed instead of reporting unsafe success. Acquire recovery title leases before hoster and global semaphores, revalidate failed-account overrides before upload, and clear claim state at batch boundaries. Add deterministic concurrent coverage for same-code rejection, distinct-code parallel success, uncertainty propagation, semaphore fairness, account isolation, Unicode equivalence, and registry lifetime.
This commit is contained in:
@@ -56,12 +56,12 @@ function settings(hoster, parallelCount) {
|
||||
};
|
||||
}
|
||||
|
||||
async function runBatch(manager, tasks) {
|
||||
async function runBatch(manager, tasks, options) {
|
||||
let summary;
|
||||
manager.once('batch-done', value => {
|
||||
summary = value;
|
||||
});
|
||||
await manager.startBatch(tasks);
|
||||
await manager.startBatch(tasks, options);
|
||||
return summary;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,56 @@ function waitFor(promise, timeoutMs, message) {
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
async function assertTitleWaiterLeavesSlotAvailable(hosterParallel, globalSettings = {}) {
|
||||
let releaseFirst;
|
||||
let markFirstStarted;
|
||||
let markIndependentStarted;
|
||||
const firstGate = new Promise(resolve => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
const firstStarted = new Promise(resolve => {
|
||||
markFirstStarted = resolve;
|
||||
});
|
||||
const independentStarted = new Promise(resolve => {
|
||||
markIndependentStarted = resolve;
|
||||
});
|
||||
let sequence = 0;
|
||||
loadManager(async (hoster, file) => {
|
||||
if (file === firstPath) {
|
||||
markFirstStarted();
|
||||
await firstGate;
|
||||
}
|
||||
if (file === distinctPath) markIndependentStarted();
|
||||
sequence++;
|
||||
return {
|
||||
file_code: `ADMISSION_${sequence}`,
|
||||
download_url: `https://byse.sx/d/ADMISSION_${sequence}`
|
||||
};
|
||||
});
|
||||
const manager = new UploadManager(settings('byse.sx', hosterParallel), globalSettings);
|
||||
const batch = runBatch(manager, [
|
||||
{ jobId: 'admission-first', file: firstPath, hoster: 'byse.sx', apiKey: 'ACCOUNT_KEY' }
|
||||
]);
|
||||
|
||||
await waitFor(firstStarted, 500, 'First upload did not start');
|
||||
const added = manager.addJobs([
|
||||
{ jobId: 'admission-waiter', file: secondPath, hoster: 'byse.sx', apiKey: 'ACCOUNT_KEY' },
|
||||
{ jobId: 'admission-independent', file: distinctPath, hoster: 'byse.sx', apiKey: 'ACCOUNT_KEY' }
|
||||
]);
|
||||
assert.equal(added.added, 2);
|
||||
let admissionError = null;
|
||||
try {
|
||||
await waitFor(independentStarted, 500, 'Independent title was blocked behind a title-lock waiter');
|
||||
} catch (err) {
|
||||
admissionError = err;
|
||||
} finally {
|
||||
releaseFirst();
|
||||
}
|
||||
const summary = await batch;
|
||||
if (admissionError) throw admissionError;
|
||||
assert.equal(summary.succeeded, 3);
|
||||
}
|
||||
|
||||
test('a batch shares recovery claims across normalized same-name jobs', async () => {
|
||||
let unsafeCalls = 0;
|
||||
loadManager(async (hoster, file, apiKey, onProgress, signal, throttle, options) => {
|
||||
@@ -172,6 +222,93 @@ test('normalized same-name recovery sections never overlap', async () => {
|
||||
assert.equal(maximumActive, 1);
|
||||
});
|
||||
|
||||
test('a title-lock waiter does not consume a scarce upload slot', async () => {
|
||||
await assertTitleWaiterLeavesSlotAvailable(2);
|
||||
});
|
||||
|
||||
test('a title-lock waiter does not consume a scarce global upload slot', async () => {
|
||||
await assertTitleWaiterLeavesSlotAvailable(3, { parallelUploadCount: 2 });
|
||||
});
|
||||
|
||||
test('an uncertain remote commit blocks retries, account fallback, and later same-title success', async () => {
|
||||
const calls = [];
|
||||
let markFirstStarted;
|
||||
let releaseUncertain;
|
||||
const firstStarted = new Promise(resolve => {
|
||||
markFirstStarted = resolve;
|
||||
});
|
||||
const uncertainGate = new Promise(resolve => {
|
||||
releaseUncertain = resolve;
|
||||
});
|
||||
loadManager(async (hoster, file, apiKey) => {
|
||||
calls.push({ file, apiKey });
|
||||
if (file === firstPath) {
|
||||
markFirstStarted();
|
||||
await uncertainGate;
|
||||
const error = new Error('Remote commit could not be confirmed');
|
||||
error.remoteCommitUncertain = true;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
file_code: 'LATE_REMOTE_CODE',
|
||||
download_url: 'https://byse.sx/d/LATE_REMOTE_CODE'
|
||||
};
|
||||
});
|
||||
const hosterSettings = settings('byse.sx', 2);
|
||||
hosterSettings['byse.sx'].retries = 2;
|
||||
const manager = new UploadManager(hosterSettings);
|
||||
const fallback = { id: 'ACCOUNT_B', apiKey: 'ACCOUNT_KEY_B' };
|
||||
|
||||
const batch = runBatch(manager, [
|
||||
{
|
||||
jobId: 'uncertain-first',
|
||||
file: firstPath,
|
||||
hoster: 'byse.sx',
|
||||
accountId: 'ACCOUNT_A',
|
||||
apiKey: 'ACCOUNT_KEY_A'
|
||||
}
|
||||
], { primeOverrides: [['byse.sx', fallback]] });
|
||||
await waitFor(firstStarted, 500, 'Uncertain predecessor did not start');
|
||||
const added = manager.addJobs([
|
||||
{
|
||||
jobId: 'uncertain-later',
|
||||
file: secondPath,
|
||||
hoster: 'byse.sx',
|
||||
accountId: 'ACCOUNT_A',
|
||||
apiKey: 'ACCOUNT_KEY_A'
|
||||
}
|
||||
]);
|
||||
assert.equal(added.added, 1);
|
||||
releaseUncertain();
|
||||
const summary = await batch;
|
||||
|
||||
assert.equal(summary.succeeded, 0);
|
||||
assert.equal(summary.failed, 2);
|
||||
assert.deepEqual(calls, [{ file: firstPath, apiKey: 'ACCOUNT_KEY_A' }]);
|
||||
});
|
||||
|
||||
test('recovery claims do not leak into a later batch on the same manager', async () => {
|
||||
loadManager(async (hoster, file, apiKey, onProgress, signal, throttle, options) => {
|
||||
if (!options.recoveryClaim.reserve('REUSED_BATCH_CODE')) {
|
||||
const error = new Error('Remote recovery candidate already claimed');
|
||||
error.hosterTransient = true;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
file_code: 'REUSED_BATCH_CODE',
|
||||
download_url: 'https://byse.sx/d/REUSED_BATCH_CODE'
|
||||
};
|
||||
});
|
||||
const manager = new UploadManager(settings('byse.sx', 1));
|
||||
const task = { jobId: 'batch-one', file: firstPath, hoster: 'byse.sx', apiKey: 'ACCOUNT_KEY' };
|
||||
|
||||
const first = await runBatch(manager, [task]);
|
||||
const second = await runBatch(manager, [{ ...task, jobId: 'batch-two' }]);
|
||||
|
||||
assert.equal(first.succeeded, 1);
|
||||
assert.equal(second.succeeded, 1);
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
label: 'VOE',
|
||||
@@ -263,6 +400,41 @@ for (const scenario of [
|
||||
);
|
||||
});
|
||||
|
||||
test(`${scenario.label} uploader instances reject one direct remote code across different titles`, async () => {
|
||||
await withUploaderMethods(
|
||||
scenario.Uploader,
|
||||
async function () {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
return scenario.buildResult(this, scenario.sharedCode);
|
||||
},
|
||||
async () => {
|
||||
loadManager();
|
||||
const manager = new UploadManager(settings(scenario.hoster, 2));
|
||||
const summary = await runBatch(manager, [
|
||||
{
|
||||
jobId: `${scenario.label}-different-title-a`,
|
||||
file: firstPath,
|
||||
hoster: scenario.hoster,
|
||||
accountId: 'LOGIN_ACCOUNT',
|
||||
username: 'account@example.test',
|
||||
password: 'password'
|
||||
},
|
||||
{
|
||||
jobId: `${scenario.label}-different-title-b`,
|
||||
file: distinctPath,
|
||||
hoster: scenario.hoster,
|
||||
accountId: 'LOGIN_ACCOUNT',
|
||||
username: 'account@example.test',
|
||||
password: 'password'
|
||||
}
|
||||
]);
|
||||
|
||||
assert.equal(summary.succeeded, 1);
|
||||
assert.equal(summary.failed, 1);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test(`${scenario.label} uploader instances preserve parallel success for distinct remote identities`, async () => {
|
||||
let active = 0;
|
||||
let maximumActive = 0;
|
||||
|
||||
Reference in New Issue
Block a user