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:
@@ -123,6 +123,65 @@ describe('SpaceLockService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Bug #1 (push 503 starvation): the PUSH path passes a bounded acquireRetry so a
|
||||
// transient overlap with a poll cycle is retried (and succeeds) instead of an
|
||||
// immediate 503. A genuinely stuck lock still skips after the bound. The poll
|
||||
// cycle passes NO retry (immediate skip), so only the push path waits.
|
||||
describe('bounded acquire-retry (push path)', () => {
|
||||
const retry = { timeoutMs: 5_000, baseMs: 100, maxMs: 500 };
|
||||
|
||||
it('retries the acquire and SUCCEEDS when the lock is briefly held then released', async () => {
|
||||
const { service, redis } = build();
|
||||
// First acquire attempt fails (lock briefly held by a cycle), the next
|
||||
// succeeds — the bounded retry must turn this into a SUCCESS, not a skip.
|
||||
redis.set
|
||||
.mockResolvedValueOnce(null) // attempt 1: held
|
||||
.mockResolvedValueOnce(null) // attempt 2: still held
|
||||
.mockResolvedValue('OK'); // attempt 3+: released -> acquired
|
||||
const fn = jest.fn(async () => 'pushed');
|
||||
|
||||
const result = await service.withSpaceLock('space-1', fn, {
|
||||
acquireRetry: retry,
|
||||
});
|
||||
|
||||
expect(result).toBe('pushed');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(redis.set.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
// The acquired lock is released in finally (DEL-CAS eval).
|
||||
expect(redis.eval).toHaveBeenCalledTimes(1);
|
||||
expect(redis.eval.mock.calls[0][0]).toContain('del');
|
||||
});
|
||||
|
||||
it('still skips (lock-held) after the bound when the lock stays stuck — and never runs fn', async () => {
|
||||
const { service, redis } = build();
|
||||
redis.set.mockResolvedValue(null); // permanently held
|
||||
const fn = jest.fn(async () => 'pushed');
|
||||
|
||||
const result = await service.withSpaceLock('space-1', fn, {
|
||||
acquireRetry: { timeoutMs: 300, baseMs: 50, maxMs: 100 },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ skipped: 'lock-held' });
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
// It retried more than once before giving up (bound > one interval).
|
||||
expect(redis.set.mock.calls.length).toBeGreaterThan(1);
|
||||
// Never acquired -> never released.
|
||||
expect(redis.eval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('without acquireRetry (poll path) a held lock skips IMMEDIATELY (single attempt)', async () => {
|
||||
const { service, redis } = build();
|
||||
redis.set.mockResolvedValue(null);
|
||||
const fn = jest.fn(async () => 'cycle');
|
||||
|
||||
const result = await service.withSpaceLock('space-1', fn);
|
||||
|
||||
expect(result).toEqual({ skipped: 'lock-held' });
|
||||
expect(redis.set).toHaveBeenCalledTimes(1); // no retry
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fn throwing', () => {
|
||||
it('propagates the throw AND still releases (eval) in finally', async () => {
|
||||
const { service, redis } = build();
|
||||
|
||||
Reference in New Issue
Block a user