Repository navigation
Fix startup crash when the OS can't look up the current user's shell - #8496
Conversation
oclif's Config.load() populates config.shell by reading `process.env.SHELL ?? os.userInfo().shell`. Node's os.userInfo() throws a SystemError whenever the OS passwd lookup fails, and oclif's call is unguarded, so the CLI dies before it can run any command. Override _shell() on ShopifyConfig to fall back to oclif's own 'unknown' sentinel. The happy path is `return super._shell()`, so healthy environments are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Assisted-By: devx/fe71f4bc-5528-4d43-bb7c-41bc10b50658
| // SHELL short-circuits oclif's `process.env.SHELL ?? userInfo()`, so it has to be unset for the | ||
| // OS lookup — the call that crashes in production — to run at all. |
There was a problem hiding this comment.
This comment seems immediately dated - the call doesn't crash in production anymore, right?
There was a problem hiding this comment.
Right, it was stale the moment it merged. Cut down to what the next reader actually needs to know — why SHELL has to be unset for the test to reach the code under test:
// oclif and the guard both only reach os.userInfo() when SHELL is unset.| /** | ||
| * The SystemError Node throws from os.userInfo() when the OS can't resolve the current user. | ||
| * | ||
| * @returns An error shaped like the real failure reported by affected users. |
There was a problem hiding this comment.
Again, something that should be a PR description/comment is leaking into the code itself.
There was a problem hiding this comment.
Fair — dropped that docblock. The function name carries it, and the error string in the body is self-evident. Took the same pass over the source comments; the only prose left is the two things that aren't recoverable from the code: why we set SHELL at all, and when it can be deleted.
| * Note when bumping the oclif dependency: 4.11 drops `_shell` from `Config` and moves the logic into a | ||
| * module-level `getShell()`, so this override stops compiling. The call is still unguarded upstream, so | ||
| * reinstate the guard at whatever seam replaces it rather than dropping it. |
There was a problem hiding this comment.
This seems like a problem waiting to happen, I don't think a guard does it. Can we research what 4.11 does instead and compensate for both, with a note to remove this temporary fix when upgrading?
There was a problem hiding this comment.
Good call — researched it, and you were right that a guard doesn't do it.
4.11 drops _shell from Config and moves the logic to a module-level getShell() in lib/util/os.js, which a subclass can't reach. But both versions read the same expression — process.env.SHELL ?? os.userInfo().shell — so setting SHELL short-circuits the failing syscall on either one. Switched to that and dropped the override entirely.
The probe only runs when SHELL is unset, and only a throwing lookup writes the variable, so healthy machines are untouched. Verified against both copies in the tree:
BEFORE 4.11.14 getShell() -> THREW: uv_os_get_passwd returned ENOMEM
AFTER 4.8.3 root config.load() -> OK, shell=unknown
SHELL after load: "unknown"
4.11.14 getShell() -> unknown
HEALTHY SHELL=/bin/zsh -> getShell() = zsh, process.env.SHELL untouched
The removal note is now on the helper: drop it once oclif guards the call itself. There is no released version that does.
Unplanned upside: this also fixes the plugins topic, which builds its own 4.11 Config and was still crashing under the override. That was #8497 — now closed by this PR.
Replace the _shell() override with a SHELL environment variable that is set only when os.userInfo() actually fails. Both 4.8's Config._shell() and 4.11's module-level getShell() read `process.env.SHELL ?? userInfo()`, so seeding the variable short-circuits the failing syscall on either version instead of relying on a member 4.11 removes. Healthy machines are untouched: the probe only runs when SHELL is unset, and only a throwing lookup writes the variable. This also covers the `plugins` topic, which loads its own oclif 4.11 Config that a subclass override could never reach. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Assisted-By: devx/fe71f4bc-5528-4d43-bb7c-41bc10b50658
Module-local helpers in cli-kit aren't documented (see os.ts), and the tests in this file carry no comments, so drop the docblock and the inline notes. What's left is the one thing the code can't say: why SHELL is set at all, and when the workaround can go. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Assisted-By: devx/fe71f4bc-5528-4d43-bb7c-41bc10b50658
4.8.3, 4.11.14, 4.14.0 and 5.0.0 all carry the same unguarded userInfo() call, so naming two versions invites the question of whether upgrading would remove the need for this. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Assisted-By: devx/fe71f4bc-5528-4d43-bb7c-41bc10b50658
Implied by the fact we haven't upgraded. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Assisted-By: devx/fe71f4bc-5528-4d43-bb7c-41bc10b50658
Differences in type declarationsWe detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:
New type declarationsWe found no new type declarations in this PR Existing type declarationspackages/cli-kit/dist/public/node/custom-oclif-loader.d.ts@@ -17,6 +17,12 @@ export declare class ShopifyConfig extends Config {
* @param loader - The lazy command loader function.
*/
setLazyCommandLoader(loader: LazyCommandLoader): void;
+ /**
+ * Override load to protect oclif's shell detection from a failing OS user lookup.
+ *
+ * @returns A promise that resolves once the config is loaded.
+ */
+ load(): Promise<void>;
/**
* Override runCommand to use lazy loading when available.
* Instead of calling cmd.load() which triggers loading ALL commands via index.js,
|
amcaplan
left a comment
There was a problem hiding this comment.
Thanks for the updates! Approving as it makes sense, but would upgrading oclif to 4.11 (we're already on 4.8.3) solve this already?
|
Thanks! No unfortunately there is the same issue in all oclif 4.x and 5.x versions. |
WHY are these changes introduced?
Fixes the top unhandled CLI startup crash in Observe: shop/issues#75079 —
A system error occurred: uv_os_get_passwd returned ENOMEM (not enough memory). P2, 113 events / 53 affected users over 7 days. For these users the CLI does not start at all — no command runs, and no analytics row is emitted.Also fixes #8497.
WHAT is this pull request doing?
While loading, oclif resolves the active shell from
process.env.SHELL ?? os.userInfo().shell. Node'sos.userInfo()throws aSystemErrorwhenever the OS can't resolve the current user, and oclif's call is unguarded — so the process dies inside theconfig.load()we invoke ourselves atpackages/cli-kit/src/public/node/cli-launcher.ts:31.Reported instances are Windows (
%APPDATA%\npmglobal installs), whereSHELLis normally unset so the syscall runs on every invocation. Worth noting libuv hardcodes the shell field toNULLon Windows, so that call can never return anything useful there — it can only succeed uselessly or crash.This sets
SHELLbefore handing off to oclif, but only when the lookup actually fails:Why the environment variable rather than a guard. The obvious fix is to override oclif's
_shell()on ourShopifyConfigsubclass and catch the throw. That works on the pinned 4.8.3 and nothing else: 4.11 drops_shellfromConfigand moves the logic into a module-levelgetShell()(lib/util/os.js:37) that a subclass cannot reach. Both versions read the sameprocess.env.SHELL ?? os.userInfo().shell, so seeding the variable is the one lever that covers both — and it does not depend on an undocumented protected member.That also fixes #8497: the
pluginstopic resolves to@oclif/core4.11.14, whoseCommand.run()re-loads its ownConfig. A subclass override could never reach it; the environment variable does.Behaviour on healthy machines is unchanged — the probe only runs when
SHELLis unset, and only a throwing lookup writes anything. On a failing machine,config.shellbecomes'unknown', which is what oclif's own fallback branch emits, and feeds one consumer:env_shellinpackages/cli-kit/src/private/node/analytics.ts:101. Those users currently send no analytics at all, so this is strictly more data, not less.Notes for the reviewer
The tests drive the real crash path. They spy on
node:os'suserInfo— the default import is the samemodule.exportsobject oclif's compiled CJS does a property lookup on — and unsetSHELL, soconfig.load()runs oclif's actual detection. Mutation-verified three ways: dropping the call fromload(), having the helper swallow the failure without settingSHELL, and settingSHELLunconditionally without probing. Each fails exactly the expected test.Removal condition. Drop this once
@oclif/coreguards the call itself. No released version does — it is unguarded in 4.8.3, 4.11.14 and on main. The note lives on the helper.Not added to
environmentIssueMessages. Leaving this class of report unhandled in Observe is what will tell us if the workaround ever stops working or the failure moves to a different frame.How to test your changes?
End to end, with the syscall forced to fail against a built CLI:
Both crash on
mainand pass here.Verified locally:
tsc --noEmit -p packages/cli-kit,nx run cli-kit:build,nx run cli:buildandbin/run-knip-ci.jsall exit 0; eslint clean; fullpackages/cli-kitsuite matches a stashedmainbaseline exactly (3 pre-existing failures indeprecations.test.tsandnode-package-manager.test.ts). Two port-binding tests (tcp.test.ts,graphiql/server.test.ts) flake intermittently under load on my machine — confirmed unrelated by re-running this branch with the two new tests skipped, where they still flake at the same rate.Measuring impact
🤖 Generated with Claude Code