Skip to content

A function read from an object property silently drops its own call to a second function passed to it as a parameter (also read from an object property) — commander error-message output is lost #10711

Closed

Description

@proggeramlug

Found while validating the removal of the native commander binding (#10686) — with the binding
gone, import { Command } from "commander" now compiles the real npm package from source (via
#10699). Every value checked matches Node 26.5.1 exactly (program.args, boolean option defaults,
subcommand .action() callbacks, the commander.missingArgument/commander.unknownOption error
codes thrown) — except the error-message text commander writes to stderr before throwing, which
Perry silently drops.

Reproduction (isolated, no commander involved)

const config: any = {
  writeErr: (str: string) => process.stderr.write(str),
  outputError: (str: string, write: (s: string) => void) => write(str),
};

function fireError(cfg: any, message: string) {
  cfg.outputError(message, cfg.writeErr);
}

fireError(config, "error: something went wrong\n");
node main.ts          # prints "error: something went wrong"
perry compile main.ts -o out && ./out   # prints nothing

This is exactly the shape commander's own _displayError uses
(node_modules/commander/lib/command.js): a default outputConfiguration object holds two function
properties, writeErr: (str) => process.stderr.write(str) and
outputError: (str, write) => write(str), and error-reporting calls
this._outputConfiguration.outputError(message, this._outputConfiguration.writeErr) — an
object-property function invoking a SECOND object-property function passed to it as a parameter.

Isolating further

  • process.stderr.write alone works fine (confirmed separately).
  • A function stored as an object property, called directly (cfg.writeErr(message)), works fine.
  • The failure is specific to the two-level indirection: outputError (itself read off an
    object) invoking its own parameter write (itself a closure read off the same object and passed
    through one level of plain-function call). The inner write(str) call inside outputError's body
    never executes — no output, no crash, no error.

Impact

Any code that stores a callback on an object and calls it indirectly through a second function
(the "strategy"/"configurable output" pattern commander, and likely other npm packages, use) will
silently drop the inner call. For commander specifically: program.error(...),
exitOverride-driven validation failures (missing required argument, unknown option), and
--help/version output that routes through the same configuration object all lose their printed
text — though the actual thrown CommanderError (name, code, exitCode) is unaffected, since
that's a separate code path.

Not fixed by, and not caused by, #10686's binding removal — commander's own error codes,
program.args, option defaults, and .action() dispatch all match Node exactly in that PR's
acceptance test.

Activity

  1. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    I tried to reproduce this and could not, on any tree — including your own branch. Details
    below, because the way it fails to reproduce points fairly directly at a build problem
    rather than a compiler one.

    Your isolated repro, run verbatim

    const config: any = {
      writeErr: (str: string) => process.stderr.write(str),
      outputError: (str: string, write: (s: string) => void) => write(str),
    };
    function fireError(cfg: any, message: string) {
      cfg.outputError(message, cfg.writeErr);
    }
    fireError(config, "error: something went wrong\n");
    tree output
    Node 26.5.1 (node --experimental-strip-types) error: something went wrong
    main @ 4715bc2f (v0.5.1598, train 220) error: something went wrong
    main @ 8df83f8c — the commit your branch forks from error: something went wrong
    PR #10712 head 463c4fa5 (over #10699) — your actual tree error: something went wrong

    Each row is a separate full cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static with PERRY_RUNTIME_DIR pinned to the matching release/, so no
    arm could have linked a stale archive. Byte-identical to Node in every case, and the text
    really is on stderr (2>/dev/null swallows it, 2>&1 >/dev/null keeps it).

    Real commander 14.0.3 compiled from source via perry.compilePackages also matches Node
    byte for byte across the whole surface you list — --help, --version, missing required
    argument, unknown option, unknown command and program.error() — under both the default
    output configuration and a configureOutput() override, on main and on your tree alike.
    I confirmed the real JS class was in use, not a shim (_outputConfiguration a live object,
    outputError/writeErr functions, constructor.name === "Command"). 32 further spellings
    of the same indirection agree with Node too: method shorthand, class field, Object.assign
    override, spread, nested receiver, cross-object writer, computed keys, array-element
    receiver, getter-provided writer, Object.create chain, Object.freeze, destructuring,
    three-level chaining, two plain-function hops, async caller, nested closure, loop, and the
    shape inside a CommonJS module. A missing writer throws TypeError rather than being
    dropped.

    What does lose commander's output

    The perry-ext-commander native shim — the path you get when commander is not in
    perry.compilePackages:

    note: serving `commander` from the bundled native binding `perry-ext-commander`
          (a partial drop-in), ignoring your installed `node_modules/commander`.
    

    Against that shim the same program prints nothing at all — no error text, and no
    CommanderError either, so the catch never runs:

    === NODE ===                          === PERRY (native commander shim) ===
    error: unknown option '--nope'        (no output)
    caught code=commander.unknownOption
    error: custom failure
    caught code=demo.custom
    

    --help and --version are empty and program.args is undefined.

    Why I think this is your build

    "Codes right, text missing" is a blend of the two paths, and neither produces it on its
    own.
    Compiled from source, both are right. Through the shim, both are wrong. A build
    where part of the program reaches the shim and part reaches the real source produces
    exactly what you saw.

    That is what a stale libperry_stdlib.a looks like on a branch that removes
    perry-ext-commander and edits crates/perry-stdlib/Cargo.toml. perry-runtime and
    perry-stdlib are crate-type = ["rlib"], so cargo build -p perry-runtime -p perry-stdlib
    does not emit libperry_{runtime,stdlib}.a — those come from the -static wrapper
    crates. If they weren't rebuilt, perry compile links archives that still contain the old
    commander binding while your compiler no longer agrees about who owns it.

    Worth checking before anything else:

    cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static   # ONE invocation
    ls -la <target>/release/libperry_runtime.a <target>/release/libperry_stdlib.a   # mtime must have moved

    Keep the package set identical across builds — dropping -p perry changes cargo feature
    unification. Then confirm the note above is absent from the compile output.

    Leaving this open — it's your call once you've checked the build. If it still reproduces
    with freshly built archives, the exact perry compile invocation and the compiler/runtime
    commit stamps would be the useful next thing.

    I've put a regression lock on the shape in #10728 regardless. It passes on unfixed main,
    so it does not discriminate — it's there because the _displayError indirection had no
    coverage and #10689 (an inherited property read folding to undefined on a scalar-replaced
    object) landed one commit before this was filed, is the same family, and was silent in the
    same way.

  2. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    Fixed by b2dce52c36 (changelog.d/10728-property-fn-param-callback-lock.md), on main as of v0.5.1618.

    I verified with this issue's own reproducer, because I asserted the opposite in merge train 233's notes — at that time it genuinely was not fixed, and the fix landed afterwards:

    node:  error: something went wrong
    perry: error: something went wrong
    

    This also removes the caveat attached to the commander binding removal, which shipped noting that four stderr lines were lost to this defect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions