Skip to content

Keep a pad until its file is deleted for good, and get a lost pad back from its file - #302

Merged
Jaggob merged 86 commits into
mainfrom
feat/keep-pad-until-gone
Sep 29, 2026
Merged

Jaggob merged 86 commits into
mainfrom
feat/keep-pad-until-gone

Conversation

@Jaggob

@Jaggob Jaggob commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Two changes to what happens to a .pad file's pad. They share one lifecycle, so they come as one PR:

  • A pad lives as long as its file. Moving the file to the trash leaves the pad as it is, and a restore gives the file back the same pad, with its history and whatever was written into it meanwhile. The pad is deleted once the file is deleted for good: from the trash, past it, or with the account that owned it.
  • A .pad file's saved content is always enough to get a pad back. Whether Etherpad lost the pad, the trash of an earlier version deleted it, or the file was copied in from elsewhere, a new pad can be made from what the file holds.

Until now the trash deleted the pad, after writing its content into the file, and a restore made a new pad from that snapshot. The restored pad was a new one, without its history, and a public pad came back under a new address. Every step could fail halfway: Etherpad down, the file locked, or a restore racing the sweep. So rows waited in pending_delete or restore_pending, three retry jobs worked them off, and opening a file had to settle a waiting row first. That machinery is gone. The diff adds about 7,500 lines and removes 8,000.

What a delete does to a pad

How the file goes The pad
Moved to the trash: Files, a client, a WebDAV DELETE, a folder with .pad files in it, a user's own folders or a team folder Stays. A protected pad loses its sessions. A public pad stays reachable by its link.
Restored from the trash The same pad. If Etherpad lost it meanwhile, a new pad is made from the file's snapshot at once.
Deleted from the trash: emptied, the item deleted, expired, occ trashbin:cleanup, a team folder's trash too Deleted by a background job within minutes, for a folder's files too. On Nextcloud 34 up to 34.0.4 not for a folder's files, nor for a trash emptied as a whole (see "Left as it is").
Deleted past the trash: X-NC-Skip-Trashbin: true, or the trash app off for the user Deleted by a background job within minutes.
The account deleted The pads of the account's own files, in its home and its trash, are deleted. Files in team folders stay, and so do their pads. The account's own Etherpad sessions go as the delete starts.
Gone without a delete Nextcloud reports: removed outside Nextcloud and dropped by a scan, a team folder deleted as a whole, a storage removed Stays. The consistency check counts and lists it by pad id (vanished_file_count).

This fixes four cases where main leaves pads behind for good:

  • the .pad files inside a folder that went through the trash;
  • a renamed .pad file;
  • a file deleted past the trash;
  • the files of a deleted account.

The new docs/deleting-pads.md covers every way of deleting a file, for admins and users.

Getting a pad back from the file

The new pad is made from the content saved in the file. It is always a new pad: the pad id the file names is never taken over, even if Etherpad has a pad of that name.

A file restored from the trash gets its new pad without asking. A file back from the trash is surely the one its pad was, so there is nothing to decide:

  • a file trashed under 1.1.0-beta.1 or earlier, whose trash deleted the pad along with its row;
  • a file whose pad Etherpad lost while the file was in the trash.

Any other file asks when it is opened. Viewer and embed page show a card that offers to make a new pad from the file's content:

  • A file copied in or found without a pad of its own: the card also offers to open the original, when another file the user can read has the pad. This existed before.
  • A file whose pad Etherpad has lost: the same card, without the search for an original. This is new. Until now the open handed out the lost pad as if nothing happened. A public pad opened at its old address, where Etherpad made it anew, with its default text, on the visit. A protected pad ended in an error with no way out.

A copy stays a copy. A never-opened copy of another .pad file restored from the trash is not given a pad of its own: its pad lives on with the original, and its open offers the original or a new pad, as for any copy.

The file Its pad What happens
Restored from the trash, trashed by 1.1.0-beta.1 or earlier Deleted with the trash New pad at once, whatever the setting says
Restored from the trash Lost in Etherpad while the file was away New pad at once (new)
A copy restored from the trash The original's Left as it is; its open offers the original (new)
Copied in, restored from a backup, found by a scan None of its own On open, the card: new pad, or the original (as before)
Any file Lost in Etherpad On open, the card: new pad (new)

All of these take one path: a new pad from the file's snapshot, the row made or moved onto it, the file naming it (RestoreService::restoreOntoNewPad()). No other way of making a pad is added.

How

Seeing a file deleted for good. GoneFilesListener listens to the file cache. Nextcloud reports every entry it removes (CacheEntryRemovedEvent), including each descendant of a removed folder. A scan also removes entries, for files that vanished outside Nextcloud, so only these removals count:

  • A removal from a trash:
    • a user's trash (files_trashbin/files/ on a home storage);
    • a team folder's trash on the root storage (__groupfolders/trash/);
    • a team folder's trash on the folder's own storage (trash/, told apart from other folders of that name by the storage id).
    • A folder of one of those names on any other storage, an external one say, is a folder like another.
  • A removal of a node being deleted, or of anything under it, between the node's BeforeNodeDeletedEvent and its NodeDeletedEvent: a delete past the trash, or one the trash was meant for but did not take (an app vetoing it, the move failing). The node's storage and path are looked up in the file cache when the delete starts. A delete that fails (a locked file) raises no NodeDeletedEvent; its window stays open only on its own entries, so a scan later in the same cron run never counts as a delete.
    • A delete that goes to a trash is a move. Where it crosses to a storage whose cache is wrapped (an external storage with an encoding option, say), Nextcloud copies the file into the trash under new ids first and then removes the old ones: an insert into a trash while a node is being deleted closes the window. Not MoveToTrashEvent, which the trash app sends before it tries.
    • Likewise a restore from a user's trash, between BeforeNodeRestoredEvent and NodeRestoredEvent: what it removes from the trash does not count. The window closes by the source's path, which has no id once restored.
  • The home storage of an account being deleted. Nextcloud clears it without a single event, so its files are looked up at BeforeUserDeletedEvent from the account's home mount, as Nextcloud's own cleanup finds it, and marked at UserDeletedEvent.

