Skip to content

fix(core): defer afterDelete and afterUnpublish hooks so Workers doesn't cancel them - #1588

Merged
ascorbic merged 3 commits into
emdash-cms:mainfrom
Emdash-Bug-Testing:fix/afterdelete-hooks-canceled-on-workers
Jul 21, 2026
Merged

ascorbic merged 3 commits into
emdash-cms:mainfrom
Emdash-Bug-Testing:fix/afterdelete-hooks-canceled-on-workers

Conversation

@marcusbellamyshaw-cell

Copy link
Copy Markdown
Contributor

What does this PR do?

runAfterDeleteHooks and runAfterUnpublishHooks dispatch plugin hooks fire-and-forget (a bare promise with .catch()), unlike runAfterSaveHooks and runAfterPublishHooks, which already wrap their dispatch in after().

On Cloudflare Workers a promise that isn't handed to the host's lifetime extender (waitUntil, which after() wraps) is canceled the moment the HTTP response is returned. So any real work a plugin does in content:afterDelete / content:afterUnpublish — deleting uploaded files from R2, removing search-index entries, plugin-storage writes — gets killed mid-flight. In practice a half-completed plugin-storage write during afterDelete could wedge the storage backend for the isolate and hang every subsequent authenticated admin request (observed as the admin "Move to trash" action freezing the content list).

The fix wraps both dispatchers in after(), identical to the existing afterSave / afterPublish handling — trusted hook in a try/catch, sandboxed plugins collected into tasks and awaited via Promise.allSettled.

Closes #

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes (0 diagnostics)
  • pnpm test passes (full core suite: 4071 passed; the only failures are pre-existing Windows-env issues — file-URL/tar/file-SQLite — that fail identically on a clean main)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (n/a — no UI strings)
  • I have added a changeset
  • New features link to an approved Discussion (n/a — bug fix)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (Claude Code)

Screenshots / test output

New test tests/integration/runtime/after-hooks-deferred.test.ts drives a real soft-delete and unpublish through EmDashRuntime and asserts the hook work is scheduled via after() rather than run inline-and-abandoned. It fails on stock main (the handler runs inline) and passes with this change.

 Test Files  1 passed (1)
      Tests  2 passed (2)

@changeset-bot

changeset-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6633f93

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/admin@1588

@emdash-cms/auth

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth@1588

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth-atproto@1588

@emdash-cms/blocks

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/blocks@1588

@emdash-cms/cloudflare

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/cloudflare@1588

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/contentful-to-portable-text@1588

emdash

npm i https://pkg.pr.new/emdash-cms/emdash@1588

create-emdash

npm i https://pkg.pr.new/emdash-cms/emdash/create-emdash@1588

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/gutenberg-to-portable-text@1588

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-cli@1588

@emdash-cms/plugin-types

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-types@1588

@emdash-cms/registry-client

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-client@1588

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-lexicons@1588

@emdash-cms/registry-verification

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-verification@1588

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/sandbox-workerd@1588

@emdash-cms/x402

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/x402@1588

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-ai-moderation@1588

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-atproto@1588

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-audit-log@1588

@emdash-cms/plugin-color

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-color@1588

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-embeds@1588

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-field-kit@1588

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-forms@1588

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-webhook-notifier@1588

commit: 6633f93

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jun 24, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the right fix for the right problem. afterDelete and afterUnpublish hook dispatch was the only remaining lifecycle cleanup path that dispatched on a bare promise, so on Cloudflare Workers it could be canceled as soon as the HTTP response went out. Wrapping both in after() matches the existing afterSave / afterPublish pattern exactly, which is the documented way to defer bookkeeping past the response and hand the lifetime to waitUntil.

I read the full diff, the after() implementation, the existing runAfterSaveHooks / runAfterPublishHooks implementations, and the new test. The code changes mirror the established pattern closely: trusted hook awaited with try/catch, sandboxed plugins collected into tasks and awaited via Promise.allSettled, and the entry-point comments updated to reflect deferred execution. The test directly verifies the scheduling contract by mocking after() and asserting the hook handler is not run inline but is flushed from the deferred queue, and it fails on the pre-fix behavior.

No blocking issues, no AGENTS.md convention violations, no SQL/Tailwind/Lingui concerns, and the changeset is present and correctly names the emdash package. LGTM.

@ascorbic
ascorbic marked this pull request as ready for review July 1, 2026 15:00
Copilot AI review requested due to automatic review settings July 1, 2026 15:00
@github-actions github-actions Bot added the review/approved Approved; no new commits since label Jul 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a Cloudflare Workers runtime-lifetime bug where content:afterDelete and content:afterUnpublish plugin hooks could be canceled when the HTTP response completes, by deferring those hook dispatchers through the existing after() mechanism (which integrates with Workers waitUntil when available).

Changes:

  • Wrap runAfterDeleteHooks and runAfterUnpublishHooks in after() and align their trusted/sandboxed hook execution structure with the existing afterSave/afterPublish implementations.
  • Add an integration test that mocks after() to assert delete/unpublish hook work is scheduled (deferred) rather than executed inline.
  • Add a patch changeset documenting the Workers-specific fix and user impact.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
