Skip to content

Deleted songs come back: server delete and deletion tombstone both fail silently, and orphan recovery re-adopts the directory #521

Description

@thcp

Reported on macOS: deleting a song -- by clearing the trash, or purging the library -- brings it back later.

Reproduced by code inspection. Deletion has two independent halves and both swallow their failures, so several distinct mechanisms produce this symptom.

The delete path

static/js/catalog.js:1961-1970 (the clearBinBtn handler):

markJobsDeleted(toDelete); // persist before purge so reload can't re-import
purgeTrash();
saveState();
render();
for (const id of toDelete) {
  fetch(`/api/jobs/${id}`, { method: "DELETE" }).catch(() => {});
}

syncWithServer (catalog.js:2708-2723) re-adds every job from GET /api/jobs that is not in tracks, not in the trash folder, and not in the deleted-ids tombstone.

Four confirmed mechanisms

1. The server DELETE is fire-and-forget and its errors are discarded.
.catch(() => {}). A failed delete leaves the job in the registry, and nothing retries or reports it.

2. The tombstone can silently fail to persist, and it is the only remaining defence.
markJobsDeleted (catalog.js:133-138) calls storeSet(DELETED_JOBS_KEY, ...) without awaiting, and its .catch only logs a warning. If the write fails, or the app closes before it lands, the tombstone is lost. On next launch loadState reads the older list and syncWithServer re-imports every affected job. The inline comment says "persist before purge so reload can't re-import" -- the intent is right, but nothing waits for the persist.

3. A successful server delete can still leave the directory, which is then re-adopted.
_rmtree_job (app/api/jobs.py:113-121) catches every exception and only logs a warning:

try:
    shutil.rmtree(job_dir)
except Exception:
    logger.warning("failed to remove job dir %s", job_dir, exc_info=True)

delete_job (jobs.py:762-764) then calls registry_remove and registry_persist regardless of whether the files went away. On the next start, restore() (app/core/registry.py:183-190) walks jobs_dir and adopts any JOB_ID_RE-shaped directory not already known, via _recover_done_job. The job is recreated from disk.

4. Some jobs can never be deleted, and the UI never says so.
delete_job returns 409 unless job.status in ("done", "error", "cancelled"). A job stuck in queued -- for instance the phantom queued job in #520 -- 409s forever. Because the fetch is fire-and-forget, the user sees the row disappear and reappear with no error.

Also found

There is no "purge library" code path. markJobsDeleted has exactly one caller (catalog.js:1964). Any other UI route to permanent deletion does not tombstone, so syncWithServer will always re-import those tracks. catalog.js:1631-1634 (dropping a cancelled job's placeholder) is one such path: delete tracks[jobId] with no tombstone and no server delete.

Not established

No platform-gated cause was found -- nothing in these paths is macOS-specific. Mechanism 3 is the most plausible explanation for it being noticed on macOS (Finder/Spotlight touching files mid-delete makes rmtree fail more often there), but that is a hypothesis, not a verified finding.

Fix

  • Await the tombstone write before purging, and treat its failure as a failed delete.
  • Await the server DELETE; surface failures and retry, rather than .catch(() => {}).
  • _rmtree_job should report failure to its caller; delete_job should not remove the registry entry when the files are still on disk, and should return an error the UI shows.
  • Allow deleting a job in a non-terminal state, or give the UI a way to report the 409.
  • Have restore()'s orphan adoption respect a server-side deletion record, so a directory that failed to delete is not silently resurrected.

Test

  • Delete with shutil.rmtree raising: the job must not vanish from the registry, and the user must see an error.
  • Clear the bin, kill the app before the store write completes, relaunch: the tracks must stay deleted.
  • Delete a job in queued state: it must succeed or report a visible error.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions