mirror of
https://github.com/aaronsb/obsidian-mcp-plugin.git
synced 2026-07-22 06:45:14 +00:00
Parallel edit.window/append/patch/at_line/from_buffer calls against the same file (the recommended batched-tool pattern for MCP clients) ran overlapping read-modify-write cycles: every call returned "Edit successful" but only one edit survived, with no error surfaced — silent data loss. A new SemanticRouter is constructed per request, so a per-instance lock cannot serialize concurrent requests. Add a process-wide FileLockManager singleton (same pattern as ContentBufferManager) that serializes operations on the *same* file path in arrival order while leaving different paths fully concurrent. executeEditOperation runs its whole action switch inside withLock(params.path, …). - src/utils/file-lock.ts: promise-chain-per-path lock; chains drain so the map stays bounded; a rejected holder doesn't break later waiters. - router.ts: wrap executeEditOperation in the per-file lock; also move the window-edit dynamic import into the two cases that use it (window, from_buffer) instead of eagerly on every edit action — append/patch/ at_line no longer load it, and the path is unit-testable. - tests/file-lock-edit-serialization.test.ts: FileLockManager unit behaviour + a router-level #139 repro (3 concurrent appends; without the lock the read-modify-write MockAPI loses 2 of 3 — with it all land) + a different-files-stay-concurrent assertion. Scope: edit.* actions (the #139 report). No router extraction (that is #199's own PR). make check green (build + lint 0 errors + 231/231).
131 lines
4.8 KiB
TypeScript
131 lines
4.8 KiB
TypeScript
/**
|
|
* Regression for #139: parallel edit actions against the same file silently
|
|
* clobbered each other (every call returned success, only one edit survived).
|
|
*
|
|
* Two layers:
|
|
* 1. FileLockManager unit behaviour (same-path serialized, different-path
|
|
* concurrent, error-isolated, map drains).
|
|
* 2. Router-level repro: a MockAPI whose appendToFile is a realistic
|
|
* read-modify-write with an await gap. Without serialization, three
|
|
* parallel edit.append calls lose updates; with the #139 fix all land.
|
|
*/
|
|
import { SemanticRouter } from '../src/semantic/router';
|
|
import { ObsidianAPI } from '../src/utils/obsidian-api';
|
|
import { FileLockManager } from '../src/utils/file-lock';
|
|
import { App } from 'obsidian';
|
|
|
|
describe('FileLockManager (unit)', () => {
|
|
const mgr = FileLockManager.getInstance();
|
|
|
|
test('serializes critical sections for the same path', async () => {
|
|
const events: string[] = [];
|
|
const op = (tag: string) => mgr.withLock('same.md', async () => {
|
|
events.push(`${tag}:start`);
|
|
await new Promise(r => setTimeout(r, 5));
|
|
events.push(`${tag}:end`);
|
|
});
|
|
await Promise.all([op('A'), op('B'), op('C')]);
|
|
// No interleaving: each start is immediately followed by its own end.
|
|
expect(events).toEqual([
|
|
'A:start', 'A:end',
|
|
'B:start', 'B:end',
|
|
'C:start', 'C:end',
|
|
]);
|
|
});
|
|
|
|
test('different paths run concurrently', async () => {
|
|
const order: string[] = [];
|
|
const slow = mgr.withLock('x.md', async () => {
|
|
await new Promise(r => setTimeout(r, 20));
|
|
order.push('x');
|
|
});
|
|
const fast = mgr.withLock('y.md', async () => {
|
|
order.push('y');
|
|
});
|
|
await Promise.all([slow, fast]);
|
|
expect(order).toEqual(['y', 'x']); // y not blocked behind x
|
|
});
|
|
|
|
test('a rejected holder does not break serialization for later waiters', async () => {
|
|
const seen: string[] = [];
|
|
const bad = mgr.withLock('z.md', async () => { throw new Error('boom'); });
|
|
const good = mgr.withLock('z.md', async () => { seen.push('ran'); });
|
|
await expect(bad).rejects.toThrow('boom');
|
|
await good;
|
|
expect(seen).toEqual(['ran']);
|
|
});
|
|
|
|
test('chain entries drain so the map does not grow unbounded', async () => {
|
|
await mgr.withLock('drain-a.md', async () => {});
|
|
await mgr.withLock('drain-b.md', async () => {});
|
|
// allow the post-drain microtask cleanup to run
|
|
await new Promise(r => setTimeout(r, 0));
|
|
expect(mgr.activeLockCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* MockAPI.appendToFile mimics a read-modify-write with an await gap — the
|
|
* exact shape that loses concurrent updates without a per-file lock.
|
|
*/
|
|
class MockAPI extends ObsidianAPI {
|
|
files = new Map<string, string>([['_test.md', '# Test\n']]);
|
|
|
|
constructor() {
|
|
super({} as App);
|
|
}
|
|
|
|
async appendToFile(path: string, content: string): Promise<any> {
|
|
const current = this.files.get(path) ?? '';
|
|
// Yield: a competing append can read the same `current` here if the
|
|
// edit handler is not serializing per file.
|
|
await new Promise(r => setTimeout(r, 5));
|
|
this.files.set(path, current + content);
|
|
return { success: true, path };
|
|
}
|
|
}
|
|
|
|
describe('edit.append parallel calls against one file (#139, router level)', () => {
|
|
test('all three concurrent appends persist (no silent clobber)', async () => {
|
|
const api = new MockAPI();
|
|
|
|
// Fresh router per call — faithful to production (router is per-request;
|
|
// FileLockManager is the process-wide singleton that actually serializes).
|
|
const append = (line: string) =>
|
|
new SemanticRouter(api).route({
|
|
operation: 'edit',
|
|
action: 'append',
|
|
params: { path: '_test.md', content: line },
|
|
});
|
|
|
|
const results = await Promise.all([
|
|
append('ALPHA\n'),
|
|
append('BRAVO\n'),
|
|
append('CHARLIE\n'),
|
|
]);
|
|
|
|
// Every call still reports success...
|
|
for (const r of results) {
|
|
expect((r as any).error).toBeFalsy();
|
|
}
|
|
// ...and every edit actually landed (the #139 bug lost two of three).
|
|
const final = api.files.get('_test.md')!;
|
|
expect(final).toContain('ALPHA');
|
|
expect(final).toContain('BRAVO');
|
|
expect(final).toContain('CHARLIE');
|
|
expect(final).toBe('# Test\nALPHA\nBRAVO\nCHARLIE\n');
|
|
});
|
|
|
|
test('edits to different files are not serialized against each other', async () => {
|
|
const api = new MockAPI();
|
|
api.files.set('other.md', '');
|
|
|
|
const start = Date.now();
|
|
await Promise.all([
|
|
new SemanticRouter(api).route({ operation: 'edit', action: 'append', params: { path: '_test.md', content: 'A' } }),
|
|
new SemanticRouter(api).route({ operation: 'edit', action: 'append', params: { path: 'other.md', content: 'B' } }),
|
|
]);
|
|
// Two independent ~5ms ops in parallel should not take ~10ms+ serially.
|
|
expect(Date.now() - start).toBeLessThan(40);
|
|
});
|
|
});
|