Skip to content

[Fix] Run Electron as Node through the Helper bundle on macOS so build workers leave the Dock alone - #519

Merged
juanmaguitar merged 3 commits into
trunkfrom
juanmaguitar/electron-helper-dock-icons
Sep 23, 2026
Merged

juanmaguitar merged 3 commits into
trunkfrom
juanmaguitar/electron-helper-dock-icons

Conversation

@juanmaguitar

@juanmaguitar juanmaguitar commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Why

On macOS, a Gutenberg build (full build, npm run dev, a PR apply, a trunk update) fills the Dock with black tiles titled exec, up to a dozen at a time, one per build worker, and they come and go with the build. A Core build shows none. Seen on the v1.2.0-beta.1 manual pass and reported as #518; it is the macOS sibling of the Windows console windows of #497.

Cause, pinned and verified. Every node the build runs is the app's shim, which runs the main bundle binary with ELECTRON_RUN_AS_NODE=1. When such a process sets process.title, libuv's darwin implementation checks it in with LaunchServices, which registers it under the Info.plist of the bundle the binary lives in. The main bundle has no LSUIElement, so each one becomes a Foreground app with a Dock tile. npm sets its own title and Gutenberg's build workers set theirs to exec; Core's grunt sets none, which is why only Gutenberg ever showed it. Without a title assignment nothing registers, spawning children included, so it is the title, not the spawn.

What changes

nodeExecPath() in src/node-shims.cjs is now the one answer to which binary the shims exec and the runners are spawned on. On macOS it is the … Helper.app binary beside process.execPath, derived from the executable's own name so it holds for the packaged bundle and for npm start's Electron.app alike; when no Helper is there it falls back to process.execPath, which is what the app did before. On Windows and Linux it returns process.execPath and never touches the filesystem: #497 and #512 fixed their symptom another way.

The Helper is the same Electron (identical process.versions, node 24.18.0 and electron 43.2.0), signed by the same team with the same entitlements (allow-jit, allow-unsigned-executable-memory, disable-library-validation), and its Info.plist is the only one of the two that carries LSUIElement. Both ensureNodeShimDir (the node, npm, npx shims) and spawnRunner (the four runners, which are npm and set a title too) use it, and buildChildEnv gets the same path so NODE agrees with the runner it is set in. The deep-link registration keeps process.execPath on purpose: that one has to name the app.

Deliberately not in it: nothing changes on Windows or Linux, and no attempt to stop the workers setting a title, which is theirs to set.

AGENTS.md and the first invariant of .github/instructions/code-review.instructions.md now name the resolver, so a new Node spawn on process.execPath reads as a regression.

How to test this

Platforms: macOS; the change is a no-op elsewhere. Build: the Buildkite artifact for the current head, or npm start from the repository root (the dev Electron.app has a Helper of its own, so the fix applies there too). Use a Gutenberg site you do not mind rebuilding.

Starting state: a Gutenberg site past the install step.

  1. Keep the Dock in view. Click Run full build (or Start build watch). Expected: no new tile appears in the Dock during the transpile and bundle phases, where trunk shows up to a dozen exec tiles. The build itself finishes as before, same time, same output.
  2. In a terminal while the build runs: lsappinfo list | grep -c '"exec"'. Expected: 0.
  3. A Core site, Run full build. Expected: unchanged, no tiles before or after.
  4. Open pull request on any site, or a wpct:// link from the browser. Expected: unchanged, the deep-link registration still names the app.

What must not have happened: a build that fails to start (the fallback path), a build slower than trunk (the Helper lacks an entitlement), a Windows or Linux build that behaves differently in any way.

Mechanism, proven without the UI, on this head, with the dev Electron.app: both shim variants written by nodeShim, each run as node -e "process.title='exec'", and the child looked up in lsappinfo:

shim execs LaunchServices says
main binary (trunk) type="Foreground" — a Dock tile
Helper (this branch) type="UIElement" — no tile

The same two commands on the installed v1.2.0-beta.1 app gave the same two answers and are in the issue.

Done on macOS 27 (M5 Pro), on 5febca8, on a built Gutenberg site (gutenberg-toma-2, on a work-item branch) with the Dock in view: a Gutenberg build ran with no exec tile appearing, and lsappinfo list | grep -c '"exec"' during the build answered 0, where the same command on v1.2.0-beta.1 answered eleven. The build finished as before. The same check on a Core site also answered 0, unchanged.

Tests: tests/unit/node-shims.test.cjs, five new cases: the packaged bundle and the dev Electron.app each resolve to their Helper, a missing Helper falls back to the main binary and probes exactly one path, Windows and Linux return the binary untouched without touching the filesystem, and a Helper path with its four spaces survives into a working shim. Lint clean, 1646 unit tests pass, all 33 journeys pass.

Risks and limitations

  • Windows and Linux are not covered by the pass, by design: the change returns before touching anything there, and the unit tests pin that it never probes their filesystem.
  • A macOS bundle with no Helper beside its binary gets the old behaviour, tiles and all. It says so once in the app log (shims: no Helper bundle found beside …), so a repackaging or a future Electron layout that misses the derivation does not read as the fix never having shipped. Builds still run either way.
  • Gutenberg workers still set process.title; the fix changes what LaunchServices does with that, not the workers.

Related

Fixes #518. Sibling of #497 (Windows), fixed by #512. Part of #251, v1.2.0-beta.2.


Design decisions and alternatives considered
  • LSUIElement on the main bundle. Rejected: the app itself must be a Foreground app with a Dock tile and a menu bar.
  • Stripping process.title from the children, through the preload the shims already carry. Rejected: it would be the app rewriting a property of every tool it runs, for a cosmetic effect, and would break any tool that reads its own title back.
  • A Helper resolved by product name rather than by the executable's own name. Rejected: npm start runs Electron.app, whose Helper is Electron Helper; a product-name rule would leave development on the old behaviour and fix the bug only where nobody sees it.
  • Only the shims, not the runners. Rejected: the runners are npm, and npm sets a title; the tile for npm run dev was one of those seen.
Review outcome (required — see AGENTS.md)

2 [fix here] · 1 [follow-up] on c958c96 — both fix-here fixed and the follow-up taken in 5febca8.

  • Fixed: the resolver was tested but its wiring was not, and the wiring is where the bug lived. Reverting spawnRunner to process.execPath, or dropping execPath from buildChildEnv so NODE named a different binary than the runner, both passed the suite. The four runner tests now assert that buildChildEnv is told the binary the runner was spawned on, and assertShimsPreloadCompat that the node shim execs what nodeExecPath() resolves. Checked by mutation: the first regression fails one test, the second fails four.

  • Fixed: the new invariant in code-review.instructions.md said any Node spawn on process.execPath is a regression, which the Windows spawn patch and the wpct:// registration both contradicted. It now names those two as the uses that stay, and why.

  • Taken: the silent fallback now logs one line from ensureNodeShimDir when a macOS bundle has no Helper beside its binary, gated to executables inside a .app so a bare node binary under the unit suite stays quiet.

  • Style: the resolver's comment said the Helper "registers nothing"; lsappinfo shows it registers as a UIElement, and the comment now says so.

  • Judged and found sound, not findings: the Helper is the same Electron with the same entitlements (allow-jit, allow-unsigned-executable-memory, disable-library-validation, checked with codesign on the installed app); crashpad is not started under ELECTRON_RUN_AS_NODE; the derivation reads the executable's own name so both real layouts resolve; the change returns before touching fs or path off macOS. One thing the reviewer asked to be watched on the manual pass rather than asserted: whether the Helper's bundle id changes TCC attribution for a site under ~/Documents or ~/Desktop.

  • Review: completed — /self-review (fresh Explore subagent, judgement pass; lint, unit and journeys inline); reviewed head c958c96 / base 6cb471c (trunk); evidence: findings above; outcome: 2 fixed, 1 taken.

  • Since review: c958c96 → 5febca8, the fixes above; then 5febca8 → 6af5710, one line: CI's Windows unit run failed the four darwin cases of nodeExecPath, because the platform is injected and path.join wrote backslashes into a macOS bundle path on that host; the derivation now uses path.posix, which is what path already is on macOS. Lint clean, 1646 unit tests pass, all 33 journeys pass on 5febca8 (the last commit touches only the darwin branch of the resolver and its tests).

  • CodeRabbit: requested on 5febca8 by comment.

Screenshots or recording

The change is in what is absent: the Dock during a Gutenberg build. The lsappinfo table and the pass above are the evidence.

🤖 Generated with Claude Code

juanmaguitar and others added 2 commits September 23, 2026 15:03
…d workers leave the Dock alone

On macOS a Gutenberg build filled the Dock with "exec" tiles, one per
build worker, and npm's own run put one up too. Every one of them is
Electron running as Node through the app's shims, and libuv's darwin
process.title implementation checks the process in with LaunchServices,
which registers it under the Info.plist of the bundle the binary lives
in. The main bundle has no LSUIElement, so each became a Foreground app.
Core's grunt sets no title, which is why only Gutenberg showed it.

nodeExecPath() in src/node-shims.cjs is now the one answer to which
binary the shims and the runner spawns exec: on macOS the Helper bundle
beside the main binary, whose Info.plist carries LSUIElement and which is
the same Electron, falling back to process.execPath when no Helper is
there; process.execPath untouched everywhere else, with no filesystem
probe. Both ensureNodeShimDir and spawnRunner in main.js use it, so the
runners stop registering too. The deep-link registration keeps
process.execPath on purpose: that one must name the app.

Fixes #518.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…acOS has no Helper

From the self-review of c958c96. The resolver was tested but not its
wiring, which is where #518 lived: spawning the runner on
process.execPath again, or dropping execPath from buildChildEnv so NODE
named a different binary than the runner, both passed the suite. The
four runner tests now assert that buildChildEnv is told the binary the
runner was spawned on, and the shim test that the node shim execs what
nodeExecPath resolves; each regression fails four tests or one.

The review instructions' new invariant is now literally true: it names
the two uses of process.execPath that stay, the Windows spawn patch
(same path there by construction) and the wpct:// registration (not a
spawn). A macOS bundle with no Helper beside its binary logs one line
instead of silently looking like the fix never shipped; a bare node
binary, as under the unit suite, has nothing to look for and stays
quiet. The resolver's comment now says the Helper registers as a
UIElement, which is what lsappinfo shows, rather than "nothing".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: WordPress/contributor-toolkit/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: ac722f65-65de-4a86-bcfd-cef05c1d86de

📥 Commits

Reviewing files that changed from the base of the PR and between 6cb471c and 5febca8.

📒 Files selected for processing (6)
  • .github/instructions/code-review.instructions.md
  • AGENTS.md
  • src/main.js
  • src/node-shims.cjs
  • tests/unit/ipc-wiring.test.cjs
  • tests/unit/node-shims.test.cjs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change adds nodeExecPath() to resolve Electron’s Node executable. On macOS, it returns the Helper executable when present and otherwise returns the original executable. Windows and Linux retain the original path. Shim generation and runner spawning use the resolved path, and the runner environment receives the same path. Tests cover resolution, fallback, shim quoting, and runner-path consistency.

Assessment against linked issues

Objective Addressed Explanation
Use the macOS Helper executable for Electron-as-Node shims and runner processes when it exists, with a fallback to process.execPath [#518] ✅

Assessment against linked issues: Out-of-scope changes

Merge Risk: ⚪ Minimal · up to 5febc

The macOS Helper selection and runner wiring are covered without an identified regression. The change is ready to merge after normal checks.

🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description follows the required template. It explains the cause, implementation, testing steps and results, risks, related issue, design decisions, review outcome, and the absence of a screenshot…
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

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.

@juanmaguitar
juanmaguitar marked this pull request as ready for review September 23, 2026 15:22
@juanmaguitar

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…tests hold on Windows

CI on 5febca8: the four darwin cases of nodeExecPath failed on the
Windows runner. The platform is injected, so that branch runs there
too, and path.join wrote backslashes into a macOS bundle path. A bundle
path is POSIX by definition; path.posix makes the derivation the same
on every host. No change on macOS, where path is path.posix already,
and the non-darwin branch still returns before any path work.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@juanmaguitar
juanmaguitar merged commit 229d3eb into trunk Sep 23, 2026
8 checks passed
@juanmaguitar
juanmaguitar deleted the juanmaguitar/electron-helper-dock-icons branch September 23, 2026 15:52
juanmaguitar added a commit that referenced this pull request Sep 24, 2026
…528)

## Why

On macOS, "Apply and rebuild" fails on Gutenberg pull requests based on
trunk from before 17 April 2026, such as
[WordPress/gutenberg#76292](WordPress/gutenberg#76292).
The screen says only `Build failed: Command failed: npm run --if-present
--workspaces --silent build`, exit 1. Without `--silent`,
`@wordpress/theme`'s `build:tokens` fails:

```
[ERROR:electron/shell/app/node_main.cc:153] Node.js environment variables are disabled because this process is invoked by other apps.
✗  [config] Cannot find module '.../packages/theme/bin/terrazzo-plugin-mode-overrides/index'
```

Reported in #525.

## What changes

Root cause: the Gutenberg script runs `cross-env
NODE_OPTIONS=--import=esbuild-esm-loader/register tz build`. `tz` starts
through our bash `node` shim, and on macOS a signed Electron removes
`NODE_OPTIONS` from any process whose parent is not part of the app, so
the `--import` never arrives. It is not a regression from #519: the main
binary fails the same way as the Helper, and the same build passes on
the system `node`.

The macOS `node`/`npm`/`npx` shims now split `NODE_OPTIONS` the way Node
does (quotes, backslash escapes, no globbing) and pass the options as
arguments, after our `--require` and before the CLI and the caller's own
arguments. Then they unset the variable, so an unsigned Electron (`npm
start`, CI) does not apply every option twice and dev behaves like the
release. Unset, not emptied: a signed Electron prints a
`node_main.cc:153` warning whenever the variable exists, even empty.
Linux and Windows shims are byte-identical to before.

Deliberately not in it: only the shim's own child gets the options. What
that child starts inherits no `NODE_OPTIONS`, as before this PR
(follow-up below).

## How to test this

Platforms: **macOS** only; the change is macOS only. Build: the
Buildkite artifact for the current head.

**Starting state:** a Gutenberg site on trunk, no work item linked, no
pull request checked out. A copy you can throw away is best: the build
rewrites `packages/theme/tokens/color.json`.

1. In **Check out a pull request**, enter `76292`, check it out and let
the install finish.
2. Click **Apply and rebuild**. Expected: the build finishes. On beta.2
it fails with the message above.
3. Unlink to go back to trunk and rebuild. Expected: the trunk build
finishes as before.

**What must not have happened:** no `node_main.cc:153 Node.js
environment variables are disabled` lines in the terminal; no Dock tiles
while the build runs (#518).

If the root build then fails on `routes/dashboard/package.json`, that is
a different bug (a trunk-only route left behind with only
`node_modules`, same with the system `node`); delete `routes/dashboard/`
on the test site and rebuild.

I reproduced the failure and checked the fix on a copy of a real site on
the PR 76292 commit, running the app's exact command, `npm run
--if-present --workspaces --silent build`:
- the installed beta.2 shims: exit 1;
- shims generated from this branch, with the same Helper: exit 0;
- the system `node`: exit 0.

The new tests in `tests/unit/node-shims.test.cjs` run the real shim
under `/bin/bash` (3.2, as macOS ships it) and fail on the old code.

## Risks and limitations

- An option that Node accepts only in `NODE_OPTIONS` but not as a flag:
none found. Command-line-only flags are now accepted, which is more
lenient, not broken.
- Malformed `NODE_OPTIONS` (unterminated quote) is passed through as
text where Node would refuse it.
- Self-review (fresh context): no bugs; 2 fix-here findings applied (the
variable cleared for the call, `/bin/bash` in the tests) and the wording
corrected. 1 follow-up, the grandchild gap above.
- The first version of that fix emptied the variable instead of
unsetting it, and the signed Buildkite build then printed the warning
for every process. The unit tests use a stand-in binary, and CI never
runs a signed Electron, so only the test's expectation guards this now
(it asserts the variable arrives unset).
- Checked on the copy with the installed signed Helper: the workspace
build and the full `npm run build` exit 0 with no warnings.

## Related

Fixes #525. Found while testing #524.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

macOS: a Gutenberg build fills the Dock with "exec" icons, one per Electron-as-Node worker that sets its process title

1 participant