fix(git-sync): push 503 starvation + concurrent-edit marker leak/silent loss

Bug #1 (push 503 starvation): an external receive-pack that briefly overlapped
a poll cycle immediately 503'd because the per-space single-writer lock was
held. Add a BOUNDED retry-acquire on the PUSH path only (SpaceLockService
.withSpaceLock acquireRetry: capped exponential backoff up to ~5s); a transient
overlap now waits and succeeds, a genuinely stuck cycle still 503s after the
bound. The poll cycle passes no retry (immediate skip). Push result stays
deterministic: the receive-pack only runs once the lock is held, so a 503 never
leaves a half-applied ref.

Bug #2 (concurrent-edit marker leak + silent same-block loss):
- Marker leak (a): the push UPDATE path stripped markers for the body sent to
  Docmost but left raw <<<<<<</>>>>>>> committed on the published `main` vault
  forever (autoMergeConflicts ON). Now the cleaned body is written back to the
  vault file + recorded in writtenBack so runPush commits it on `main` and the
  vault converges to clean bytes.
- Marker leak (b): pin merge.conflictStyle=merge in ensureRepo and teach
  stripConflictMarkers/hasConflictMarkers about the diff3 `|||||||` base section
  (drop the marker AND the stale base region) so diff3/zdiff3 conflicts can
  never leak `|||||||` + base content into a page. Also scrub the 3-way merge
  BASE markdown.
- Silent same-block loss: the block 3-way merge still resolves same-block
  conflicts deterministically to git, but it is no longer silent: diff3Plan now
  reports a conflict count (mergeXmlFragments3WayWithStats), gitSyncWriteBody
  logs it, and the persistence boundary-snapshot now fires for git-sync writes
  over a non-git-sync baseline so the human's pre-merge content is preserved in
  page history (recoverable). Full both-preserved persisted-conflict UI remains
  the deferred redesign.

Tests: space-lock bounded-retry (success/stuck/poll-immediate); push vault-clean
+ diff3 |||||||  strip; ensureRepo conflictStyle pin; diff3Plan/3-way conflict
counts; persistence git-sync boundary snapshot. Server tsc clean; git-sync
vitest + server collaboration/git-sync jest all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-28 20:03:21 +03:00
parent 906733b5c8
commit b7e5cb6970
15 changed files with 567 additions and 77 deletions

View File

@@ -162,6 +162,10 @@ describe('VaultGit (integration; temp repo)', () => {
expect(await localConfig('commit.gpgsign')).toBe('false');
expect(await localConfig('core.safecrlf')).toBe('false');
expect(await localConfig('core.attributesFile')).toBe('/dev/null');
// merge.conflictStyle=merge keeps conflict markers to the canonical three
// (no diff3 `|||||||` base section) regardless of the operator's global
// config (bug #2 marker-leak determinism, SPEC §9).
expect(await localConfig('merge.conflictStyle')).toBe('merge');
// Idempotent: a second run leaves the same single values (no duplicates).
await git.ensureRepo();

View File

@@ -145,6 +145,79 @@ describe('#13 conflict markers reach Docmost', () => {
expect(pushedBody).toContain('their line');
});
it('autoMergeConflicts on: rewrites the vault file with the CLEAN body so raw markers do not stay in the published vault (bug #2 marker-leak)', async () => {
// Previously the UPDATE path stripped markers for the body SENT to Docmost but
// left the file on `main` carrying raw `<<<<<<<`/`>>>>>>>` forever — the
// published vault external clients clone kept the markers and the page
// re-conflicted every cycle. The fix writes the cleaned body back + records it
// in writtenBack so runPush commits it on `main`.
const { deps, importPageMarkdown } = makeConflictDeps({
...makeSettings(),
autoMergeConflicts: true,
});
const res = await runPush(deps, { dryRun: false });
expect(res.mode).toBe('apply');
// The clean body was imported into Docmost (no markers).
const pushedBody: string = importPageMarkdown.mock.calls[0][1] as any;
expect(pushedBody).not.toMatch(/[<>=]{7}/);
// The vault file was rewritten with the cleaned content (no raw markers).
const writeCalls = (deps.writeFile as any).mock.calls as [string, string][];
const docWrite = writeCalls.find(([p]) => p === 'Doc.md');
expect(docWrite).toBeDefined();
expect(docWrite![1]).not.toMatch(/[<>=]{7}/);
expect(docWrite![1]).toContain('my line');
expect(docWrite![1]).toContain('their line');
// It is recorded for the follow-up commit so `main` converges to clean bytes.
expect(res.applied?.writtenBack).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'Doc.md', pageId: 'p-1' }),
]),
);
});
it('autoMergeConflicts on: strips diff3-style ||||||| base markers + base content (defense-in-depth)', async () => {
// A vault created before `merge.conflictStyle=merge` was pinned (or content a
// human committed in diff3 style) can carry a `||||||| base` section. The
// scrub must drop the `|||||||` marker AND the stale base region, keeping only
// the two live sides — otherwise `|||||||` + obsolete base lines leak into the
// Docmost page.
const diff3Body =
'<<<<<<< HEAD\nmy line\n||||||| base\nold base line\n=======\ntheir line\n>>>>>>> feature\n';
const file = serializePageFile('p-1', diff3Body);
const { git } = makePushGit({ changes: [{ status: 'M', path: 'Doc.md' }] });
const importPageMarkdown = vi.fn(async () => ({ success: true }));
const client = {
listSpaceTree: vi.fn(async () => ({ pages: [], complete: true })),
importPageMarkdown,
createPage: vi.fn(),
deletePage: vi.fn(),
movePage: vi.fn(),
renamePage: vi.fn(),
};
const deps: PushDeps = {
settings: { ...makeSettings(), autoMergeConflicts: true },
git,
makeClient: () => client as any,
readFile: vi.fn(async (p: string) => {
if (p === 'Doc.md') return file;
throw new Error(`no such file: ${p}`);
}),
writeFile: vi.fn(async () => {}),
log: () => {},
};
await runPush(deps, { dryRun: false });
const pushedBody: string = importPageMarkdown.mock.calls[0][1] as any;
expect(pushedBody).not.toContain('|||||||');
expect(pushedBody).not.toContain('old base line'); // stale base dropped
expect(pushedBody).toContain('my line');
expect(pushedBody).toContain('their line');
});
it('CREATE branch (autoMergeConflicts off): does NOT create a page from a conflicted NEW file; records a create failure', async () => {
// The conflict-markers guard is DUPLICATED on the CREATE path (a brand-new
// .md with NO gitmost_id, status 'A') and was previously untested — only the