Skip to content

Forwarded worker event-bus emits to the main process#28429

Open
rob-ghost wants to merge 1 commit into
mainfrom
fix/worker-events-bridge
Open

Forwarded worker event-bus emits to the main process#28429
rob-ghost wants to merge 1 commit into
mainfrom
fix/worker-events-bridge

Conversation

@rob-ghost

Copy link
Copy Markdown
Contributor

Problem

State writes performed inside a Bree worker leave main-process subscribers stale. Specifically: when the update-check worker called notifications.add, the notification was correctly persisted to the database, but the main process's in-memory settings cache never refreshed. The admin notifications endpoint kept returning the pre-worker snapshot, so the alert banner did not render until Ghost was restarted.

The shared events bus is process-local. Workers and the main process each instantiate their own emitter. Events fired in one process never reach subscribers in the other. The cache freshness contract, several model-listener hooks, and any future worker that touches cached settings all silently break across this boundary.

Solution

Bridge the events bus across the worker boundary. When the module is loaded inside a worker thread, every emit is also posted across the parent port. The main process replays the message on its own emitter from inside the existing worker message handler. Subscribers receive the event identically to a local emission, so settings cache, model-listener hooks, and anything else listening continue to work without changes.

The serializer is the only piece with surface area worth thinking about. Bookshelf models are unwrapped via their toJSON method and rehydrated on the receiving side into an adapter that preserves the get, toJSON, and attributes shape listeners expect. Other objects are deep-cleaned of values that structured-clone rejects (Bookshelf attaches query callbacks and a Knex transaction handle to option objects, neither of which listeners read). EventEmitter internals are intentionally not forwarded.

If a payload can't be cloned for any reason, the bridge logs once per event name and skips the forward. The local emit always runs regardless, so worker-side behaviour is unchanged.

Added an HTTP-level test that spawns the update-check worker and asserts the admin notifications endpoint returns the worker-added notification on the next read. The test exercises the contract the admin UI depends on without observing the bridge mechanism. Bumped the test environment's per-IP login limiter so multi-login files don't trip it; the dedicated rate-limiting test reads its threshold from config so it stays accurate.

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a548f572-c3ed-4b3d-b4e6-255ffbb4704d

📥 Commits

Reviewing files that changed from the base of the PR and between f3aff4b and 0daa10a.

📒 Files selected for processing (5)
  • ghost/core/core/server/lib/common/events.js
  • ghost/core/core/server/services/jobs/job-service.js
  • ghost/core/core/shared/config/env/config.testing-mysql.json
  • ghost/core/core/shared/config/env/config.testing.json
  • ghost/core/test/e2e-api/admin/notifications.test.js
🚧 Files skipped from review as they are similar to previous changes (5)
  • ghost/core/core/shared/config/env/config.testing.json
  • ghost/core/core/shared/config/env/config.testing-mysql.json
  • ghost/core/core/server/services/jobs/job-service.js
  • ghost/core/core/server/lib/common/events.js
  • ghost/core/test/e2e-api/admin/notifications.test.js

Walkthrough

This PR implements cross-worker event forwarding: EventRegistry now extends EventEmitter, serializes event args (wrapping Bookshelf-like models and cleaning non-cloneable values), and forwards non-local emits over parentPort. The main process consumes IPC messages with eventRegistryInstance.replayForwarded(message), deserializes args, and re-emits without re-forwarding. job-service workerMessageHandler now defers to replayForwarded for forwarded events. E2E tests and testing env configs were updated to exercise worker-persisted notifications surfacing in the admin API.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • allouis
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Forwarded worker event-bus emits to the main process' directly and clearly describes the main change: bridging events from worker threads to the main process.
Description check ✅ Passed The description comprehensively explains the problem (state writes in workers not reaching main-process subscribers), solution (forwarding events via parent port with serialization), and testing approach, all directly related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/worker-events-bridge

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
ghost/core/test/e2e-api/admin/notifications.test.js (1)

182-186: Await mockUpdateServer shutdown in afterEach to avoid lingering handles
afterEach currently calls mockUpdateServer.close() without waiting (around line 184); use the close callback/Promise so teardown completes before the next test proceeds.

