mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
Findings from the original review (P2): - streaming: flush unterminated final SSE event at EOF (some providers close the stream without a trailing blank line, dropping the last delta) - cache-manager: validate entry shape on load — drop entries where cards is not an array, anchor is not a string, or bullets is not an array. Tolerates missing optional fields (treated as cache miss). - view: card edit/delete now check cacheReplaceCards return and surface failures via a localized Notice instead of pretending success - main: clear-current / clear-all / file-menu-clear refresh open view via renderEmpty so stale UI does not display deleted data - prompt + settings-tab: card count is normalized via a single helper used by buildPrompts and onChange. Prompt and fingerprint stay in sync. UI value is written back to the textbox after clamping. - generation-job-manager: global concurrency limit (default 3) with a cancellable wait queue. Race-safe slot accounting via a reserved counter so resolved-but-not-yet-set waiters are visible to fast-path start() callers. - runForFile: now returns RunForFileResult; accepts preloadedContent + silentView + skipEditConfirm options used by batch (and reflected on the PluginHost interface). - batch: avoid double file read, do not steal UI focus, classify results correctly (generated / cached / already-running / empty / ...) Follow-up from the 1.0.11 review: - generationFingerprint: codex backend excludes settings.model from the hash. Codex ignores --model; previously editing model spuriously invalidated all codex cache. Codex review pass: - generation-job-manager: race fix — releaseSlot's resolve microtask and a synchronous start() fast-path could briefly overshoot maxConcurrent. Reserve the slot synchronously inside the wrapped resolve to close the window. - cache-manager: anchor type validation in addition to bullets. NOTE: Codex backend users will see a one-time "stale cache" banner on existing notes due to the fingerprint change; regenerate to refresh. Change-Id: I7721c7dfe51dea3f51b0215764f721523c2f6806
206 lines
7.1 KiB
JavaScript
206 lines
7.1 KiB
JavaScript
const { assert, t } = require('./test-setup');
|
|
|
|
const { GenerationJobAlreadyRunningError, GenerationJobCancelledError, GenerationJobManager, classifyGenerationError } =
|
|
t;
|
|
|
|
async function testSingleFlightAndCleanup() {
|
|
const manager = new GenerationJobManager();
|
|
let release;
|
|
const blocker = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
|
|
const first = manager.start('note.md', async (job) => {
|
|
job.setPhase('reading');
|
|
await blocker;
|
|
job.setPhase('done');
|
|
return 'ok';
|
|
});
|
|
|
|
assert.strictEqual(manager.isRunning('note.md'), true);
|
|
assert.strictEqual(manager.get('note.md').phase, 'reading');
|
|
await assert.rejects(() => manager.start('note.md', async () => 'duplicate'), GenerationJobAlreadyRunningError);
|
|
|
|
release();
|
|
assert.strictEqual(await first, 'ok');
|
|
assert.strictEqual(manager.isRunning('note.md'), false);
|
|
assert.strictEqual(manager.get('note.md'), null);
|
|
}
|
|
|
|
async function testGlobalConcurrencyLimit() {
|
|
const manager = new GenerationJobManager(2); // max 2 concurrent
|
|
const releases = [null, null, null, null];
|
|
const blockers = releases.map(
|
|
(_, i) =>
|
|
new Promise((r) => {
|
|
releases[i] = r;
|
|
}),
|
|
);
|
|
const startedAt = [];
|
|
|
|
const promises = ['a.md', 'b.md', 'c.md', 'd.md'].map((path, i) =>
|
|
manager.start(path, async () => {
|
|
startedAt.push(i);
|
|
await blockers[i];
|
|
return path;
|
|
}),
|
|
);
|
|
|
|
// Let microtasks flush
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
|
|
assert.strictEqual(manager.isRunning('a.md'), true, 'a is running');
|
|
assert.strictEqual(manager.isRunning('b.md'), true, 'b is running');
|
|
assert.strictEqual(manager.isRunning('c.md'), false, 'c is queued, not running yet');
|
|
assert.strictEqual(manager.isRunning('d.md'), false, 'd is queued');
|
|
assert.strictEqual(manager.isPending('c.md'), true, 'c is pending (queued)');
|
|
assert.strictEqual(manager.waitingCount(), 2, 'two waiters queued');
|
|
assert.deepStrictEqual(startedAt, [0, 1], 'only first 2 entered runner');
|
|
|
|
// Release first -> c gets slot
|
|
releases[0]();
|
|
await promises[0];
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.strictEqual(manager.isRunning('c.md'), true, 'c promoted after a finishes');
|
|
assert.strictEqual(manager.waitingCount(), 1, 'one waiter left');
|
|
|
|
// Same-key dedup while queued: starting c again throws (without waiting)
|
|
await assert.rejects(
|
|
() => manager.start('d.md', async () => 'dup'),
|
|
GenerationJobAlreadyRunningError,
|
|
'same-key (queued) dedup throws',
|
|
);
|
|
|
|
releases[1]();
|
|
releases[2]();
|
|
releases[3]();
|
|
await Promise.all(promises);
|
|
assert.strictEqual(manager.waitingCount(), 0);
|
|
assert.strictEqual(manager.isRunning('d.md'), false, 'all jobs settled');
|
|
}
|
|
|
|
async function testNoOverbookingDuringRelease() {
|
|
// Regression for the microtask race: when a queued waiter is resolved by
|
|
// releaseSlot(), a synchronous start() call must NOT slip past with the now-stale
|
|
// jobs.size and bring concurrent runners above maxConcurrent.
|
|
const manager = new GenerationJobManager(2);
|
|
const releases = [null, null, null];
|
|
const runnerEntered = [];
|
|
const blockers = releases.map(
|
|
(_, i) =>
|
|
new Promise((r) => {
|
|
releases[i] = r;
|
|
}),
|
|
);
|
|
|
|
const a = manager.start('a.md', async () => {
|
|
runnerEntered.push('a');
|
|
await blockers[0];
|
|
return 'a';
|
|
});
|
|
const b = manager.start('b.md', async () => {
|
|
runnerEntered.push('b');
|
|
await blockers[1];
|
|
return 'b';
|
|
});
|
|
// c is queued (slot full); c will block too so we can observe state during transitions.
|
|
const c = manager.start('c.md', async () => {
|
|
runnerEntered.push('c');
|
|
await blockers[2];
|
|
return 'c';
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.strictEqual(manager.waitingCount(), 1, 'c is queued');
|
|
assert.deepStrictEqual(runnerEntered, ['a', 'b'], 'only 2 runners entered');
|
|
|
|
// Release a → c's waiter resolves and continuation queued. c will run and block.
|
|
releases[0]();
|
|
await a;
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
// After a settles + microtasks: c is now in jobs (b + c running), runnerEntered = a,b,c
|
|
assert.deepStrictEqual(runnerEntered, ['a', 'b', 'c'], 'c promoted after a finished');
|
|
|
|
// Now slots are full again (b + c). New start('d.md') MUST queue, not jump in.
|
|
const d = manager.start('d.md', async () => {
|
|
runnerEntered.push('d');
|
|
return 'd';
|
|
});
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.strictEqual(manager.waitingCount(), 1, 'd queued because b+c are running');
|
|
assert.deepStrictEqual(runnerEntered, ['a', 'b', 'c'], 'd not entered while b+c running');
|
|
|
|
releases[1]();
|
|
releases[2]();
|
|
await Promise.all([b, c, d]);
|
|
assert.ok(runnerEntered.includes('d'), 'd entered eventually');
|
|
assert.strictEqual(manager.waitingCount(), 0);
|
|
}
|
|
|
|
async function testCancelAllWaiters() {
|
|
const manager = new GenerationJobManager(1);
|
|
let release;
|
|
const blocker = new Promise((r) => {
|
|
release = r;
|
|
});
|
|
|
|
const running = manager.start('first.md', async () => {
|
|
await blocker;
|
|
return 'first';
|
|
});
|
|
|
|
// queued: should be cancellable
|
|
const queued1 = manager.start('q1.md', async () => 'q1');
|
|
const queued2 = manager.start('q2.md', async () => 'q2');
|
|
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.strictEqual(manager.waitingCount(), 2, 'two queued');
|
|
|
|
const drained = manager.cancelAllWaiters();
|
|
assert.strictEqual(drained, 2, 'cancelAllWaiters returns drained count');
|
|
await assert.rejects(queued1, GenerationJobCancelledError, 'q1 rejects with cancelled');
|
|
await assert.rejects(queued2, GenerationJobCancelledError, 'q2 rejects with cancelled');
|
|
|
|
release();
|
|
assert.strictEqual(await running, 'first', 'running job still completes');
|
|
assert.strictEqual(manager.waitingCount(), 0);
|
|
}
|
|
|
|
async function testCancelSignalsRunnerAndCleansUp() {
|
|
const manager = new GenerationJobManager();
|
|
let cancelHookCalled = false;
|
|
|
|
const running = manager.start('cancel.md', async (job) => {
|
|
job.onCancel(() => {
|
|
cancelHookCalled = true;
|
|
});
|
|
await Promise.resolve();
|
|
job.throwIfCancelled();
|
|
return 'should-not-complete';
|
|
});
|
|
|
|
assert.strictEqual(manager.cancel('cancel.md'), true);
|
|
await assert.rejects(running, GenerationJobCancelledError);
|
|
assert.strictEqual(cancelHookCalled, true);
|
|
assert.strictEqual(manager.isRunning('cancel.md'), false);
|
|
assert.strictEqual(manager.cancel('cancel.md'), false);
|
|
}
|
|
|
|
function testErrorClassification() {
|
|
assert.strictEqual(classifyGenerationError(new Error('API key 未设置')), 'auth');
|
|
assert.strictEqual(classifyGenerationError(new Error('CLI 超时 (120000ms)')), 'timeout');
|
|
assert.strictEqual(classifyGenerationError(new Error('OpenAI API 429: rate limit')), 'rate-limit');
|
|
assert.strictEqual(classifyGenerationError(new Error('LLM 返回非 JSON')), 'schema');
|
|
assert.strictEqual(classifyGenerationError(new Error('Model 未设置')), 'config');
|
|
assert.strictEqual(classifyGenerationError(new Error('something else')), 'unknown');
|
|
}
|
|
|
|
(async () => {
|
|
await testSingleFlightAndCleanup();
|
|
await testGlobalConcurrencyLimit();
|
|
await testNoOverbookingDuringRelease();
|
|
await testCancelAllWaiters();
|
|
await testCancelSignalsRunnerAndCleansUp();
|
|
testErrorClassification();
|
|
console.log('generation job manager tests passed');
|
|
})();
|