FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

copyPadWithoutHistory writes an invalid changeset: copies grow a newline and fail pad.check() forever · Issue #8139 · ether/etherpad · GitHub

/ etherpad Public

copyPadWithoutHistory writes an invalid changeset: copies grow a newline and fail pad.check() forever #8139

Description

Found while investigating #8134. Filing separately because it is independent of that report and, I think, more damaging.

Summary

Pad.copyPadWithoutHistory() builds the destination pad's revision 1 with:

const oldLength = 2;
const newLength = assem.getLengthChange();   // a DELTA
const newText = oldAText.text;
const changeset = pack(oldLength, newLength, assem.toString(), newText);

pack(oldLen, newLen, ...) wants the total length of the new document, not the delta. So the changeset's header disagrees with its own ops.

Impact

1. Every copy gains a newline. opsFromAText() deliberately skips the source document's final newline, so both of the destination pad's rev-0 newlines ("\n\n") survive alongside the inserted text. Measured on a 242-char source pad, the copy is 243 chars. It grows again on each subsequent copy.

2. The copy fails pad.check() permanently — this is the real problem. Revision 1 fails checkRep():

Error: (pad <id> revision 1) Failed assertion: Invalid changeset: claimed length does not match actual length
  at checkRep (static/js/Changeset.ts:285:3)
  at Pad.check (node/db/Pad.ts:994:9)

Measured header on that same pad: oldLen=2, newLen=241, while applying the ops actually yields 243.

deleteRevisions() — i.e. cleanup.keepRevisions from the admin UI, and compactPad with a keep count — calls pad.check() before it touches anything. And compactPad's full-collapse mode (deleteAllRevisions) routes through copyPadWithoutHistory twice.

So: the feature that exists to reclaim database space leaves pads that can never be cleaned up again. A healthy pad that passes check() fails check() after compactPad.

This also affects the public copyPadWithoutHistory API endpoint and anything built on it.

Reproduction

const pad = await padManager.getPad(padId);
for (let i = 0; i < 6; i++) await pad.appendText(`line ${i}\n`);
await pad.check();                       // passes

await api.compactPad(padId);             // {ok: true, mode: 'all'}

padManager.unloadPad(padId);
await (await padManager.getPad(padId)).check();   // throws

Why it went unnoticed

  • dstPad.appendRevision(changeset, authorId) is not awaited, so failures surface as unhandled rejections rather than failing the copy.
  • Two existing tests encode the off-by-one rather than catching it. tests/backend/specs/api/pad.ts strips '<br></body>' when checking a pad's own HTML but '<br><br></body>' when checking a copy of it. tests/backend/specs/compactPad.ts says it "doesn't assert byte-exact equality because Cleanup.deleteAllRevisions goes through copyPadWithoutHistory twice and may adjust trailing whitespace", and never calls check() afterwards.

Fix

PR incoming: pass the total length, delete the surplus newline, trim the char bank to the characters actually inserted, await the appendRevision, and tighten both tests above.

Affected

develop (verified at 671b15c). The pack() call is long-standing, so released versions with compactPad / cleanup are affected.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions


    Back | FazBrowse Home | New Git URL