Proposed fix
         afterEach(async function () {
             if (mockUpdateServer) {
-                mockUpdateServer.close();
+                await new Promise(resolve => mockUpdateServer.close(resolve));
                 mockUpdateServer = null;
             }
             await jobService.removeJob(UPDATE_CHECK_JOB).catch(() => {});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/test/e2e-api/admin/notifications.test.js` around lines 182 - 186,
The afterEach teardown calls mockUpdateServer.close() without awaiting its
completion, which can leave the server handle open; modify the afterEach that
references mockUpdateServer to await shutdown by either wrapping
mockUpdateServer.close in a Promise (resolving in the close callback) or using
mockUpdateServer.close()'s returned Promise if available, then set
mockUpdateServer = null only after the awaited close completes so teardown fully
finishes before the next test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ghost/core/core/server/lib/common/events.js`:
- Around line 95-106: The current guard treats any object with a __model key as
a bridge envelope and can accidentally rewrite normal payloads; tighten the
match in the envelope-detection block so it only converts true bridge envelopes
by checking for a specific marker or expected shape: update the condition around
__model to require either a bridge marker (e.g. arg.__model.__isGhostBridge ===
true) or a proven model shape (e.g.
Object.prototype.hasOwnProperty.call(arg.__model, 'attributes') && typeof
arg.__model.attributes === 'object' or
Object.prototype.hasOwnProperty.call(arg.__model, 'id')); change the condition
in the block that returns get/toJSON/attributes (the code handling arg.__model)
to use this stricter check so ordinary payloads with a __model key are left
untouched.

---

Nitpick comments:
In `@ghost/core/test/e2e-api/admin/notifications.test.js`:
- Around line 182-186: The afterEach teardown calls mockUpdateServer.close()
without awaiting its completion, which can leave the server handle open; modify
the afterEach that references mockUpdateServer to await shutdown by either
wrapping mockUpdateServer.close in a Promise (resolving in the close callback)
or using mockUpdateServer.close()'s returned Promise if available, then set
mockUpdateServer = null only after the awaited close completes so teardown fully
finishes before the next test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d9802678-dee4-4d3a-a942-8ca9014066a5

📥 Commits

Reviewing files that changed from the base of the PR and between 798d373 and f3aff4b.

📒 Files selected for processing (5)
  • ghost/core/core/server/lib/common/events.js
  • ghost/core/core/server/services/jobs/job-service.js
  • ghost/core/core/shared/config/env/config.testing-mysql.json
  • ghost/core/core/shared/config/env/config.testing.json
  • ghost/core/test/e2e-api/admin/notifications.test.js

Comment thread ghost/core/core/server/lib/common/events.js Outdated
The shared events bus was process-local. Listeners registered in the main process (settingsCache, the notifications cleanup hook, etc.) never reacted to events emitted from inside a Bree worker, so state writes performed by workers left main-process subscribers stale until restart. This bit the update-check worker: notifications were persisted to the DB but the in-process settingsCache didn't refresh, so the admin notifications endpoint returned an empty list and no banner rendered.

Bridged the gap inside the events module itself. When loaded in a worker thread, every emit is also postMessaged across the parent port. The main process replays the message on its own emitter via a hook installed in the worker message handler. Existing subscribers receive the event identical to a local emission.

Payloads survive structured-clone via a small serializer: Bookshelf models are unwrapped via toJSON and rehydrated into an adapter that preserves the .get/.toJSON/.attributes shape listeners depend on; other objects are deep-cleaned of values structured-clone rejects (Bookshelf attaches query callbacks and a Knex transaction handle that listeners never read). EventEmitter internals (newListener, removeListener, error) are intentionally not forwarded. Unserializable payloads warn once per event name and skip the forward; the local emit always runs.

Added an HTTP-level e2e test that spawns the update-check worker and asserts GET /ghost/api/admin/notifications/ returns the worker-added notification. The test exercises the contract the admin UI depends on without observing the bridge mechanism. Verified the test fails when the bridge is disabled. Bumped the test environment's global brute-force freeRetries (was 4, now 99) so worker-spawning tests don't trip the per-IP login limiter; the dedicated rate-limiting test reads its threshold from config so it stays correct.
@rob-ghost rob-ghost force-pushed the fix/worker-events-bridge branch from f3aff4b to 0daa10a Compare June 8, 2026 22:16
@rob-ghost rob-ghost marked this pull request as draft June 8, 2026 23:06
@rob-ghost

Copy link
Copy Markdown
Contributor Author

Marking as draft before this very well might start showing upgrade notifications to Ghost(Pro) customers when it was silently failing before. We want to see if we can add the ability to subscribe / unsubscribe to specific "channels" as a hoster so you can pick and choose which notifications go to your customers before we merge this.

@rob-ghost rob-ghost marked this pull request as ready for review June 9, 2026 00:21
@rob-ghost

Copy link
Copy Markdown
Contributor Author

Release notification banner concern was over-stated, we will fix, but this won't make anything worse.

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