Some details of the listener:

  • A counted removal sets the row to pending_delete, dated by deleted_at.
  • What counts is the file id, never the name, so a renamed .pad file takes its pad along.
  • Versions on a home storage and app data on the root storage never count; a file of such a name elsewhere counts like any other.
  • A move to another storage keeps the file id. Nextcloud reports it as a removal plus an insert (CacheEntryInsertedEvent), and the insert takes the mark back.
  • Marks are collected and written in blocks of 500 (UPDATE … WHERE file_id IN), when a delete through a node is done (NodeDeletedEvent, or \OCP\Files::postDelete, which a trash's deletes send too), and at the end of the process, and only for files the file cache no longer has. The rows are looked up first, so a removal of a file without one costs no look into the file cache, and the listener keeps only the files it marked: a cleanup over millions of entries holds a handful. The request never calls Etherpad, and the listener never throws.
  • A file removed again after its insert took the mark back is marked again.
  • What a scan drops never counts, in a trash or under a delete: occ files:scan sends NodeRemovedFromCache right before an entry goes, and that entry and all under it are left out, until the entry's own removal, which Nextcloud reports after all under it. A removal there later in the same process counts again.
  • On Nextcloud 34 the block of removals reaches the listener ahead of other apps' listeners (priority 100), so one of them throwing cannot keep the misnumbering from it (below).

The trashbin's OC_Hooks were not an option. They are old hooks with relative paths, and groupfolders, occ trashbin:cleanup and account deletion do not send them. Its typed restore events are used.

Deleting the pad. GoneFileSweep runs on every tick of a five-minute cron (GoneFileSweepJob, declared in info.xml, with an interval of four minutes: Nextcloud runs a job only once more than its interval has passed, and five would run it on every other tick), within 20 s, in batches of 200 and at most ten per run.

  • It takes the rows marked at least five minutes ago whose file the file cache has nothing of.
  • It asks the file cache once more right before deleting, then deletes the pad and the row.
  • If Etherpad refuses a delete, the row is tried again an hour later, with a warning only the first time. If Etherpad does not answer, the run ends. An HTTP error or an answer Etherpad could not have meant may be one pad's alone: Etherpad is asked checkToken first, and if it answers, the pad waits its hour and the run goes on.
  • If a marked file is still in the file cache an hour later, the mark is taken back. That covers a rolled-back delete, for example.
  • The admin page's "Check pending pads" (settle-pending) runs the sweep at once, and says so when deleting is off, including rows still in their five minutes and rows Etherpad refused within the hour, after the admin fixed Etherpad, say. A run tries each row once.
  • With delete_pad_with_file off, no pad is deleted and the rows keep waiting.

Sessions. RevokeSessionsOnDeleteListener runs for a move to the trash and for a delete past it alike.

  • At BeforeNodeDeletedEvent it collects the protected pads the delete takes along. For a file that is its own row. For a folder it is every active protected row below it, found by walking the file cache's parent index one level at a time (ProtectedPadsOfNode), whatever the files are called. An instance without an active protected pad takes no walk; a new index on state and access mode answers that. The walk stops past 100 pads or at 10,000 folders, with a line, and its queries read no more than it has room for.
  • At NodeDeletedEvent, once the delete is done, PadSessionRevoker::revokeForPads() reuses the logout's loop: at most two seconds, and up to 100 deletes, newest first, since every open makes a session and the newest are those of whoever is at it now. Whatever does not fit expires on its own. A delete that fails, a file locked by a sync say, takes no sessions.
  • Group by group: a group's sessions are listed first, and go before the next group is asked. Most groups hold no live session, and then nothing else is asked.
  • A group loses its sessions only if it holds nothing but pads leaving here, the rule a pad's deletion goes by (ManagedPadLifecycle::groupHoldsOnly()). A legacy Ownpad file can name someone else's group; a legacy group whose pads all leave together loses its sessions.
  • The delete never waits for this and never fails because of it.
  • Deleting an account takes the account's own sessions, as a logout does (RevokeSessionsOnAccountDeleteListener). An account deleted without a logout kept them, and with them the protected pads of other people's files shared with it, until they expired. They go as the delete starts: Nextcloud removes the account's settings, the cached Etherpad author among them, before it reports the account gone. A delete the user backend then refuses has lost them as a logout would.

Recognising a lost pad. An open that may write asks Etherpad before any address or session (ManagedPadLifecycle::howLost()). Each question waits at most three seconds. Only a definite answer stops the open: Etherpad slow, silent or refusing the question opens the pad as before, with a line at debug. Any other fault of the check opens it too, with a warning. A pad counts as lost when:

  • Etherpad has no pad under that id, and it is a protected pad, or a public pad whose file holds saved content;
  • or Etherpad has one without a single revision, while the file holds saved content, and its text is not the text the file saved: a public pad Etherpad made anew on a visit, with its default text and the visitor as its author.

A file holds saved content once its snapshot is past a pad's first revision (snapshot_rev above 0), or once it holds any text: a file made from a template by 1.1.0-beta.1 holds its content at snapshot_rev: 0.

It does not count as lost when:

  • a public pad has nothing saved in its file, such as a new file or an Ownpad link to a pad nobody opened yet. Etherpad makes that on the first visit, as before, and is not even asked;
  • a pad is merely behind its snapshot. Restores in 1.1.0-beta.1 left files like that;
  • a pad at revision 0 holds the text the file saved: its history was cut short in Etherpad.

A lost pad answers 400 with the code pad_missing, which the card acts on. A reader is not asked, and is shown what the pad server has. A public share that may write is told in one sentence that the file's owner can make the new pad. The refusal is logged once a minute per file.

Restore.

  • An active row keeps its pad; one whose pad Etherpad lost gets its new pad at once, as above.
  • A file without a row gets a new pad from its snapshot, unless it is a never-opened copy of another file's .pad whose file is still there. A copy whose original is gone - deleted for good, or vanished - gets a pad of its own. One that cannot be read, such as a legacy Ownpad link without metadata, is left to its open.
  • A pending_delete row whose file is back means the delete did not happen. The row becomes active again. An open does the same.
  • A core restore arrives twice, by the hook and then by the event. The event pass leaves what the hook pass decided. A folder restored does not ask Etherpad for each of its files: those whose pad is lost ask when opened.

Safety

  • A sync looks at the row before it writes. Right before each write of a pad's snapshot, the ones after a wait for the file's lock too, the row is asked again: a recovery may have moved it onto a new pad and written the file meanwhile. What is left is the moment between that look and the write (see "Left as it is").
  • A sync leaves a pad made anew to its file. The forced sync a viewer or embed page runs as it closes compares content even when the pad is behind its snapshot. It wrote a pad Etherpad had made anew, with its default text, over the saved content: that content was then left to the file's versions, and the open no longer found the pad lost. It now answers pad_missing for a pad without a single revision whose text is not the text the file saved, by the rule the open goes by (ManagedPadLifecycle::isMadeAnew()), and writes nothing. A pad behind its snapshot that holds a revision still syncs, which is what brings files a 1.1.0-beta.1 restore left with the old pad's revision count up to date.
  • A recovery does not write over a newer file. Seeding the new pad takes a while, so the file is read again right before the row is claimed. If it moved, or no longer holds what the new pad was seeded from - written meanwhile by a sync or an upload - the new pad goes and nothing is claimed or written (file_moved, file_changed); the next open asks again.
  • Readers are refused. recover-from-snapshot answers 403 to anyone who may not change the file, such as the recipient of a read-only share, before Etherpad is asked. A new pad means writing the file and moving its row.
  • Cleaning up takes no pad that is back. After a replacement, the lost pad's group goes only while it holds nothing: the pad may have been made anew through the API meanwhile, and a group that cannot be read stays.
  • A failed attempt keeps the row. If making the new pad fails - Etherpad gone while it is seeded, the file locked for the write - the row stays on the lost pad, and is moved back onto it if it was already claimed. The next open asks again. A write that landed but threw afterwards - a hook failing - keeps the new pad, since file and row both name it. A write that threw on a file that then cannot be read leaves open which pad the file names: the row goes, and the next open offers a new pad from the file's content. The new pad stays, named in the log: a write that broke off may have cut the file short, and the new pad is then the last whole copy. A row of an unknown access mode is left as it is.
  • A file counts as holding content from the start. Seeding reports the new pad's revision count (ManagedPadLifecycle::seed()), and restore, recovery and templates all write that count as snapshot_rev. Before, a pad made from a template said snapshot_rev: 0. Lost before its first sync, it was handed out; Etherpad made it anew, and the next sync wrote that pad over the template's content. Files 1.1.0-beta.1 made from a template still say 0, and count by their saved text. If Etherpad does not report the count, a template's file starts at 0, and counts by its text too.
  • A gap from 1.1.0-beta.1 is closed. POST /api/v1/pads/trash ran for anyone who could see the file. Called by the recipient of a read-only share, it deleted the owner's pad. This PR removes the endpoint.

Upgrading from 1.1.0-beta.1

Version000005Date20260928120000:

  • In beta.1, a trash that could not reach Etherpad left its row pending_delete.
    • If the file is still there, in a trash or back in Files, the row becomes active.
    • If the file is gone for good, the row stays for the sweep.
    • Rows left waiting in a way this version does not know go the same way: restore_pending, which a main after beta.1 wrote for a restore that could not finish, and a pending_delete row without a date. Otherwise an open would refuse them and nothing would list them.
  • delete_on_trash becomes delete_pad_with_file, keeping the admin's value. The value is read and written only through IAppConfig, as a string. Until it is taken over, the old key's value stands, in the sweep and in the admin form, so this code running before its migration keeps an admin's opt-out, and saving the form does not undo it.
  • The three retry jobs of beta.1 (Hot, Warm, ColdPendingDeleteRetryJob) are taken off the job list. Otherwise the first cron run would drop each with a warning.
  • The test_fault key a debug instance of beta.1 may have set is removed, with the faults.
  • The index on state (ep_bind_state_idx) is replaced by one on state and access_mode (ep_bind_state_mode_idx), which also serves every lookup by state alone.

API and admin changes

  • Removed: POST /api/v1/pads/trash and POST /api/v1/pads/restore. They ran the old trash and restore for a path; only the shell scripts used them, and those are deleted too.
  • Removed: the debug-only POST /api/v1/admin/test-fault and every test fault it set (trash_* and restore_*), with the test_fault setting. Only the removed shell scripts set them.
  • New error code pad_missing (PadLostException), 400, on open, and on a sync that would write a pad Etherpad made anew over its file.
  • POST /api/v1/pads/recover-from-snapshot/{fileId}:
    • now also serves a file whose row names a pad Etherpad has lost;
    • answers 403 to whoever may not change the file;
    • still refuses a row whose pad is there, with 409 as before;
    • answers 409 with status=skipped and reason=file_moved or file_changed when the file moved or was written while the new pad was seeded.
  • Renamed: the setting delete_on_trash is now delete_pad_with_file, in the admin API and the form. The admin page says it applies once a file is deleted permanently.
  • Changed: settle-pending now only deletes the pads of files deleted for good. It returns checked, settled and pending_delete_count.
  • Changed: the consistency check counts only vanished files (vanished_file_count, with samples.vanished_files), reports issues on them, and the admin page lists them by pad id. binding_without_file_count and samples.bindings_without_file are gone: they also counted files seen deleted for good, which are on their way, and every such file made the check report issues, for good while deleting is off.
  • Gone again (both unreleased): restore_pending_count in the health check, and the error code waiting_binding.
  • Docs only: docs/api-reference.md no longer lists file_without_binding_count and invalid_frontmatter_count. The check never returned them.

Left as it is

  • Nextcloud 34: a folder's descendants are reported under the wrong ids, and reported again with every block. The fix is fix(filecache): announce every removed entry so metadata is cleaned up nextcloud/server#63998, in 35.0.1; its backport to 34, [stable34] fix(filecache): announce every removed entry so metadata is cleaned up nextcloud/server#64497, is planned for 34.0.5 but not merged yet.
    • A block holding id 0 is such a block, and none of its removals count. Should the block not reach the listener, the removals under wrong ids still mark no file the file cache has.
    • Emptying a user's trash as a whole, from the Files app or with occ trashbin:cleanup, deletes files_trashbin as one folder, so it is affected too.
    • As a result, the .pad files inside a folder deleted for good there, or in a trash emptied as a whole, keep their pads, and the consistency check lists them.
    • A single file deleted from the trash, by hand or by expiry, a file deleted past the trash, and deleted accounts are not affected.
  • Only deletions Nextcloud reports delete a pad. A file dropped by a scan, a team folder deleted as a whole, or a storage removed leaves its pad, and the consistency check lists it. Only occ files:scan says what it drops; the background scan and the watcher do not, and what they drop in a trash still counts as a deletion from it. Files in the trash of a team folder kept on Nextcloud's own storage go with it as from a trash, and take their pads.
  • A team folder's trash deletes at the storage. Nothing marks the end of such a delete, so its marks wait for a full block or the end of the process; a process killed before loses them, and those pads are listed as vanished.
  • A share recipient's restore brings back a copy. Nextcloud puts the file itself into its owner's trash and a copy into the recipient's. The copy restored holds the content of the last sync and offers a new pad; the pad stays with the owner's file until that goes. The pad follows the original: handed to the copy, it would leave the owner's restored original a copy.
  • A sync and a recovery can still meet in one moment. A sync of a pad Etherpad made anew that someone wrote into after the recovery asked about it, writing between its last look at the row and its write just as the recovery moves the row, leaves file and row naming two pads, which open and recovery both refuse. A lock around the look and the write is not possible: Nextcloud's write takes a shared lock and turns it exclusive, which fails under a lock the sync holds itself.
  • Vanished files stay on the consistency check's list. Deleting such a pad in Etherpad leaves its row, and the list does not get shorter. An admin action that deletes the pads of vanished files follows in its own PR.
  • A public pad stays reachable by its link while its file is in the trash. A file in the trash is not synced, so what is written there is in the pad and comes back with a restore.
  • A pad made anew that someone wrote into syncs. A viewer already open when Etherpad loses the pad syncs on leaving as before once someone has written into the pad made anew, and writes what Etherpad then has into the file: a pad holding a revision cannot be told from one merely behind its snapshot.
  • The file metadata endpoints (meta-by-id, resolve) still hand out the pad address from the file, without asking Etherpad. That is left for a later change.
  • The lost pad, or the one Etherpad made anew, is left alone. A pad an admin re-created through the API with other text than the file saved counts as made anew, and stays in place too.
  • A pad whose history was cut short to revision 0 after edits that never reached the file cannot be told from one made anew. An open offers a new pad from the file, a restore makes one at once, and the pad with the newer text stays in Etherpad, its id in the log.
  • Session revocation is best effort within the logout's budget. An account deletion that the user backend refuses leaves the files unmarked.

Tests

  • Unit (the in-memory binding table now rejects any column the real schema does not have):
    • new: GoneFilesListenerTest (with a move to the trash, a restore, a file gone again after coming back, a scan's drop ending with its own entry, only marked files kept), ProtectedPadsOfNodeTest, RevokeSessionsOnDeleteListenerTest (sessions taken once the delete is done, none for a failed one), RevokeSessionsOnAccountDeleteListenerTest, KeepPadsThroughTheTrashMigrationTest (with restore_pending and undated rows);
    • AdminSettingsRepositoryTest: the form shows the old opt-out until the setting is taken over;
    • rewritten: GoneFileSweepTest, RestoreServiceTest, BindingServiceTest;
    • PadSessionRevokerTest: a slow Etherpad still revokes the first group's sessions;
    • a restore test an unclosed docblock had swallowed runs again;
    • lost pads: the open and the public-share open ask once, only when they may write, and open on no answer; a pad made anew is told by its text; recovery is refused to a reader, keeps the row when seeding or writing fails, and lets its new pad go when the file was written meanwhile; seeding reports its revision count; a forced sync leaves a pad made anew to its file, and still writes one behind its snapshot that holds a revision;
    • the tests of the removed classes are deleted with them.
  • Vitest: viewer and embed page show the card on pad_missing.
  • Playwright:
    • new pad-gone-for-good.spec.ts, 11 cases:
      • past the trash;
      • the trash keeping the pad, a restore giving back the same pad, deleting from the trash;
      • sessions of a file and of a folder;
      • a renamed file;
      • nested folders;
      • account deletion;
      • team folders: a file deleted past the trash, a folder deleted from the trash, a restored folder whose team folder is then deleted as a whole (its pad stays and the consistency check counts it as vanished), an account's files there.
    • new pad-lost.spec.ts, 9 cases:
      • a public and a protected pad deleted in Etherpad;
      • a public pad made anew by a visit in a browser, and one made anew through the API;
      • a public pad made from a template and lost before its first sync, and one whose file 1.1.0-beta.1 wrote at snapshot_rev: 0;
      • a reader of a read-only share refused;
      • a restore after Etherpad lost the pad while the file was in the trash;
      • the viewer's card end to end;
      • the pad a visit made anew, and the one of a 1.1.0-beta.1 template, also refuse a forced sync, and the file keeps its content.
    • pad-orphan-recovery.spec.ts: a copy restored from the trash still offers the original.
    • protected-pad-group-cleanup.spec.ts is removed; its scenario no longer exists.
  • E2E stack: it installs groupfolders, pinned per Nextcloud major, and sync-app.sh runs the migrations a branch adds, through this app's own upgrade: occ upgrade also upgraded every app the app store had a newer release of, and moved the pinned ones.
  • Shell scripts: the five tests/integration/e2e-lifecycle-*.sh scripts are removed, since they drove the removed endpoints, and dropped from release-check.sh. e2e-protected-cookie-contract.sh no longer cleans up through pads/trash.
  • Mutation checks, all caught:
    • 21/21 on the state model, 10/10 on session revocation, 4/4 on the setting takeover, 8/8 on the delete windows, the marking and the job cleanup;
    • 3/3 on the last round: the file read again but not compared, taken as unchanged, or reported as moved;
    • 6/6 on the round before: the scan's window never ended, ended before its own entry was judged, or ended by an entry under it; and, with the sync's table cut to its three branches, the guard off, the pad's text compared with itself, every pad at revision 0 refused;
    • 5/5 on the round before: the old waits left alone, restore_pending or undated rows skipped, the form reading the new key alone, sessions taken after the account is gone;
    • 8/8 on the round before: a scan drop counted, a lost race refused, a copy kept while its original is gone, the old setting ignored, a silent settle, a refusal in the marking second, a group checked against one pad, the home not found;
    • 10/10 on the round before: the kept pad, the check before each attempt and after a wait, the empty group, a trash, versions and app data by their storage, the bounded folder and pad queries;
    • 12/12 on the round before: a trash insert, any insert, the write at a trash delete, the restore window's path, the sweep's probe, a settle's retry, the order and ceiling of a delete's sessions, asking a group without live sessions, the walk's bound, the sync's second look, the beta.1 key;
    • 7/7 on saved text at revision 0, an unreadable file after a failed write, and revoking group by group; 6/6 on the listener's delete and restore windows; 6/6 on the merged replacement, the recovery endpoint and the consistency check;
    • 11/11 on the round before: a move to the trash or a restore counted, a later removal losing to an earlier insert, marking a file still there, reporting every id as marked, sessions taken before the delete, a walk without a protected pad, the settle waiting out a refusal, the migration leaving rows waiting, the check's faults logged quietly, the saved text not passed;
    • on lost pads, 18 across the review rounds: the reader's refusal, the kept and moved-back row, the three-second probe, opening on no answer, the text rule, the landed write, the template's revision, the sync's guard.
  • Checked both ways in the stack:
    • the visit case fails with a rule that takes a pad at revision 0 for fine (the open answers 200, not pad_missing);
    • the template case fails with the old snapshot_rev: 0;
    • the 1.1.0-beta.1 template case fails with the old rule (the open answers 200, not pad_missing);
    • without the sync's guard, the forced sync of a pad made anew answers 200, not pad_missing, and those cases fail;
    • an account with a live session on a protected pad, deleted with occ user:delete: no live session left; without the listener, the session stays.

Numbers and verification

  • PHPUnit 1375 and Vitest 352 pass.
  • Psalm is green on OCP 31.0.9 and 34.0.4. The baseline drops from 221 to 197. One inline suppression, with its reason: the migration removes job classes that no longer exist.
  • js/ is rebuilt with Node 24 and byte-identical with a fresh build.
  • Playwright against Nextcloud 34.0.4 and Etherpad 2.7.3: 55 passed, 4 skipped.
    • three folder cases skipped on 34 up to 34.0.4 (see above);
    • the external-pad case skipped without an external Etherpad.
  • Upgrade from 1.1.0-beta.1, on a fresh stack installed with beta.1 and then upgraded to this branch, which runs the app's own upgrade (migrations, repair steps, jobs):
    • Before the upgrade, beta.1 left: a pad its trash had deleted along with its row; a pending_delete row with its file in the trash (Etherpad stopped during the trash); a pending_delete row whose file had also been deleted from the trash; delete_on_trash = no; a test_fault key; its three retry jobs; the index ep_bind_state_idx.
    • After: the row whose file is in the trash is active, and restoring the file gives back the same pad with its content. The row whose file is gone stays pending_delete, and the sweep deletes its pad. delete_pad_with_file is no, stored as a string; delete_on_trash, test_fault and the three jobs are gone, GoneFileSweepJob is registered, ep_bind_state_idx has given way to ep_bind_state_mode_idx, and the upgrade logs no warning.
    • Playwright on the upgraded stack, with groupfolders added: 55 passed, 4 skipped, as on a fresh one.
    • A file trashed by beta.1 whose content had been synced gets a new pad with that content when restored. A file beta.1 trashed through WebDAV before any sync comes back empty: beta.1 deleted such a pad without writing it into the file, so there is nothing left to restore.
  • Checked by hand in the stack:
    • the migration and the setting takeover (old key removed, value kept, stored as a string);
    • saving the setting through the admin API, off and on;
    • with the session listener switched off, the session case fails;
    • a file dropped by a scan keeps its pad;
    • a trashed .pad missing on disk and dropped by occ files:scan keeps its row and pad; with the scan's exception switched off, the same run marks the row;
    • the consistency check counts vanished files only, not those waiting for the sweep, and the removed test-fault route answers 404;
    • occ upgrade registers GoneFileSweepJob from info.xml, with its row removed beforehand;
    • sync-app.sh runs a new migration of this app alone, and the pinned apps keep their versions;
    • on a fresh stack, the consistency check as it was before its last fix answers 500 and fails the team folder case;
    • the folder cases pass with the upstream fix (fix(filecache): announce every removed entry so metadata is cleaned up nextcloud/server#63998) patched into 34.0.4.

trashed_at says when the file was seen going to a trash or being
deleted; missing_since, when a sweep first missed it without that. Both
nullable and indexed. Nothing writes them yet.
A .pad file moved to a trash or deleted past it, every .pad file under a
folder that goes, and every file of a user about to be deleted get
trashed_at on their row: once such a file is gone from the file cache,
it is gone for good, however its trash was emptied. MarkLeavingPadsListener
hears MoveToTrashEvent, BeforeNodeDeletedEvent and BeforeUserDeletedEvent
and never throws. A folder's files are found in the file cache by the
folder's storage and path.

The in-memory binding table of the tests now holds a file cache too and
evaluates joins, LIKE, IN and IS NULL.
GoneFileSweep runs every five minutes. Active rows marked as leaving
Files whose file the file cache has nothing left of are gone for good:
pad and row go. A pass over every active row, 200 a run with a cursor,
keeps the dates in step with the file cache: it marks a file under a
trash path, clears the mark of one back in Files, starts a seven-day
grace period for a file missing without a mark, and ends it for one
found again. After the grace period pad and row go too, unless more
such files went missing since the brake's last release than its
threshold (20): then they are kept and a warning says so, once.

The file cache is asked once more right before a pad goes. A pad
Etherpad no longer has counts as deleted, a refusal is logged and tried
again, and Etherpad not answering ends the run with the slice taken
again next time. With delete_on_trash off nothing is deleted, but the
dates and the brake are kept.

The grace period, threshold, release time, brake state and cursor live
in the app config through IAppConfig.
… good

The consistency check counts the active rows whose file went missing
without being seen leaving Files, in their grace period or held, and
says whether the brake holds; its message then names the brake. While
it holds, the admin page offers to release it, after asking:
POST /api/v1/admin/gone-file-brake/release. Rows missing until then no
longer count, and their pads go once past their grace period.

architecture.md describes the sweep under "Files gone for good", with
the two columns and the events that mark a row; api-reference.md lists
the new fields and the endpoint, and drops the consistency counters the
check never returned. New strings in de, es, fr and it.
A file that goes missing without passing a trash, a delete or a user's
deletion is left alone, pad and row with it, as before: no grace period,
no brake, no missing_since column, and no admin action to release the
brake. The migration adds trashed_at alone.

The sweep's pass now reads only the active rows whose file the file
cache has, and keeps their marks: it sets the mark of a file under a
trash path and clears the mark of one back in Files. It asks only the
database, so it runs even when Etherpad does not answer or the run's
budget is spent.

architecture.md describes the sweep under "Files gone for good".
Nextcloud runs an app's migrations only when info.xml's version rises,
and a branch keeps it. sync-app.sh now asks which migrations the tree
has that the stack has not run, and if there are any upgrades the app
as a release would. It asks for the set, not the next one after the
last run, so a stack that ran another branch's migrations still gets
this one's.
A delete past the trash - a WebDAV DELETE with X-NC-Skip-Trashbin, the
trash app off for the user, a move to the trash that fails - takes the
file at once, so its pad goes at once too. The listener, now
LeavingPadsListener, holds the files it marked before a delete until
Nextcloud reports the delete done (NodeDeletedEvent), and the sweep
deletes the pads of those gone from the file cache, within five
seconds. A file the trash took is still in the file cache and is passed
by. What does not fit, or finds Etherpad not answering, keeps its mark
for the next run, and nothing is thrown at the delete.

Nextcloud reports a deleted folder as a file, so what counts is what
was marked before the delete. markTrashedUnder() names the files under
the folder for that.

docs/deleting-pads.md lists every way a .pad file can leave Nextcloud
and what happens to its pad; architecture.md and the README link it.
A file moved to the trash keeps its pad, and a restored file has it back;
the pad goes once the file is deleted for good. The page now says so,
with the trash and a deletion for good apart, and what a protected and a
public pad do while their file is in the trash.
The page keeps the trash and a deletion for good apart, and says what a
restore gives back today: a new pad from the file's snapshot, or the old
pad while its deletion still waits. It changes with the code that keeps
pads through the trash.
groupfolders 22 gives each team folder its own storage, with its trash
under a bare trash/ beside files/. The sweep's pass took a file there
for one back in Files and cleared its mark, so a folder deleted from
that trash for good left its pads behind. The pass now clears a mark
only for a file surely in Files - files/, or a team folder's
__groupfolders/<id>/ on the root storage - and leaves a path it cannot
place as it is.
The admin's settle now runs the sweep of files gone for good as well,
as its job would, so a spec can run what the background jobs do within
minutes.

pad-gone-for-good.spec.ts, on the container stack: a protected pad and
a folder's pads go in the request that deletes past the trash; a folder
in the trash keeps its pads until it is deleted from there; a deleted
account takes the pads of its own files. With groupfolders enabled, the
same for a team folder, and a pad an account made in a team folder
stays when the account goes. Fixtures for accounts, groups and team
folders through the OCS APIs, for deleting past the trash, for making a
pad as another account, and for asking Etherpad whether a pad or group
is still there.
Team folders are how many installations share pads, and a team folder's
trash keeps its files apart from a user's, so the suite runs against
them on every supported major: groupfolders 19.1.20, 20.1.18, 21.0.15
and 22.0.6 for Nextcloud 31 to 34, each checked against its release
checksum. The full-text-search apps go through the same installer.
A restore now clears the marks of what it brings back: NodeRestoredEvent
for core's trash, and the legacy post_restore hook, which groupfolders
alone raises, for every restored item, folders included. Before, a
restored file kept its mark until the sweep's pass reached it, and a
file that then went unseen - a team folder deleted as a whole - lost its
pad.

A delete no longer marks before it happens: it looks the files up, a
move to the trash marks those rather than looking them up again, and a
delete past the trash marks them once they are gone, then deletes their
pads. So the pass cannot clear a mark on a file a delete is about to
take, and a folder's files are joined once per trash, not twice. A user
about to be deleted has their home storage found through the mount
cache, without setting up a home just to delete it.

The sweep's pass takes slices of 1,000 rows, up to ten a run, and keeps
a mark younger than five minutes. A pad Etherpad refuses to delete is
tried again an hour later, so it neither holds the head of the queue
nor warns every run. The request that deletes past the trash reports
Etherpad not answering as info and anything else as a warning. The
admin's settle runs the sweep in what the older sweep left of one
budget, not in a second one.
A new Nextcloud major or a nightly has no groupfolders release pinned
yet. The stack now comes up without it, with a note, and the team
folder specs skip, rather than stopping before Nextcloud starts.
A pad whose file went without the app seeing it - a team folder deleted
as a whole, a file removed outside Nextcloud - stays, by design. The
consistency check now counts these apart (vanished_file_count: active
rows never seen leaving Files) and returns up to 25 of them; the admin
page lists them by pad id, so an admin can delete what is no longer
needed in Etherpad.

architecture.md and api-reference.md described checks and counters the
consistency check never had; they now say what it does.
Core raises the legacy post_restore hook and NodeRestoredEvent for one
restore; the listener now remembers what it cleared in the request, so
a folder's files are looked up and cleared once.

A move to the trash marks what the delete before it looked up and keeps
it for the delete's end: a move that fails falls back to a delete past
the trash, and its folder's pads then go in the same request too, as
docs/deleting-pads.md says. For a move that succeeds the files are
still in the file cache, and the sweep passes them by.

Binding::$trashedAt and architecture.md say that a refused deletion
moves the mark ahead, to the next try.
The pads under a folder that goes to the trash, is deleted or restored
were found by a prefix match on the path. The file cache has no index
for that on Postgres, so each folder read its whole storage: measured,
46 ms for a folder of 1,000 files on a storage of a million. A team
folder from before groupfolders gave each its own storage shares the
root storage with all others, and there that grows with every team
folder. FolderPadFiles now walks down from the folder through the
parent index, one level of folders at a time, and fetches only folders
and .pad files: 0.6 ms for the same folder, growing with the folder
alone.
An open that may write now asks Etherpad once whether it has lost the
file's pad, before any address or session. Lost is no pad under that
id - a protected pad, or a public one whose file holds saved content -
or one without a single revision while the file's snapshot holds more:
a public pad Etherpad made anew, empty, when someone visited its
address. A public pad with nothing saved, an Ownpad link to a pad
nobody opened yet, is made on the first visit as before; a pad merely
behind its snapshot is not lost, as restores in 1.1.0-beta.1 left such
files.

A lost pad answers pad_missing. The viewer and the embed page show the
recovery card without looking for an original, and
recover-from-snapshot, which asks Etherpad again, makes a new pad from
the file's content through the path a restore takes for a row whose
pad is gone: the row moves onto it and the file names it. No other way
of making a pad is added. A reader is not asked; a public share that
may write is told only the owner can make the new pad. Logged once a
minute per file, as a refusal is.

pad-lost.spec.ts covers a public and a protected pad deleted in
Etherpad, a public pad made anew empty, and the viewer's card.
A file back from the trash is surely the one its pad was, so a restore
no longer leaves the question to the next open. A file whose row stayed
active takes its pad back as it is, unless Etherpad lost it while the
file was away: then the restore makes a new pad from the file at once,
through the same replacement the recovery uses. Etherpad not answering,
or a file that cannot be read, leave the row to the next open.

A file back without a row, whose pad went with the trash as in
1.1.0-beta.1, now gets its new pad whatever delete_on_trash says:
nothing is left to keep. RestoreService no longer reads the setting.

A folder restored does not ask Etherpad for each of its files; one an
open finds lost offers a new pad, as before.
A file back from the trash without a row gets a new pad only when no
other file's row names its pad. A copy that was never opened has no row
either, but its pad lives on with the original: it now comes back as it
went, and its open still offers the original.

A core restore arrives by the hook and then by the event. What the hook
pass decided - the pad restored, made anew, or left as it is - the event
pass now leaves, rather than ask Etherpad the same question again. It
still tries again after a hook pass that could not read the file or
failed on the database.

The restore of an active row and the API's recovery share one check of
whether the pad is lost. The restore's skip names what it found:
pad_present, row_names_other_pad or copy_of_another_file. Etherpad
refusing to answer at restore is reported, not taken for an unreadable
file.
A pad's file is deleted for good when Nextcloud removes it from the file
cache: from a trash, while it deletes the file or a folder above it, or
with its account. The marks now come from those removals
(CacheEntryRemovedEvent), one for each of a folder's files, instead of
looking up .pad files by name before a trash or delete. A .pad renamed
keeps its row, and its pad goes with it. A removal a scan makes - a file
deleted outside Nextcloud - does not count: its pad stays, and the
consistency check lists it.

The request that deletes only marks, in blocks of 500; the job deletes
the pads within minutes, for a delete past the trash too. A move to
another storage, reported as a removal and an insert of the same id,
takes its mark back. The sweep's pass over every row and its cursor,
the folder walk, and the marks set on trash and cleared on restore are
gone. The column is gone_after: the time from which the pad may go,
moved ahead after a refusal.

Nextcloud 34 up to 34.0.4 reports a removed folder's files under wrong
ids (nextcloud/server#63969). Such removals are recognised and left
out, so no other file's row is marked; the pads of the files inside a
folder deleted for good there stay until the fix.

pad-gone-for-good.spec.ts covers a renamed file deleted past the trash
and from it, and skips the folder cases on the affected versions.
An account's files are looked up before it is deleted and marked only
once Nextcloud reports it gone (UserDeletedEvent). A deletion the user
backend refuses stops before that, and no longer leaves marks on files
that stay.

A mark is due five minutes after it was set, a margin before a step
that cannot be undone; the admin page's settle takes it at once. Each
run first clears the marks of files the file cache still has an hour
after they were due - a deletion that did not happen after all - so a
file a scan drops later keeps its pad, as any such file does.
A trash no longer deletes the pad. The file keeps its row, active, and
its pad; a restore gives it the same pad back, with its history. The pad
goes once the file is deleted for good, through the sweep of files gone
for good. A file seen deleted for good is now a row in pending_delete,
dated by deleted_at; the grace counts from deleted_at, a retry from
updated_at, and the gone_after column goes.

What the old trash needed goes with it: the snapshot written into a
trashed file, the deletions a trash owed and their three jobs, the
restore of a row that waited, restore_pending, the settling of a waiting
row on open, and the waiting_binding error code. POST /api/v1/pads/trash
and /api/v1/pads/restore, which ran that flow for a path, are removed;
recover-from-snapshot stays. The health check and the admin page's check
report pending_delete_count alone.

A restore still makes a new pad from the file's snapshot where the file
has none: a file without a row, trashed under an earlier version, and one
whose pad Etherpad lost while it was away. A row in pending_delete whose
file is opened or restored was not deleted after all and is active again.

On upgrade the rows that waited under the old trash are taken over: a
deletion owed whose file is still in a trash or back in Files becomes
active, one whose file is gone for good stays for the sweep, and a
restore left undecided becomes active.

The lifecycle shell scripts, which drove the removed endpoints, and the
spec of the trash's group clean-up go. pad-gone-for-good.spec.ts covers a
.pad file in the trash keeping pad and group, getting the same pad back
on restore, and taking both when deleted from the trash.
The trash keeps a pad now, and a session would keep giving it to whoever
holds one, as the deleted file no longer does. A delete - to the trash
or past it (BeforeNodeDeletedEvent) - now revokes the sessions of the
protected pads it takes along: a file's own, or every protected pad
under a folder, found through the file cache's parent index and the
binding rows, whatever the files are called.

It is built on the logout's revocation. PadSessionRevoker::revokeForPads()
lists each group's sessions (listSessionsOfGroup) and deletes the live
ones within the logout's budget; what does not fit expires on its own.
A group loses its sessions only while it holds that pad alone, or
nothing: a legacy .pad may name someone else's group. A public pad has no
sessions and stays reachable by its address.

pad-gone-for-good.spec.ts covers a protected file and a folder opened and
moved to the trash: no live session is left, and the pads stay.
The trash keeps pads now, so the setting that said whether a trash
deleted them says whether a file deleted for good takes its pad along.
Its name says so: delete_on_permanent_delete, after Nextcloud's "Delete
permanently", with the admin page's label and hint to match.

On upgrade the value an admin gave delete_on_trash is taken over, unless
the new setting is set already, and the old key is removed. The setting
is read and written through IAppConfig alone, as a string, so it keeps
one type whichever way it is written.
pad-lost.spec.ts now covers the restore of a public pad's file whose pad
Etherpad deleted while the file was in the trash: the restore makes a new
pad from the file's content, and the next open finds it without the
card.
A restore left undecided never shipped; the table is there by the time the
step runs.
The consistency check still asked for gone_after, which no migration
makes: on a real database it failed. The in-memory table now knows the
columns a database has, so a statement naming another fails there too,
and the e2e case of a team folder deleted as a whole runs the check.
… cached

A delete that fails raises no NodeDeletedEvent, which left every later
removal of the process counting as a deletion, a scan's in a cron run
too. A delete now counts the removals of its own node and of what is
under it, found by the node's place in the file cache. Marks from
removals skip files the file cache still has, so a removal under a
wrong id marks no file that is there. The upgrade takes the three retry
jobs of 1.1.0-beta.1 off the job list, and the docs say where the fix
for Nextcloud 34's misnumbered removals stands.
The walk stopped past a hundred pads or ten thousand folders, but each
query still read all it found: one folder with twenty thousand others in
it read them all in the delete's request. The queries ask for one more
than there is room for, which also tells that the walk stopped short.
…an still meet a recovery

The admin page told admins to delete vanished pads in Etherpad; doing so
leaves their rows, and the list never gets shorter. The hint now says
so. A recipient's restored copy holds the content of its last sync, not
all it had. And a mismatch between file and row can come from a forced
sync writing in the moment after its last look at the row, not only
from a rollback that fails.
…its mount

A scan dropping entries of a trash - files missing on disk for a while,
say - counted as a deletion from the trash, and the sweep took their
pads. `occ files:scan` sends NodeRemovedFromCache right before it
removes an entry; that entry and all under it no longer count, in a
trash or under a delete whose window a failure left open.

A deleted user's home was looked up in the mount cache, and a home it
had no row for kept its pads. It is found from the home mount, as
Nextcloud's own cleanup of a deleted user finds it.
A row seen deleted that two requests find at once is taken back by one
of them; the other found its transition lost and refused the file with
"Pad binding is not active." although the row was active. It reads the
row again.
A row counts as never tried while updated_at equals deleted_at. A pad
Etherpad refused in the second its file was seen deleted stayed so: it
was tried again at the next run instead of an hour later, and its
warning was given twice.
A file back without a row whose pad another row names was left as a
copy, whatever became of the original. With the original deleted for
good - a share recipient restoring their copy after the owner emptied
the trash - its pad was about to go, and the copy was left none to
open. It now gets a pad of its own from its content.
Code of this version running before its migration - deployed without a
version bump - read only the new key, whose default is on, and a settle
deleted pads the admin chose to keep. Until the new key is set, the old
one's word stands.
With deleting off the check deleted nothing and answered as if all went
well, while the count of pending deletes kept growing. Its message now
says that deleting is off.
A group was checked against the first of its pads a delete took along:
two Ownpad files of one group deleted together left it holding another
pad, and its sessions stayed. It is checked against all of them.
A pad Etherpad made anew in place of the file's - at revision 0, with
other text than the file saved - is not the file's document. Written
over the file, its default text would leave the saved content to the
file's versions, and the open would no longer find the pad lost and
offer a new pad from the file. The sync now refuses it with
pad_missing, by the rule the open goes by. A pad behind the snapshot
that holds a revision still syncs, so the files a restore in
1.1.0-beta.1 left with the old pad's revision count still come up to
date.
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Keep pads through trash and recover lost pads from file snapshots

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Keep pads and their history when files enter trash; delete them after confirmed permanent
 deletion.
• Recover lost pads from saved files, with write access required and safeguards against overwriting
 snapshots.
• Replace trash retry machinery with cache-event tracking, a bounded sweep, migration, and lifecycle
 tests.
Diagram

graph TD
  NC["Nextcloud events"] --> GL["Gone-file listener"] --> DB[("Binding rows")]
  DB --> SW["Bounded sweep"] --> EP["Etherpad pads"]
  FILE["Saved pad file"] --> RS["Restore service"] --> EP
  RS --> DB
  FILE --> OPEN["Open and sync"] --> EP
  OPEN --> RS
  NC --> REV["Session revoker"] --> EP
  subgraph Legend
    direction LR
    _source["Event or service"] ~~~ _store[("Persistent data")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Periodic file-cache reconciliation
  • ➕ Does not depend on every cache removal event.
  • ➕ Could identify files lost through otherwise unreported operations.
  • ➖ Cannot reliably distinguish permanent deletion from a scan, unmounted storage, or temporary absence.
  • ➖ Requires broad, recurring scans and risks deleting pads whose files may return.
2. Delete and recreate pads on trash transitions
  • ➕ Follows the former lifecycle and avoids retaining trashed pads in Etherpad.
  • ➖ Loses history and stable public addresses.
  • ➖ Reintroduces failure-prone snapshot, retry, and restore transitions.

Recommendation: Prefer the event-qualified deletion marks and delayed sweep: they preserve pad identity through trash and avoid treating unreported disappearance as consent to delete. Retain explicit consistency reporting for cases Nextcloud cannot classify; review the documented Nextcloud 34 folder-event limitation and remaining sync/recovery race before release.

Files changed (163) +7149 / -2879

Enhancement (33) +2035 / -690
etherpad_nextcloud-admin-settings.mjsRebuild admin settings bundle +1/-1

Rebuild admin settings bundle

• Ships the compiled UI for permanent-deletion settings and revised consistency results.

js/etherpad_nextcloud-admin-settings.mjs

etherpad_nextcloud-embed-main.mjsRebuild embed bundle +1/-1

Rebuild embed bundle

• Ships the embed UI's lost-pad recovery prompt.

js/etherpad_nextcloud-embed-main.mjs

etherpad_nextcloud-viewer-init.mjsRebuild viewer bundle +1/-1

Rebuild viewer bundle

• Ships the viewer's lost-pad recovery prompt.

js/etherpad_nextcloud-viewer-init.mjs

pad-open-flow-_qELVjOV.chunk.mjsRebuild shared open-flow chunk +4/-4

Rebuild shared open-flow chunk

• Adds compiled handling for the pad_missing response shared by viewer and embed.

js/pad-open-flow-_qELVjOV.chunk.mjs

Application.phpWire deletion and revocation listeners +36/-15

Wire deletion and revocation listeners

• Registers cache, node, restore, and user events; removes per-request registration of retired retry jobs.

lib/AppInfo/Application.php

GoneFileSweepJob.phpSchedule bounded permanent-deletion sweeps +42/-0

Schedule bounded permanent-deletion sweeps

• Runs GoneFileSweep on the background-job cadence with a bounded execution budget.

lib/BackgroundJob/GoneFileSweepJob.php

AdminController.phpMake admin settle run the gone-file sweep +21/-32

Make admin settle run the gone-file sweep

• Returns revised sweep and consistency results and removes the debug fault action.

lib/Controller/AdminController.php

ApiErrorCode.phpExpose pad_missing API error +8/-9

Expose pad_missing API error

• Adds lost-pad error mapping while dropping errors belonging to retired waiting and fault flows.

lib/Controller/ApiErrorCode.php

PadControllerErrorMapper.phpMap lost pads for authenticated requests +3/-6

Map lost pads for authenticated requests

• Returns the pad_missing response for definitive loss instead of obsolete waiting-state errors.

lib/Controller/PadControllerErrorMapper.php

PublicViewerControllerErrorMapper.phpMap lost pads in public views +4/-7

Map lost pads in public views

• Gives writable public shares a pad_missing response and removes retired lifecycle mappings.

lib/Controller/PublicViewerControllerErrorMapper.php

PadLostException.phpDefine definitive lost-pad exception +18/-0

Define definitive lost-pad exception

• Provides a dedicated failure for opens or syncs that detect a lost or newly recreated pad.

lib/Exception/PadLostException.php

GoneFilesListener.phpMark files confirmed deleted for good +430/-0

Mark files confirmed deleted for good

• Correlates cache removals with trash paths, delete windows, restores, scans, moves, and account deletion. Marks only affected bindings in bounded batches without blocking file operations.

lib/Listeners/GoneFilesListener.php

RevokeSessionsOnDeleteListener.phpRevoke protected sessions after deletion +74/-0

Revoke protected sessions after deletion

• Collects affected pads before a file or folder delete and revokes sessions only once deletion succeeds.

lib/Listeners/RevokeSessionsOnDeleteListener.php

AdminConsistencyCheckResponseBuilder.phpReport only vanished files as issues +2/-2

Report only vanished files as issues

• Adapts the admin response to vanished_file_count and its samples.

lib/Service/AdminConsistencyCheckResponseBuilder.php

ApiErrorLog.phpRate-limit lost-pad logs +7/-0

Rate-limit lost-pad logs

• Avoids repeating the same public lost-pad refusal log for every request.

lib/Service/ApiErrorLog.php

BindingService.phpSupport deletion marks and safe rebinding +226/-99

Support deletion marks and safe rebinding

• Adds batched marking, file-cache confirmation, grace-period queries, stale-mark cleanup, and guarded row transitions; removes old waiting-row searches.

lib/Service/BindingService.php

ConsistencyCheckService.phpDistinguish vanished files from pending deletion +27/-13

Distinguish vanished files from pending deletion

• Counts and samples active bindings missing from the file cache, excluding rows already marked for permanent deletion.

lib/Service/ConsistencyCheckService.php

EtherpadClient.phpAdd bounded Etherpad lifecycle calls +36/-2

Add bounded Etherpad lifecycle calls

• Supports revision and health checks needed to detect lost pads and distinguish individual delete failures from outages.

lib/Service/EtherpadClient.php

GoneFileSweep.phpDelete pads after confirmed permanent deletion +161/-0

Delete pads after confirmed permanent deletion

• Processes aged pending rows within batch and time limits, rechecks the file cache, retries refusals, and honors the deletion switch.

lib/Service/GoneFileSweep.php

ManagedPadLifecycle.phpDetect lost pads and track seeded revisions +168/-55

Detect lost pads and track seeded revisions

• Checks definite absence or revision-zero text mismatch against saved content, bounds Etherpad probes, and reports revision counts after seeding.

lib/Service/ManagedPadLifecycle.php

PadFileService.phpPreserve saved-content metadata +3/-1

Preserve saved-content metadata

• Passes the file snapshot's saved text into parsed pad-file data.

lib/Service/PadFileService.php

PadOpenService.phpDetect lost pads before writable opens +15/-3

Detect lost pads before writable opens

• Replaces waiting-row settlement with mapping validation and rejects definitively lost pads before issuing a writable address or session.

lib/Service/PadOpenService.php

PadSessionRevoker.phpBound revocation for deleted protected pads +154/-31

Bound revocation for deleted protected pads

• Revokes newest sessions first, group by group, only when a group holds exclusively departing pads and within a time budget.

lib/Service/PadSessionRevoker.php

ParsedPadFile.phpExpose saved snapshot text +6/-0

Expose saved snapshot text

• Adds saved text to parsed file data for lost-pad and destructive-sync checks.

lib/Service/ParsedPadFile.php

ProtectedPadsOfNode.phpFind protected pads below deleted folders +172/-0

Find protected pads below deleted folders

• Walks the file-cache parent index in bounded levels to collect active protected bindings regardless of filename.

lib/Service/ProtectedPadsOfNode.php

PublicPadContextService.phpAdjust public-share loss responses +2/-2

Adjust public-share loss responses

• Updates public-view context handling for the simplified lifecycle.

lib/Service/PublicPadContextService.php

PublicPadOpenService.phpCheck writable public-share pads for loss +10/-0

Check writable public-share pads for loss

• Refuses definitively lost pads before opening a writable public share; leaves read-only behavior unchanged.

lib/Service/PublicPadOpenService.php

RestoreService.phpUnify file-snapshot pad restoration +323/-374

Unify file-snapshot pad restoration

• Keeps existing pads on restore and seeds a new pad when a restored file lacks its own or Etherpad lost it. Reuses the same guarded replacement path for explicit recovery.

lib/Service/RestoreService.php

admin-settings.jsShow permanent-deletion controls and diagnostics +30/-16

Show permanent-deletion controls and diagnostics

• Renames the deletion setting, updates settle results, and displays vanished files by pad ID.

src/admin-settings.js

embed-main.jsOffer lost-pad recovery in embeds +12/-3

Offer lost-pad recovery in embeds

• Treats pad_missing as a recoverable file-snapshot case without searching for another original.

src/embed-main.js

pad-open-flow.jsRecognize pad_missing responses +10/-0

Recognize pad_missing responses

• Adds a shared predicate for definitive lost-pad errors.

src/lib/pad-open-flow.js

viewer-main.jsOffer lost-pad recovery in viewer +18/-5

Offer lost-pad recovery in viewer

• Shows the existing recovery card for pad_missing and omits original-file lookup for this case.

src/viewer-main.js

admin-settings.phpClarify pad deletion timing +10/-8

Clarify pad deletion timing

• Updates admin form labels and descriptions to say pads are deleted after files are permanently removed.

templates/admin-settings.php

Bug fix (5) +68 / -35
PadLifecycleController.phpRestrict snapshot recovery to writers +19/-30

Restrict snapshot recovery to writers

• Allows recovery of an existing row's lost pad, checks write permission, and removes path-based lifecycle actions.

lib/Controller/PadLifecycleController.php

PadFileNotWritableException.phpClarify recovery permission failure +2/-1

Clarify recovery permission failure

• Adjusts the exception used when a requester cannot modify the file for recovery.

lib/Exception/PadFileNotWritableException.php

PadCreationService.phpSave template seeding revisions +8/-2

Save template seeding revisions

• Records the seeded pad's revision in newly created file metadata so unsynced template content remains recoverable.

lib/Service/PadCreationService.php

PadFileLockRetryService.phpRecheck bindings before retried writes +14/-1

Recheck bindings before retried writes

• Runs a mapping guard immediately before each file-write attempt, including attempts after lock waits.

lib/Service/PadFileLockRetryService.php

PadSyncService.phpProtect snapshots from recreated pads +25/-1

Protect snapshots from recreated pads

• Refuses to sync a revision-zero pad with text different from the saved file and validates the binding before every write.

lib/Service/PadSyncService.php

Refactor (9) +47 / -79
routes.phpRemove obsolete lifecycle and fault routes +0/-3

Remove obsolete lifecycle and fault routes

• Drops path-based trash and restore endpoints and the debug fault endpoint.

appinfo/routes.php

RestoreFromTrashListener.phpRestore pads without trash settlement +24/-23

Restore pads without trash settlement

• Routes restored files to the simplified restore service and avoids repeating recovery on duplicate restore notifications.

lib/Listeners/RestoreFromTrashListener.php

Binding.phpSimplify binding lifecycle states +7/-13

Simplify binding lifecycle states

• Models active and pending-delete rows without the former restore-pending state.

lib/Service/Binding.php

EtherpadHealthCheckService.phpRemove restore-pending health metric +1/-3

Remove restore-pending health metric

• Keeps the pending-deletion count but no longer reports the retired restore-pending state.

lib/Service/EtherpadHealthCheckService.php

HealthCheckResult.phpDrop obsolete health field +0/-1

Drop obsolete health field

• Removes restore_pending_count from health results.

lib/Service/HealthCheckResult.php

LifecycleResult.phpSimplify restore outcomes +2/-13

Simplify restore outcomes

• Drops result variants used only by removed trash and retry workflows.

lib/Service/LifecycleResult.php

PadPresence.phpRepresent definite loss states only +2/-11

Represent definite loss states only

• Retains absent and revision-zero mismatch outcomes while dropping present and unknown states from the old probe model.

lib/Service/PadPresence.php

PadResponseService.phpAdapt pad responses to the new lifecycle +4/-4

Adapt pad responses to the new lifecycle

• Removes response behavior tied to waiting bindings and aligns error handling with lost-pad opens.

lib/Service/PadResponseService.php

SensitiveMethods.phpRevise sensitive-method inventory +7/-8

Revise sensitive-method inventory

• Removes obsolete lifecycle and fault operations and accounts for the replacement API surface.

lib/Util/SensitiveMethods.php

Tests (84) +4453 / -1691
README.mdDocument lifecycle E2E coverage +16/-1

Document lifecycle E2E coverage

• Adds guidance for the new lost-pad and permanent-deletion Playwright suites.

tests/e2e/README.md

admin-api.tsAdd admin lifecycle test helpers +101/-0

Add admin lifecycle test helpers

• Provides admin API operations for settings, sweeping, consistency checks, and account lifecycle tests.

tests/e2e/fixtures/admin-api.ts

dav.tsExtend WebDAV deletion fixtures +26/-8

Extend WebDAV deletion fixtures

• Adds operations needed to exercise trash bypass, restores, and file or folder deletion.

tests/e2e/fixtures/dav.ts

etherpad.tsAdd Etherpad loss fixtures +39/-0

Add Etherpad loss fixtures

• Provides helpers to remove or inspect pads and sessions in end-to-end tests.

tests/e2e/fixtures/etherpad.ts

pad-gone-for-good.spec.tsExercise permanent-deletion lifecycle end to end +361/-0

Exercise permanent-deletion lifecycle end to end

• Tests trash retention, restores, direct and nested deletion, sessions, renames, accounts, team folders, and vanished-file reporting.

tests/e2e/specs/pad-gone-for-good.spec.ts

pad-lost.spec.tsExercise lost-pad recovery end to end +276/-0

Exercise lost-pad recovery end to end

• Covers absent and recreated pads, template snapshots, read-only permissions, restore recovery, viewer prompts, and forced-sync protection.

tests/e2e/specs/pad-lost.spec.ts

pad-orphan-recovery.spec.tsKeep restored copies unbound +30/-1

Keep restored copies unbound

• Checks that a never-opened copy restored from trash still offers its original pad.

tests/e2e/specs/pad-orphan-recovery.spec.ts

pad-trash-restore.spec.tsAssert retained pad identity on restore +4/-4

Assert retained pad identity on restore

• Adjusts trash/restore expectations to the same pad rather than a replacement.

tests/e2e/specs/pad-trash-restore.spec.ts

e2e-pad-copy-behavior.shRemove removed trash-endpoint assertion +0/-6

Remove removed trash-endpoint assertion

• Stops exercising the deleted path-based trash API for unbound copies.

tests/integration/e2e-pad-copy-behavior.sh

e2e-pad-flow.shAdapt shell flow to removed endpoints +3/-11

Adapt shell flow to removed endpoints

• Drops lifecycle endpoint calls superseded by file operations and Playwright coverage.

tests/integration/e2e-pad-flow.sh

e2e-protected-cookie-contract.shStop cleaning up through pad trash API +3/-12

Stop cleaning up through pad trash API

• Removes cleanup calls to the deleted trash endpoint.

tests/integration/e2e-protected-cookie-contract.sh

admin-settings.test.jsTest revised admin UI results +34/-10

Test revised admin UI results

• Checks the renamed setting, sweep response, and vanished-file display.

tests/js/admin-settings.test.js

answers.jsRemove obsolete test answer +0/-1

Remove obsolete test answer

• Drops a shared fixture for the retired lifecycle response.

tests/js/answers.js

embed-main.test.jsTest embed lost-pad recovery card +25/-10

Test embed lost-pad recovery card

• Verifies pad_missing prompts recovery without original-file search.

tests/js/embed-main.test.js

viewer-main.test.jsTest viewer lost-pad recovery card +18/-4

Test viewer lost-pad recovery card

• Verifies the viewer offers snapshot recovery for pad_missing.

tests/js/viewer-main.test.js

BuildsErrorMappers.phpAdapt error-mapper test wiring +2/-2

Adapt error-mapper test wiring

• Constructs mappers with the revised lost-pad error dependencies.

tests/phpunit/Support/BuildsErrorMappers.php

InMemoryBindingQuery.phpEmulate new binding and cache queries +246/-30

Emulate new binding and cache queries

• Adds the joins, predicates, and schema checks needed to test marked deletions and protected-pad traversal.

tests/phpunit/Support/InMemoryBindingQuery.php

InMemoryBindingTable.phpModel file-cache rows in binding tests +35/-2

Model file-cache rows in binding tests

• Stores file-cache entries alongside binding fixtures and rejects columns absent from the real schema.

tests/phpunit/Support/InMemoryBindingTable.php

PadFiles.phpExtend saved-file test fixture +1/-0

Extend saved-file test fixture

• Supports the additional parsed snapshot content used by recovery tests.

tests/phpunit/Support/PadFiles.php

WiresTheLifecycle.phpReplace legacy lifecycle test wiring +7/-74

Replace legacy lifecycle test wiring

• Removes trash retry and settlement dependencies from shared service construction.

tests/phpunit/Support/WiresTheLifecycle.php

bootstrap.phpLoad additional Nextcloud test stubs +18/-0

Load additional Nextcloud test stubs

• Registers the event, cache, migration, and mount interfaces exercised by the new lifecycle tests.

tests/phpunit/bootstrap.php

BeforeNodeRestoredEvent.phpStub pre-restore event +23/-0

Stub pre-restore event

• Provides the trash event type for restore-window unit tests.

tests/phpunit/stubs/OCA/Files_Trashbin/Events/BeforeNodeRestoredEvent.php

NodeRestoredEvent.phpStub completed restore event +23/-0

Stub completed restore event

• Provides the trash event type for restore-listener tests.

tests/phpunit/stubs/OCA/Files_Trashbin/Events/NodeRestoredEvent.php

ISchemaWrapper.phpStub migration schema access +13/-0

Stub migration schema access

• Supports assertions about binding index replacement.

tests/phpunit/stubs/OCP/DB/ISchemaWrapper.php

IQueryBuilder.phpExtend query-builder stub +1/-0

Extend query-builder stub

• Adds the query-builder contract needed by new binding queries.

tests/phpunit/stubs/OCP/DB/QueryBuilder/IQueryBuilder.php

AbstractCacheEvent.phpStub common cache-event data +36/-0

Stub common cache-event data

• Models file-cache event fields shared by insert and removal events.

tests/phpunit/stubs/OCP/Files/Cache/AbstractCacheEvent.php

CacheEntryInsertedEvent.phpStub cache insertion event +10/-0

Stub cache insertion event

• Enables move and restore cancellation tests.

tests/phpunit/stubs/OCP/Files/Cache/CacheEntryInsertedEvent.php

CacheEntryRemovedEvent.phpStub cache removal event +10/-0

Stub cache removal event

• Enables confirmed-deletion listener tests.

tests/phpunit/stubs/OCP/Files/Cache/CacheEntryRemovedEvent.php

ICachedMountInfo.phpStub cached mount information +13/-0

Stub cached mount information

• Supports account-home storage lookup in deletion tests.

tests/phpunit/stubs/OCP/Files/Config/ICachedMountInfo.php

IMountProviderCollection.phpStub mount-provider collection +15/-0

Stub mount-provider collection

• Supports detection of team-folder storage in listener tests.

tests/phpunit/stubs/OCP/Files/Config/IMountProviderCollection.php

IUserMountCache.phpStub user mount cache +14/-0

Stub user mount cache

• Supports resolving an account's home storage for deletion tests.

tests/phpunit/stubs/OCP/Files/Config/IUserMountCache.php

BeforeNodeDeletedEvent.phpStub pre-delete node event +19/-0

Stub pre-delete node event

• Supports delete-window and session-collection tests.

tests/phpunit/stubs/OCP/Files/Events/Node/BeforeNodeDeletedEvent.php

NodeDeletedEvent.phpStub completed node deletion event +19/-0

Stub completed node deletion event

• Supports mark flushing and post-delete revocation tests.

tests/phpunit/stubs/OCP/Files/Events/Node/NodeDeletedEvent.php

NodeRemovedFromCache.phpStub scan-removal event +26/-0

Stub scan-removal event

• Supports tests that keep cache-scan drops from counting as deletions.

tests/phpunit/stubs/OCP/Files/Events/NodeRemovedFromCache.php

File.phpExtend file-node test contract +1/-1

Extend file-node test contract

• Adds the file behavior required by revised restore and deletion tests.

tests/phpunit/stubs/OCP/Files/File.php

Folder.phpExtend folder-node test contract +1/-1

Extend folder-node test contract

• Adds folder behavior required by protected-pad traversal tests.

tests/phpunit/stubs/OCP/Files/Folder.php

IHomeStorage.phpStub home-storage marker +12/-0

Stub home-storage marker

• Allows listener tests to distinguish user trash and versions from other storages.

tests/phpunit/stubs/OCP/Files/IHomeStorage.php

IRootFolder.phpExtend root-folder stub +2/-0

Extend root-folder stub

• Supports home-mount and user-file resolution tests.

tests/phpunit/stubs/OCP/Files/IRootFolder.php

IMountPoint.phpStub mount-point lookup +12/-0

Stub mount-point lookup

• Supports identifying account-owned home storage.

tests/phpunit/stubs/OCP/Files/Mount/IMountPoint.php

Node.phpExtend node stub +11/-0

Extend node stub

• Supplies storage and identity methods used by lifecycle listeners.

tests/phpunit/stubs/OCP/Files/Node.php

IStorage.phpExtend storage stub +6/-0

Extend storage stub

• Supplies storage identifiers and cache access used by deletion classification.

tests/phpunit/stubs/OCP/Files/Storage/IStorage.php

IAppConfig.phpExtend app-config stub +2/-0

Extend app-config stub

• Supports deletion-setting migration tests.

tests/phpunit/stubs/OCP/IAppConfig.php

IConfig.phpExtend config stub +2/-0

Extend config stub

• Supports revised configuration and migration fixtures.

tests/phpunit/stubs/OCP/IConfig.php

SimpleMigrationStep.phpStub migration base class +19/-0

Stub migration base class

• Allows the new migration to run under PHPUnit.

tests/phpunit/stubs/OCP/Migration/SimpleMigrationStep.php

BeforeUserDeletedEvent.phpStub pre-account-deletion event +19/-0

Stub pre-account-deletion event

• Supports collection of owned file IDs before home-storage cleanup.

tests/phpunit/stubs/OCP/User/Events/BeforeUserDeletedEvent.php

UserDeletedEvent.phpStub completed account-deletion event +19/-0

Stub completed account-deletion event

• Supports marking owned files only after account deletion succeeds.

tests/phpunit/stubs/OCP/User/Events/UserDeletedEvent.php

AdminConsistencyCheckResponseBuilderTest.phpTest vanished-file admin responses +7/-5

Test vanished-file admin responses

• Asserts the revised consistency count and samples.

tests/phpunit/unit/AdminConsistencyCheckResponseBuilderTest.php

AdminControllerTest.phpTest revised admin settle behavior +50/-61

Test revised admin settle behavior

• Checks immediate gone-file sweeping, deletion-disabled responses, and new admin result fields.

tests/phpunit/unit/AdminControllerTest.php

AdminSettingsRepositoryTest.phpTest renamed stored setting +4/-2

Test renamed stored setting

• Checks persistence of delete_pad_with_file.

tests/phpunit/unit/AdminSettingsRepositoryTest.php

AdminSettingsValidatorTest.phpTest renamed settings validation +6/-6

Test renamed settings validation

• Covers validation of the permanent-deletion switch.

tests/phpunit/unit/AdminSettingsValidatorTest.php

ApiErrorCodeTest.phpTest lost-pad error code +4/-6

Test lost-pad error code

• Updates error-code expectations after removing waiting and fault responses.

tests/phpunit/unit/ApiErrorCodeTest.php

ApiErrorLogTest.phpTest lost-pad log throttling +34/-5

Test lost-pad log throttling

• Checks that repeated public loss reports are limited.

tests/phpunit/unit/ApiErrorLogTest.php

AppConfigServiceTest.phpTest deletion-setting takeover +56/-13

Test deletion-setting takeover

• Covers old-key fallback, migration, opt-out preservation, and new-key writes.

tests/phpunit/unit/AppConfigServiceTest.php

BindingServiceTest.phpTest file-cache-aware binding lifecycle +211/-277

Test file-cache-aware binding lifecycle

• Replaces waiting-state cases with deletion marks, cache checks, stale cleanup, and safe transitions.

tests/phpunit/unit/BindingServiceTest.php

BindingTest.phpTest simplified binding model +9/-65

Test simplified binding model

• Updates state and timestamp expectations for active and pending-delete rows.

tests/phpunit/unit/BindingTest.php

ConsistencyCheckServiceTest.phpTest vanished-file-only reporting +36/-0

Test vanished-file-only reporting

• Ensures pending deletions are excluded while unexplained missing files are listed.

tests/phpunit/unit/ConsistencyCheckServiceTest.php

EtherpadClientTest.phpTest new Etherpad probes +17/-0

Test new Etherpad probes

• Covers client calls used for revision and availability checks.

tests/phpunit/unit/EtherpadClientTest.php

EtherpadHealthCheckServiceTest.phpTest simplified health counts +3/-7

Test simplified health counts

• Removes restore-pending assertions and retains pending-delete coverage.

tests/phpunit/unit/EtherpadHealthCheckServiceTest.php

ExpiredSessionCollectorTest.phpAdapt session fixture wiring +2/-2

Adapt session fixture wiring

• Updates expectations for the revised session-revocation dependencies.

tests/phpunit/unit/ExpiredSessionCollectorTest.php

GoneFileSweepTest.phpTest bounded permanent-deletion sweep +356/-0

Test bounded permanent-deletion sweep

• Covers grace, settings, cache rechecks, stale marks, retries, refusal isolation, and outages.

tests/phpunit/unit/GoneFileSweepTest.php

GoneFilesListenerTest.phpTest deletion-event classification +535/-0

Test deletion-event classification

• Covers trash, restores, scans, moves, folder descendants, account deletion, batching, and repeat removals.

tests/phpunit/unit/GoneFilesListenerTest.php

KeepPadsThroughTheTrashMigrationTest.phpTest beta-state migration +95/-0

Test beta-state migration

• Checks pending-row reactivation, setting takeover, index changes, and removal of retired jobs.

tests/phpunit/unit/KeepPadsThroughTheTrashMigrationTest.php

LifecycleResultTest.phpTest simplified lifecycle outcomes +3/-4

Test simplified lifecycle outcomes

• Updates result expectations after removal of waiting and trash-specific states.

tests/phpunit/unit/LifecycleResultTest.php

LivePadHtmlFetcherTest.phpAdapt Etherpad fixture behavior +6/-6

Adapt Etherpad fixture behavior

• Updates fetcher tests to the revised lifecycle client setup.

tests/phpunit/unit/LivePadHtmlFetcherTest.php

ManagedPadLifecycleTest.phpTest definitive pad-loss rules +130/-69

Test definitive pad-loss rules

• Covers absent pads, revision-zero text comparison, saved template content, probe failures, and seeded revision counts.

tests/phpunit/unit/ManagedPadLifecycleTest.php

PadBootstrapServiceTest.phpRemove obsolete bootstrap assertion +0/-5

Remove obsolete bootstrap assertion

• Drops a test expectation tied to the former lifecycle path.

tests/phpunit/unit/PadBootstrapServiceTest.php

PadControllerErrorMapperTest.phpTest authenticated lost-pad mapping +5/-3

Test authenticated lost-pad mapping

• Checks pad_missing handling in pad controller errors.

tests/phpunit/unit/PadControllerErrorMapperTest.php

PadCreationServiceTest.phpTest template revision persistence +60/-0

Test template revision persistence

• Verifies initial snapshots record seeded revisions for recovery.

tests/phpunit/unit/PadCreationServiceTest.php

PadFileLockRetryServiceTest.phpTest guards on repeated writes +25/-0

Test guards on repeated writes

• Checks binding validation runs again after a file-lock wait.

tests/phpunit/unit/PadFileLockRetryServiceTest.php

PadFileServiceTest.phpTest saved snapshot parsing +1/-0

Test saved snapshot parsing

• Checks parsed files expose the saved text needed for loss detection.

tests/phpunit/unit/PadFileServiceTest.php

PadLifecycleControllerTest.phpTest recovery permissions and routing +39/-26

Test recovery permissions and routing

• Checks reader refusal and replacement of a definitively lost bound pad.

tests/phpunit/unit/PadLifecycleControllerTest.php

PadOpenServiceTest.phpTest writable lost-pad opens +52/-56

Test writable lost-pad opens

• Checks single probes, read-only bypass, definite loss errors, and fail-open behavior when Etherpad cannot answer.

tests/phpunit/unit/PadOpenServiceTest.php

PadSessionControllerTest.phpAdapt session controller fixtures +7/-5

Adapt session controller fixtures

• Updates controller test wiring for the revised session revoker.

tests/phpunit/unit/PadSessionControllerTest.php

PadSessionRevokerTest.phpTest delete-time session revocation +166/-0

Test delete-time session revocation

• Covers shared groups, newest-first limits, time budgets, and progress when Etherpad is slow.

tests/phpunit/unit/PadSessionRevokerTest.php

PadSyncServiceTest.phpTest safe forced synchronization +105/-9

Test safe forced synchronization

• Ensures a recreated revision-zero pad cannot overwrite saved content, while a revision-bearing pad behind its snapshot can sync.

tests/phpunit/unit/PadSyncServiceTest.php

ProtectedPadsOfNodeTest.phpTest bounded folder pad discovery +133/-0

Test bounded folder pad discovery

• Checks file-cache traversal, protected-mode filtering, and query limits.

tests/phpunit/unit/ProtectedPadsOfNodeTest.php

PublicPadContextServiceTest.phpAdapt public context lifecycle tests +11/-42

Adapt public context lifecycle tests

• Removes old waiting-state assumptions from public-share context checks.

tests/phpunit/unit/PublicPadContextServiceTest.php

PublicPadOpenServiceTest.phpTest public-share loss detection +30/-2

Test public-share loss detection

• Checks writable opens detect loss and read-only opens do not probe.

tests/phpunit/unit/PublicPadOpenServiceTest.php

PublicViewerControllerErrorMapperTest.phpTest public lost-pad response +4/-5

Test public lost-pad response

• Checks public viewer mapping after removal of obsolete waiting errors.

tests/phpunit/unit/PublicViewerControllerErrorMapperTest.php

PublicViewerControllerTest.phpAdapt public viewer response tests +7/-8

Adapt public viewer response tests

• Updates controller expectations for the simplified open lifecycle.

tests/phpunit/unit/PublicViewerControllerTest.php

RestoreFromTrashListenerTest.phpTest retained-pad restores +84/-74

Test retained-pad restores

• Covers restore events and hooks, duplicate notification handling, and restored missing pads.

tests/phpunit/unit/RestoreFromTrashListenerTest.php

RestoreServiceTest.phpTest unified snapshot replacement +504/-727

Test unified snapshot replacement

• Covers kept pads, lost pads, copies, permissions, failed seeding and writes, and guarded rebinding.

tests/phpunit/unit/RestoreServiceTest.php<...

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (6) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Trashed pads keep active edit sessions 🐞 Bug ⛨ Security
Description
RevokeSessionsOnDeleteListener::handle() revokes protected-pad sessions only on
NodeDeletedEvent. Trash deletes can finish through \OCP\Files::postDelete without that event, so
a session issued before the file left Files remains usable against the pad kept in the trash until
it expires.
Code

lib/Listeners/RevokeSessionsOnDeleteListener.php[R59-64]

+			} elseif ($event instanceof NodeDeletedEvent) {
+				$fileId = $event->getNode()->getId();
+				$pads = $this->pending[$fileId] ?? [];
+				unset($this->pending[$fileId]);
+				if ($pads !== []) {
+					$this->revoker->revokeForPads($pads);
Evidence
The listener stores pads before deletion and calls revokeForPads() only in its NodeDeletedEvent
branch. Application registration sends the legacy post-delete event to the gone-files listener but
not this revocation listener; the architecture documentation states that trash deletes use that
post-delete event without NodeDeletedEvent.

lib/Listeners/RevokeSessionsOnDeleteListener.php[51-65]
lib/AppInfo/Application.php[124-151]
docs/architecture.md[235-235]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Protected-pad sessions are revoked only after `NodeDeletedEvent`, but a trash delete can finish with only the legacy post-delete event. The pad remains in the trash while its existing sessions remain usable.
## Fix Focus Areas
- lib/Listeners/RevokeSessionsOnDeleteListener.php[51-65]
- lib/AppInfo/Application.php[124-151]
## Recommended Fix
Handle the trash delete's completion event as well as `NodeDeletedEvent`. Consume the matching pending delete once, revoke its sessions only after deletion succeeds, and cover both event paths with tests.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Restored files can lose their pad 🐞 Bug ≡ Correctness
Description
GoneFileSweep::discard() checks that the file is absent before calling Etherpad, but does not
recheck the binding before deleting the pad. If a restore puts the file back and reactivates its row
between those steps, the sweep deletes the restored file's pad while deleteInState() leaves its
active row in place.
Code

lib/Service/GoneFileSweep.php[R130-135]

+		if (!$this->bindingService->isFileGone($binding->fileId)) {
+			return false;
+		}
+		$context = ['app' => 'etherpad_nextcloud', 'fileId' => $binding->fileId, 'padId' => $binding->padId];
+		try {
+			$this->padLifecycle->discardIfPresent($binding->padId, $budget, retried: true);
Evidence
The sweep separates its file-cache check from the Etherpad deletion, and its conditional row
deletion occurs only afterward. The restore path can change a pending row back to active during that
interval.

lib/Service/GoneFileSweep.php[129-157]
lib/Service/RestoreService.php[74-90]
lib/Service/BindingService.php[315-332]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A restore can reactivate a binding after the sweep checks the file cache but before it deletes the pad, leaving the restored file bound to a deleted pad.
## Fix Focus Areas
- lib/Service/GoneFileSweep.php[129-157]
- lib/Service/RestoreService.php[74-90]
## Recommended Fix
Serialize the restore and sweep decisions for a binding so the sweep cannot delete its pad after restoration has reclaimed it. Ensure the final state check protects the Etherpad deletion, not just the database-row deletion.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Deleted users keep access to shared pads ✓ Resolved
Description
GoneFilesListener marks a deleted user's home-file bindings but never calls the account-wide
session revoker. When an account is deleted without a logout, its existing Etherpad
sessions—including sessions for protected pads owned by other users—remain valid until they expire.
Code

lib/Listeners/GoneFilesListener.php[R178-182]

+			} elseif ($event instanceof UserDeletedEvent) {
+				$uid = $event->getUser()->getUID();
+				$fileIds = $this->leavingHomes[$uid] ?? [];
+				unset($this->leavingHomes[$uid]);
+				$this->bindingService->markGone($fileIds);
Evidence
User-deletion events are handled by the gone-files listener, whereas the new session listener
receives only node-deletion events. The revoker has a separate account-wide method, and its
documentation states that an issued session continues granting pad access until expiry.

lib/Listeners/GoneFilesListener.php[176-182]
lib/AppInfo/Application.php[127-142]
lib/Service/PadSessionRevoker.php[70-100]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Deleting an account marks its own files for cleanup but leaves that account's Etherpad sessions usable, including sessions for shared pads.
## Fix Focus Areas
- lib/Listeners/GoneFilesListener.php[176-182]
- lib/AppInfo/Application.php[127-142]
## Recommended Fix
Invoke the account-wide session revocation path after successful account deletion, independently of the delayed sweep of that account's files.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View high (1)
4. Files left mid-restore can never be opened ✓ Resolved
Description
The removed restore_pending state is not converted by
Version000005Date20260928120000::postSchemaChange(), and assertConsistentMapping() now throws
for any row that is not active. recoverLostPad() refuses such rows, findGone() only takes
pending_delete rows, and the consistency check counts only active rows, so these files are stuck
and nothing reports them.
Code

lib/Migration/Version000005Date20260928120000.php[R95-100]

+		// A pending_delete row whose file the file cache still has is the
+		// file's, kept: the same step the sweep takes for a deletion that
+		// did not happen, for every such row at once.
+		$now = $this->timeFactory->getTime();
+		while ($this->bindingService->clearStaleGone($now, 500) === 500) {
+		}
Evidence
STATE_RESTORE_PENDING is removed from BindingService, and assertConsistentMapping throws when
the state is not active. The migration only calls clearStaleGone, which selects `state =
pending_delete. The removed RestoreService::deferRestore is what wrote restore_pending` rows.

lib/Service/BindingService.php[399-417]
lib/Service/BindingService.php[344-355]
lib/Service/RestoreService.php[229-232]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
1.1.0-beta.1 wrote `restore_pending` rows. This PR removes that state, but the migration leaves those rows unchanged, so open, recovery and the sweep all reject them.
## Fix Focus Areas
- lib/Migration/Version000005Date20260928120000.php[88-101]
- tests/phpunit/unit/KeepPadsThroughTheTrashMigrationTest.php[25-62]
## Recommended Fix
In `postSchemaChange`, update the binding table: SET state='active', deleted_at=NULL, updated_at=now WHERE state='restore_pending'. The open's lost-pad check will then offer a new pad where the pad is actually gone. Add a `restore_pending` row to the migration test.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. Recovery can overwrite a newer file edit ✓ Resolved
Description
RestoreService::restoreOntoNewPad() writes content built from an earlier file read after checking
only whether the file's ID and path changed. If another writer updates the file while the
replacement pad is being seeded, the recovery can claim the row and replace that newer content with
its stale snapshot.
Code

lib/Service/RestoreService.php[R465-468]

  		$this->provisionedPadRollback->discardUnlessBoundToFile($fileId, $newPadId, $flow);
  		return LifecycleResult::skipped('binding_state_transition_conflict', $fileId, $this->logger);
  	}
-			$this->writeRestoredContent($file, $updatedContent);
+			$file->putContent($updatedContent);
Evidence
Recovery reads the file before seeding its replacement. The pre-write hasMoved() check compares
only ID and path, and the subsequent putContent() writes the earlier snapshot; the sync service is
another file-content writer that can run during this interval.

lib/Service/RestoreService.php[201-205]
lib/Service/RestoreService.php[443-468]
lib/Service/UserNodeResolver.php[227-236]
lib/Service/PadSyncService.php[201-212]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Recovery seeds a replacement from a previously read snapshot, then writes it without establishing that the file content is still the same. A concurrent file update can be overwritten even though the file has not moved.
## Fix Focus Areas
- lib/Service/RestoreService.php[443-468]
- lib/Service/UserNodeResolver.php[227-236]
## Recommended Fix
Before claiming the binding and writing, verify under an appropriate file lock or conditional-write mechanism that the file still contains the snapshot used for seeding. On a mismatch, discard the unclaimed replacement and retry from the current content or leave the file unchanged.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Public pads can hide saved whitespace 🐞 Bug ≡ Correctness
Description
ManagedPadLifecycle::sameText() removes trailing spaces as well as final newlines, while
savedAnything() treats whitespace-only snapshot text as empty. When a revision-zero public pad has
been recreated with text differing only in trailing whitespace, or its file saved only whitespace,
the lost-pad check can accept or skip the recreated pad instead of offering recovery from the file.
Code

lib/Service/ManagedPadLifecycle.php[R272-275]

+	/** Text as Etherpad and a file hold it, but for line endings and the final newline. */
+	private static function sameText(string $a, string $b): bool {
+		$normalize = static fn (string $text): string => rtrim(str_replace("\r\n", "\n", $text));
+		return $normalize($a) === $normalize($b);
Evidence
File parsing retains the snapshot text, but rtrim() makes texts differing in trailing spaces
compare equal and trim() makes whitespace-only text count as unsaved. The writable open blocks
access only when this lost-pad check detects a loss.

lib/Service/PadFileService.php[171-183]
lib/Service/ManagedPadLifecycle.php[243-275]
lib/Service/ManagedPadLifecycle.php[310-321]
lib/Service/PadOpenService.php[123-130]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Lost-pad detection discards meaningful whitespace when comparing saved text with a revision-zero pad and when deciding whether a public file holds saved text. This can prevent recovery from being offered for content that is still in the file.
## Fix Focus Areas
- lib/Service/ManagedPadLifecycle.php[268-275]
- lib/Service/ManagedPadLifecycle.php[310-321]
## Recommended Fix
Normalize only the line-ending differences that must be ignored, without stripping trailing spaces or tabs. Treat nonempty whitespace-only snapshot text as saved content, and add revision-zero public-pad tests for both cases.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Nested deletes can leave pads behind 🐞 Bug ≡ Correctness
Description
GoneFilesListener::inserted() clears every entry in $deleting when it sees one insertion into a
trash, rather than closing only the matching delete window. If a second delete is in progress in the
same request, its later cache removals no longer match a window, so its bindings are never marked
for the sweep.
Code

lib/Listeners/GoneFilesListener.php[R262-265]

+	private function inserted(CacheEntryInsertedEvent $event): void {
+		if ($this->deleting !== [] && $this->inTrash($event->getPath(), $event->getStorage(), $event->getStorageId())) {
+			$this->deleting = [];
+		}
Evidence
The listener stores deletion windows as a map, but a single trash insertion replaces the entire map
with an empty array. Subsequent removals qualify only when they are under a remaining window or
already in trash.

lib/Listeners/GoneFilesListener.php[137-141]
lib/Listeners/GoneFilesListener.php[262-265]
lib/Listeners/GoneFilesListener.php[321-335]
lib/Listeners/GoneFilesListener.php[393-405]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A trash insertion globally discards the tracking windows for other deletions underway in the same request.
## Fix Focus Areas
- lib/Listeners/GoneFilesListener.php[262-265]
- lib/Listeners/GoneFilesListener.php[275-289]
- lib/Listeners/GoneFilesListener.php[321-335]
## Recommended Fix
Associate a trash insertion with the delete window it completes and clear only that window. Preserve unrelated or nested windows until their own delete completes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (3)
8. A recreated protected pad can be deleted 🐞 Bug ☼ Reliability
Description
ManagedPadLifecycle::discardIfPresent() lists the pads in a previously absent pad's group and then
deletes that group in a separate call if the list was empty. If another request recreates a pad in
that group between the two calls, cleanup of the old pad deletes the new one.
Code

lib/Service/ManagedPadLifecycle.php[R384-385]

+			} elseif ($groupId !== null && $this->etherpadClient->listPads($groupId, RunBudget::timeoutOf($budget)) === []) {
+				$this->etherpadClient->deleteGroup($groupId, RunBudget::timeoutOf($budget));
Evidence
The newly added known-absent branch makes separate list and delete calls. Restore invokes that
branch after observing the old pad absent, while pad provisioning can independently add a pad to a
group.

lib/Service/ManagedPadLifecycle.php[363-385]
lib/Service/RestoreService.php[361-366]
lib/Service/PadCreationService.php[211-216]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Cleanup deletes a protected group based on an earlier empty-list response, so a pad recreated before group deletion can be removed.
## Fix Focus Areas
- lib/Service/ManagedPadLifecycle.php[379-386]
- lib/Service/RestoreService.php[361-366]
## Recommended Fix
Coordinate group cleanup with pad creation, or use a deletion mechanism that cannot remove a group once another pad has joined it.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Undated waiting deletions are never swept ✓ Resolved
Description
findGone() and clearStaleGone() filter on b.deleted_at <= x, which is never true when
deleted_at is NULL. Rows that older versions left in pending_delete without a date are therefore
never deleted, never made active again, and not listed by the consistency check, which now counts
only active rows.
Code

lib/Service/BindingService.php[R320-322]

+			->where($qb->expr()->eq('b.state', $qb->createNamedParameter(self::STATE_PENDING_DELETE)))
+			->andWhere($qb->expr()->isNull('fc.fileid'))
+			->andWhere($qb->expr()->lte('b.deleted_at', $qb->createNamedParameter($graceBy, IQueryBuilder::PARAM_INT)))
Evidence
The removed docs said deletedAt is null on a deletion owed that never recorded one, and that such
rows were reached only by the admin's settle run. The new queries all require deleted_at <=, and
the consistency check filters on state = 'active'.

lib/Service/BindingService.php[315-333]
lib/Service/ConsistencyCheckService.php[50-54]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pending_delete` rows with a NULL `deleted_at` are excluded by the `lte` filters, so no code path ever handles them.
## Fix Focus Areas
- lib/Migration/Version000005Date20260928120000.php[88-101]
- lib/Service/BindingService.php[315-355]
## Recommended Fix
Before the `clearStaleGone` loop in the migration, set deleted_at = updated_at (or now) on rows WHERE state='pending_delete' AND deleted_at IS NULL.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Admin form can re-enable pad deletion ✓ Resolved
Description
getStoredSettings() reads only delete_pad_with_file with a default of 'yes', while
isDeletePadWithFileEnabled() falls back to the old delete_on_trash key until the migration has
run. An opted-out admin who saves the form before the migration writes 'yes', and
takeOverDeleteOnTrash() then keeps that value because the new key is already set.
Code

lib/Service/AdminSettingsRepository.php[29]

+			$this->appConfig->getValueString(Application::APP_ID, AppConfigService::DELETE_PAD_WITH_FILE, 'yes') === 'yes',
Evidence
AppConfigService documents the fallback for code running before its migration. The repository line
bypasses that fallback, and persist() writes the new key.

lib/Service/AppConfigService.php[52-74]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The admin form ignores the legacy `delete_on_trash` fallback, so saving the form before the migration can overwrite an admin's opt-out.
## Fix Focus Areas
- lib/Service/AdminSettingsRepository.php[26-32]
## Recommended Fix
Read the value through `AppConfigService::isDeletePadWithFileEnabled()`, or apply the same fallback to `delete_on_trash` when the new key is empty.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

11. Opens wait on extra Etherpad lookups 🐞 Bug ➹ Performance
Description
buildOpenContext calls isKnownLost() on every writable non-external open; this calls
getRevisionsCount, and sometimes also getText, each with a 3-second timeout. Protected pads
always make the call, so a slow Etherpad adds up to about 6 seconds to each open even though
failures are swallowed.
Code

lib/Service/PadOpenService.php[R123-125]

+		if (!$isExternal && $mayWrite && $this->padLifecycle->isKnownLost($padId, $accessMode, $pad->snapshotRev, $pad->savedText)) {
+			throw new PadLostException('Etherpad has lost the pad of this file.');
+		}
Evidence
howLost calls getRevisionsCount with PROBE_TIMEOUT_SECONDS and, at revision 0, also getText.
holdsSavedContent is always true for protected pads.

lib/Service/ManagedPadLifecycle.php[243-260]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Every writable open waits on one or two synchronous Etherpad calls to check whether the pad is lost.
## Fix Focus Areas
- lib/Service/PadOpenService.php[117-125]
- lib/Service/PublicPadOpenService.php[50-56]
## Recommended Fix
Cache a recent 'present' answer per pad for a short time, or run the probe only after the open has already seen a sign that the pad may be missing.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can add REVIEW.md to your repo root and Qodo follows it on every PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +130 to +135
if (!$this->bindingService->isFileGone($binding->fileId)) {
return false;
}
$context = ['app' => 'etherpad_nextcloud', 'fileId' => $binding->fileId, 'padId' => $binding->padId];
try {
$this->padLifecycle->discardIfPresent($binding->padId, $budget, retried: true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Restored files can lose their pad 🐞 Bug ≡ Correctness

GoneFileSweep::discard() checks that the file is absent before calling Etherpad, but does not
recheck the binding before deleting the pad. If a restore puts the file back and reactivates its row
between those steps, the sweep deletes the restored file's pad while deleteInState() leaves its
active row in place.
Agent Prompt
## Issue description
A restore can reactivate a binding after the sweep checks the file cache but before it deletes the pad, leaving the restored file bound to a deleted pad.
## Fix Focus Areas
- lib/Service/GoneFileSweep.php[129-157]
- lib/Service/RestoreService.php[74-90]
## Recommended Fix
Serialize the restore and sweep decisions for a binding so the sweep cannot delete its pad after restoration has reclaimed it. Ensure the final state check protects the Etherpad deletion, not just the database-row deletion.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as it is: this interleaving cannot happen. The sweep deletes a pad only while the file cache has nothing under the file's id, checked when the row is picked and again right before the delete. A restore needs the file's entry in the trash, and a restore or a move updates that entry in place, keeping its id; an id the file cache no longer has does not come back. So a file whose pad the sweep is deleting cannot be restored under that id.

Comment thread lib/Listeners/GoneFilesListener.php
Comment thread lib/Listeners/GoneFilesListener.php
Comment thread lib/Service/ManagedPadLifecycle.php
Comment thread lib/Migration/Version000005Date20260928120000.php
Comment thread lib/Service/BindingService.php
Comment thread lib/Service/AdminSettingsRepository.php Outdated
Comment thread lib/Service/PadOpenService.php
An account deleted without a logout kept its sessions, and with them
the protected pads of other people's files shared with it, until they
expired. They go now as the delete starts, as on a logout: Nextcloud
removes the account's settings, the cached Etherpad author among them,
before it reports the account gone.
…does not know

A restore that could not finish on a main after 1.1.0-beta.1 left its
row restore_pending, and a deletion owed may have no date. Neither is
reached by anything here: an open refuses them, and no list shows them.
The migration dates them as seen deleted now, so the next step makes
those whose file is there active, and the sweep takes the pads of the
rest.
The form read the new key alone, with its default on, while the sweep
falls back to the old key until the migration takes it over. An admin
who had switched deleting off and saved the form before the migration
wrote the new key on, and the takeover kept that. The form reads the
setting through AppConfigService now, as the sweep does.
The session TTL, the revoker's budget, a revoked share and the list of
events still said only a logout ends a session. The event list names the
logout's listener, which it had left out, beside the new one.
The window NodeRemovedFromCache opened stayed open for the rest of the
process: a removal under that path later on - the trash expiring, a
delete - no longer counted, and the pad was left and listed as
vanished. Nextcloud reports the dropped entry after all under it, so its
own removal now ends the window, once it is judged itself.

The listener's list of what never counts names the scan's drop, the
event lists name NodeRemovedFromCache, and the lookup of a deleted
account's home says it sets up that storage as Nextcloud's own cleanup
does at the same event.
The forced sync's table repeated the rule of a pad made anew, which the
lifecycle test holds case by case; the sync keeps one refused case, one
written behind the snapshot and one new file. A copy's restore looped
over row states the code never reads. Two of the four end-to-end checks
of the forced sync took the same branch of the rule as the visit; the
visit and the 1.1.0-beta.1 template keep it.
@Jaggob

Jaggob commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

/agentic_review

Comment thread lib/Listeners/RevokeSessionsOnDeleteListener.php
Comment thread lib/Service/RestoreService.php
Comment thread lib/Service/ManagedPadLifecycle.php
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 33145b7

A recovery made its new pad from the file as it read it, and after the
seeding asked only whether the file had moved. A file written meanwhile -
a sync of a pad made anew that someone wrote into, an upload - lost what
was written to the older snapshot. The file is read again before the
claim: holding something else, the new pad goes, nothing is claimed or
written (file_changed), and the next open asks again.
@Jaggob
Jaggob merged commit 868fdb1 into main Sep 29, 2026
25 checks passed
@Jaggob
Jaggob deleted the feat/keep-pad-until-gone branch September 29, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant