[Fix] Stop a Gutenberg build from spawning processes without bound (#275) - #283
Open
juanmaguitar wants to merge 2 commits into
Open
Conversation
The node/npm shims the app puts on PATH are Electron running as Node, and Electron keeps process.versions.electron set in that mode. yargs reads exactly that to decide where a command's arguments begin, so every yargs-based tool started through a shim treats its own executable path as the first argument it was given. For a task runner that is a command to run — itself, with no arguments — and the copy it starts does the same, without end. Each shim now preloads a small module that hides the Electron version from the process it starts, as an explicit --require argument rather than through NODE_OPTIONS: we are the ones invoking these processes, and an argument cannot fail to be inherited. Only versions.electron is hidden. Hiding versions.chrome alongside it broke Gutenberg's bundling step, and it answers a different question — what to compile for, not who is running the compiler. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Pin the wiring, not just the formatter: blanking the preload path at main.js's six call sites left the whole suite green, so ipc-wiring now reads the shims ensureNodeShimDir actually wrote. - Drop nodeCompatPath from the buildChildEnv call. It accepts no such key, so it was discarded silently and read as though descendants were covered by the environment. - Skip the two Electron-only tests explicitly instead of returning early, so a runtime-specific assertion cannot pass by asserting nothing. - Report a failed preload copy through the app's log rather than stderr, which a packaged app has nobody to read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Building a Gutenberg site never finished. The wizard sat on "Run build" forever while the app spawned processes without bound — over 1,300 in a few minutes, until the machine was unusable and the app had to be killed. Nothing was ever written to
build/.The same checkout builds fine outside the app, so this was never a Gutenberg problem. Core sites never hit it either: their build is Grunt, which does not reach the code path below.
Fixes #275.
What changes
Root cause: the
node/npm/npxshims this app puts onPATHare Electron running underELECTRON_RUN_AS_NODE, and Electron keepsprocess.versions.electronset in that mode.yargsreads exactly that to decide where a command's arguments begin — "electron set,defaultAppunset" reads as a packaged Electron app whose argv carries no script path — so every yargs-based tool started through a shim treats its own executable path as the first argument it was given.For a task runner that extra argument is a command to run: itself, with no arguments. The copy it starts does the same, forever. Each link spawns exactly one child, which is why the process tree is an unbounded chain rather than a fan-out.
Argument shifting is the general failure here; the runaway processes are only its loudest form. Other tools reached through the shim have been misreading their arguments quietly.
The fix: each shim now
--requires a small module that hides the Electron version from the process it starts. Two decisions worth naming, because both were arrived at by measurement rather than by reasoning:NODE_OPTIONS.win-spawn-patch.jsusesNODE_OPTIONSand is untouched — it is right there, because it must reach a process several levels down that we never invoke ourselves. Here we are the one invoking the process, andNODE_OPTIONSdid not survive every chain reliably in testing. An argument cannot fail to be inherited, and it confines the patch to processes that actually go through the shim.versions.electronis hidden, notversions.chrome. Hiding both was the plan; it broke Gutenberg's bundling step outright. Build tooling readschrometo decide what it is compiling for, which is a question about the output, not about who is running the compiler.Deliberately not in this PR:
versions.v8still carries its-electronsuffix (tools parse it as a version number), and the shim directory is still a predictable path underos.tmpdir().How to test this
Platforms: any for the suite. The manual path below was driven on macOS; Windows is covered by unit tests only — see Risks.
Starting state: a site whose contribution target is Gutenberg, cloned and with dependencies installed, not yet built.
ps -A | grep -c concurrentlyon macOS/Linux.build/modules/block-libraryexists in the checkout.What must not have happened: the process count must stay flat — on the broken code it climbs without stopping and never recovers. The build must also finish: a run that merely stops spawning but hangs is the earlier, subtler half of this bug.
To watch the old behaviour fail to reproduce, the trigger needs no Gutenberg at all: a throwaway package whose only script is
concurrently "npm run a" "npm run b", withconcurrently@9, run through the app, reached ~50 processes in three seconds before this change and finishes in one after it.Which test covers it, and yes, I checked it fails on the old code:
test/ipc-wiring.test.cjs→ "npm:run-script" now asserts the shimsensureNodeShimDirreally wrote carry the preload. Blanking the preload path at all sixmain.jscall sites — the exact way this regresses — left the entire suite green before that assertion existed, and now fails it. Undernpm run test:electron,test/electron-node-compat.test.cjs→ "without the preload the child still looks like Electron" pins the runtime condition itself.Risks and limitations
Review outcome: 4
[fix here]· 3[follow-up]— all 4 fixed..cmd/.batcontent is asserted directly (quoting,setordering,%*last, backslashes kept), and the review checked it line by line, but no one ran a Gutenberg build on a real Windows machine. Buildkite has a signed artifact for this branch if someone wants to.child_process.forkinheritsexecArgv, so worker pools are covered. A descendant started with an explicitspawn(process.execPath, …), or aworker_threadsworker, inheritsELECTRON_RUN_AS_NODEand seesversions.electronagain. No such case is known to be reachable today;NODE_OPTIONSwould cover them, at the cost of the reliability problem that ruled it out.os.tmpdir(). This PR adds one more file to a directory that already holds executable shims, so it extends an existing exposure rather than introducing one — but it is worth closing withmkdtempSyncfor all of them.Related
Fixes #275. Part of #251.
Design decisions and alternatives considered
Preferring a real system Node over the shim. Verified to work — the same Gutenberg build completes in 30s through the app's own spawn path once
nodeonPATHis a real Node. Rejected because it does nothing for a contributor with no Node installed, which is precisely the case the shims exist for: the app's promise is zero prerequisites.Neutralising only yargs' branch (setting
process.defaultApp, the other half of its condition). Narrower, and it would have fixed the runaway. Rejected because it leaves every other library that asks "am I inside Electron?" answering wrongly, which is the general bug.NODE_OPTIONSfor the compat preload. Implemented first, then abandoned: measured, it did not survive every chain from the app down to a task runner's children, while the same preload passed as an argument did.win-spawn-patch.jskeeps using it because it has no alternative.Where the shim content lives. Moved out of
main.jsintosrc/node-shims.cjsas pure string building, so the property that matters — every shim, on every platform, carries the preload — is a unit test rather than something only a real Windows machine could show.Review outcome (required — see AGENTS.md)
4
[fix here]· 3[follow-up]— all 4[fix here]fixed. Run per.github/instructions/code-review.instructions.md, with the judgement pass given to a subagent with fresh context. Deterministic layer: lint clean, 889 tests pass on both Node runtimes.Fixed:
main.jscall sites to pass no preload path and the suite stayed green on both runtimes — the bug could be fully reintroduced without a single red test. The unit tests coverednode-shims.cjs's parameters, not the decision to hand it the path. Nowipc-wiringreads the shims from disk.nodeCompatPathwas passed tobuildChildEnv, which does not accept it. Silently dropped, and it read as though descendants were covered through the environment — the exact misreading that would justify removing a--requirefrom a shim later. Argument removed.t.skip(), and the two passes no longer report identical counts.process.stderr.write, whichelectron-logdoes not hook, so a packaged app recorded nothing on the one path that decides whether builds run away — and the write itself sat outside atry. Now goes through the app's logger.Deferred, with reasons:
hideBinheuristic rather than importing it, so it pins our model of the dependency rather than the dependency. Verified faithful againstyargsas vendored today. Importing from a transitive dependency in a test is its own trap; left as is, and the comment says what it models.worker_threadsor an explicitspawn(process.execPath, …). No reachable case today; noted under Risks so the next reader does not take "an argument always survives" as covering more than it does.os.tmpdir(). Pre-existing for the shims andwin-spawn-patch.js; fixing it properly meansmkdtempSyncfor all of them, which is a change to code this PR does not otherwise touch.Implementation notes
How the root cause was isolated, since the trail is not obvious from the diff:
bash → Electron → bash → Electron, every one of them runningconcurrently— 25 copies with no arguments alongside a single correct invocation.spawnshowed the original process launching three children for two commands: its own path, then the two real ones.hideBin's Electron branch.concurrently@9, which still uses that yargs path;@10does not, which is why a first attempt to reproduce failed and briefly looked like the trigger was elsewhere.versions.chromeis the interesting negative result: hiding it removed no recursion (already gone) and broke the bundling step, and there is now a test whose only job is to stop someone widening the set back.