packages/core/src/emdash-runtime.ts Defers after-delete and after-unpublish hook dispatch via after() and awaits sandboxed hook tasks with Promise.allSettled inside the deferred work.
packages/core/tests/integration/runtime/after-hooks-deferred.test.ts Adds coverage that pins the “deferred via after()” contract for delete and unpublish lifecycle hooks.
.changeset/defer-delete-unpublish-hooks.md Documents the patch-level fix for dropped lifecycle hooks on Cloudflare Workers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review of #1588. This is the same fix I previously approved: runAfterDeleteHooks and runAfterUnpublishHooks now wrap their hook dispatch in after(), mirroring the existing runAfterSaveHooks / runAfterPublishHooks pattern so Cloudflare Workers’ waitUntil can keep the work alive past the HTTP response.

I re-read the diff, the full emdash-runtime.ts implementation around all four lifecycle dispatchers, the after() implementation, and the new after-hooks-deferred.test.ts. The changes remain consistent with the established deferred pattern: trusted hooks are awaited with try/catch, sandboxed plugins are collected into tasks and awaited via Promise.allSettled, and the entry-point comments now correctly say "deferred past the response via after()". I also checked that no other bare-promise hook dispatches remain in emdash-runtime.ts.

The test directly verifies the scheduling contract by mocking after() and asserting the handlers are not run inline but are flushed from the deferred queue. The changeset is present and names the emdash package.

No blocking issues and no AGENTS.md violations. The prior approval stands; LGTM.

@ascorbic

ascorbic commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

@marcusbellamyshaw-cell can you resolve the conflicts

…n't cancel them

runAfterDeleteHooks and runAfterUnpublishHooks dispatched plugin hooks
fire-and-forget. On Cloudflare Workers a promise not handed to waitUntil is
canceled when the response returns, so plugin cleanup work (R2 deletes,
search-index removal) is killed mid-flight -- and a half-completed
plugin-storage write can wedge the isolate and hang later admin requests.

Wrap both dispatchers in after(), exactly as runAfterSaveHooks and
runAfterPublishHooks already do.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@marcusbellamyshaw-cell
marcusbellamyshaw-cell force-pushed the fix/afterdelete-hooks-canceled-on-workers branch from 7259317 to 4208c78 Compare July 2, 2026 02:32
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review size/M and removed size/L review/approved Approved; no new commits since needs-rebase labels Jul 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.

If you're still working on this, please push an update or leave a comment.

@github-actions github-actions Bot added stale and removed stale labels Jul 16, 2026
@marcusbellamyshaw-cell

Copy link
Copy Markdown
Contributor Author

Bumping this — all checks are green (last run 2026-07-02), just needs a maintainer look before the stale-bot auto-closes it. Happy to rebase/update if anything's changed upstream.

@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jul 21, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review of #1588. This is the same narrowly-scoped bug fix I previously approved, and the changes remain sound.

Approach: Deferring content:afterDelete hook dispatch through after() so it survives the Cloudflare Workers response boundary is the right fix for the reported symptom (plugin cleanup work killed mid-flight). The new implementation of runAfterDeleteHooks mirrors the existing runAfterSaveHooks shape: trusted hooks awaited with try/catch, sandboxed plugins collected into tasks and awaited via Promise.allSettled, all wrapped in after().

runAfterUnpublishHooks already delegates to the shared runDeferredContentHook() helper, which is already wrapped in after(); the PR just corrects the stale "fire-and-forget" comment above the call site in handleContentUnpublish.

What I re-checked:

  • Full packages/core/src/emdash-runtime.ts around all four lifecycle dispatchers and the after() implementation.
  • New integration test packages/core/tests/integration/runtime/after-hooks-deferred.test.ts — it directly verifies scheduling through after() rather than just asserting the handler eventually ran.
  • Changeset (emdash patch, present, observable wording).
  • AGENTS.md conventions: no new logged-out queries, no UI strings, no SQL interpolation, no new indexes or migrations, comments are explanatory rather than reviewer-facing.

Still open (non-blocking): media:afterUpload in handleMediaCreate is still dispatched as a bare promise with .catch(), so plugin work in that hook is subject to the same Workers cancellation. That’s pre-existing and outside this PR’s scope, but worth a follow-up to keep the lifecycle-hook behavior consistent.

No blocking issues. Prior approval stands; LGTM with the one suggestion noted below.


Findings

  • [suggestion] packages/core/src/emdash-runtime.ts:3139-3141

    media:afterUpload is still dispatched as a bare promise with .catch(), so any real work a plugin does there can be canceled the moment Workers returns the response. It has the same lifecycle shape as content:afterDelete / content:afterUnpublish and should be deferred through after() for consistency.

    		if (result.success && this.hooks.hasHooks("media:afterUpload")) {
    			const item = result.data.item;
    			const mediaItem: MediaItem = {
    				id: item.id,
    				filename: item.filename,
    				mimeType: item.mimeType,
    				size: item.size,
    				url: `/media/${item.id}/${item.filename}`,
    				createdAt: item.createdAt,
    			};
    			after(async () => {
    				try {
    					await this.hooks.runMediaAfterUpload(mediaItem);
    				} catch (err) {
    					console.error("EmDash afterUpload hook error:", err);
    				}
    			});
    		}
    

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 21, 2026
github-actions Bot pushed a commit that referenced this pull request Jul 21, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 21, 2026
@ascorbic
ascorbic merged commit 15d5a45 into emdash-cms:main Jul 21, 2026
44 checks passed
@emdashbot emdashbot Bot mentioned this pull request Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants