Skip to content

fix(ui): unwatch projects on UI delete; harden deferred-free OOM corner#843

Merged
DeusData merged 1 commit into
mainfrom
distill/830-ui-unwatch
Jul 4, 2026
Merged

fix(ui): unwatch projects on UI delete; harden deferred-free OOM corner#843
DeusData merged 1 commit into
mainfrom
distill/830-ui-unwatch

Conversation

@DeusData

@DeusData DeusData commented Jul 4, 2026

Copy link
Copy Markdown
Owner

fix(ui): unwatch projects on UI delete; harden deferred-free OOM corner

Distilled from #830 (author: Dustin Persek dustin.persek@protonmail.com), rebased over the
defer_state_free refactor that landed with #817.

Summary

DELETE /api/project removed the project's .db file but never told the watcher. The zombie
watch kept polling the repo and the next change reindexed it — silently resurrecting the DB the
user just deleted (#803).

  • main.c: hand the watcher to the UI HTTP server (cbm_http_server_set_watcher) right after
    server creation. Lifecycle holds on current main: watcher is created (l.658) before the HTTP
    server (l.677); teardown stops/frees the HTTP server (l.702–705) before the watcher (l.709–712),
    so the non-owned reference can never dangle.
  • http_server.c/.h: handle_delete_project now unwatches after a successful unlink and on
    ENOENT (clears an already-orphaned zombie watch, still 404). Invalid project name → 404 with
    no unwatch. Unlink failure (non-ENOENT) → 500 and the watch is kept. The cbm_file_exists
    pre-check is replaced by unlink-errno handling (removes the TOCTOU window).
  • watcher.c: the OOM corner of the deferred-free path is hardened. Previously, if growing the
    pending_free list failed, the state was freed immediately — a potential use-after-free against
    an in-flight poll_once snapshot. defer_state_free now returns failure without freeing;
    callers keep the entry registered (cbm_ht_delete only happens once the state is safely on the
    deferred-free list) and emit watcher.unwatch.oom.

Re-target rationale (why this differs from #830's watcher hunk)

#830 was audited CORRECT but written against pre-#817 code, where the pending-free logic lived
inline in cbm_watcher_unwatch. Main has since extracted it into defer_state_free, shared by
cbm_watcher_unwatch and the stale-root prune path (prune_missing_project) — and the UAF-prone
state_free fallback moved inside that helper. Applying #830's hunk verbatim would have hardened
only the unwatch path and left the same bug reachable via root-prune. The hardening therefore goes
into defer_state_free itself (fail → keep registered → warn), covering both call sites; the
prune path additionally retries on the next poll cycle.

Tests (carried from #830, adapted to current main)

Six regressions in tests/test_httpd.c (suite httpd), driving the live UI server over a socket
with a real watcher wired in:

  • ui_server_delete_project_unwatches_after_delete (core red-first case)
  • ui_server_delete_project_unwatches_missing_db (ENOENT zombie-clear, still 404)
  • ui_server_delete_project_no_watcher_still_deletes
  • ui_server_delete_project_missing_name_keeps_watch (400)
  • ui_server_delete_project_invalid_name_keeps_watch (404, no unwatch)
  • ui_server_delete_project_unlink_failure_keeps_watch (500)

Red/green evidence

RED — src/ui/http_server.c + src/main.c reverted to origin/main (plus a link-only no-op
cbm_http_server_set_watcher shim so the fixture compiles), rebuilt, test-runner httpd:

FAIL tests/test_httpd.c:673: cbm_watcher_watch_count(fx.watcher) == 1, expected 0 == 0
FAIL tests/test_httpd.c:694: cbm_watcher_watch_count(fx.watcher) == 1, expected 0 == 0
36 passed, 2 failed

GREEN — fix restored, rebuilt, test-runner httpd watcher:

96 passed

make -f Makefile.cbm cbm builds -Werror clean; make -f Makefile.cbm lint-ci passes.

Closes #803.

DELETE /api/project removed the project's .db file but never told the
watcher; the zombie watch kept polling the repo and the next change
silently resurrected the deleted DB (#803). Wire the watcher into the
UI HTTP server (non-owned ref; created before the server, freed after
it) and unwatch after a successful unlink and on ENOENT (clears an
already-orphaned watch, still 404). Invalid name stays a plain 404
with no unwatch; a non-ENOENT unlink failure stays 500 and keeps the
watch. The cbm_file_exists pre-check is replaced by unlink-errno
handling, removing the TOCTOU window.

Distilled from #830, rebased over the defer_state_free refactor
(#817): the OOM hardening of the deferred-free path moves into
defer_state_free itself so BOTH unwatch and stale-root prune get it.
On pending-free realloc failure the helper no longer frees the state
immediately (a potential use-after-free against an in-flight poll_once
snapshot) — it logs watcher.unwatch.oom and returns failure, and the
callers keep the entry registered; the prune path retries on the next
poll cycle.

Carries six httpd regression tests from #830 driving the live UI
server with a real watcher wired in.

Closes #803.

Co-authored-by: Dustin Persek <dustin.persek@protonmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
@DeusData DeusData enabled auto-merge July 4, 2026 09:56
@DeusData DeusData added bug Something isn't working ux/behavior Display bugs, docs, adoption UX stability/performance Server crashes, OOM, hangs, high CPU/memory priority/normal Standard review queue; useful PR with ordinary maintainer urgency. labels Jul 4, 2026
@DeusData DeusData merged commit e8d2832 into main Jul 4, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working priority/normal Standard review queue; useful PR with ordinary maintainer urgency. stability/performance Server crashes, OOM, hangs, high CPU/memory ux/behavior Display bugs, docs, adoption UX

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ui: DELETE /api/project leaves a zombie watch (deleted project resurrects)

1 participant