perf(cli): Load subcommands and their heavy dependencies on dispatch - #2398
Conversation
13e87fc to
30b9a8e
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Telemetry.flush can skip its callback when no backend was initialized, breaking installer exit handling.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Improves CLI startup performance by loading commands, heavy dependencies, and telemetry state only when needed.
Changes:
- Adds lazy yargs command registration with metadata validation.
- Defers Navie, RPC, and browser-serving imports.
- Lazily initializes telemetry configuration resources and backends.
File summaries
| File | Description |
|---|---|
packages/telemetry/src/client.ts |
Lazily initializes telemetry resources. |
packages/cli/tests/unit/cliCommands.spec.ts |
Validates command registry metadata. |
packages/cli/src/rpc/llmConfiguration.ts |
Defers loading Navie. |
packages/cli/src/lib/lazyCommand.ts |
Implements lazy command dispatch. |
packages/cli/src/lib/handleWorkingDirectory.ts |
Defers RPC configuration loading. |
packages/cli/src/cmds/sequenceDiagram.ts |
Defers browser-serving dependencies. |
packages/cli/src/cliCommands.ts |
Declares the lazy command registry. |
packages/cli/src/cli.ts |
Registers lazy commands and defers depends helpers. |
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| export default function lazyCommand(spec: LazyCommandSpec): AnyCommandModule { | ||
| const { command, describe, aliases, load } = spec; | ||
| let loaded: AnyCommandModule | undefined; | ||
| const implementation = () => { | ||
| if (!loaded) loaded = commandModule(load()); |
cli.ts imported every command module in order to register it with yargs, so any invocation paid for loading all of them before parsing its arguments. The Navie and RPC commands alone pull in mermaid, jsdom, langchain and express; `appmap --version` took about 540 ms, of which Node itself is 70 ms. Commands are now declared in cliCommands.ts by name, description and aliases, with a loader for the implementing module. lazyCommand.ts hands yargs a stub whose builder and handler load the module on first use. A test loads every module and checks that the declared metadata matches the module's own, so the two cannot drift apart. Three shared imports also loaded far more than their callers need and are made on demand: - lib/handleWorkingDirectory imported the RPC configuration, which imports the LLM configuration, which imports the whole Navie package. Nearly every command calls handleWorkingDirectory. - rpc/llmConfiguration imported Navie for a single constant. - cmds/sequenceDiagram imported the browser serving helper, and with it inquirer, rxjs and lodash, which only the PNG format uses. Median of 7 runs, same Node, same 253 KiB recording: appmap --version 541 ms -> 86 ms appmap sanitize 579 ms -> 103 ms appmap sequence-diagram 646 ms -> 188 ms Output of sanitize and sequence-diagram is byte-identical, and the top-level help text is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZureVETeSm9qMExQuCSZv
…t use configure() built the conf store, the session and the backend on every call, which loaded conf and read-pkg-up and read the user's config file from disk. Every CLI command configures telemetry at startup and most never send an event. The three are now created the first time an event or the session id is needed, and with telemetry disabled and debug off, sendEvent returns before building an event at all. This takes about 25 ms off every CLI invocation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZureVETeSm9qMExQuCSZv
30b9a8e to
15dc378
Compare
There was a problem hiding this comment.
I like it, impressive improvement! But please at least use this opportunity to move the depends command into its own file, dropping back the many requires needed to load this dynamically when its defined inline.
Couple nits/questions:
- Would it be feasible to use dynamic
import()instead? We will want to migrate to ESM at some point in the future and adding dynamicrequires might make it more difficult. - I don't love the duplication of the command specs. While there is a test that guards against drift, I wonder if it wouldn't be cleaner to drop the specs from command modules entirely (though this might make it easier to forget to update them when needed) or have the command index autogenerated (although that would add a build step).
- It has always bothered me that we aren't passing the command modules through yargs in type-preserving manner (instead, by using top-statement level
parser.command();, we were throwing away the return type). This change doubles-down on this limitation, by hiding the builders behind dynamic loads and erasing the types entirely withAnyCommand.
This might not matter much in practice except from a type discipline viewpoint (it's not really used downstream except to invoke the parser), but it's my belief that whenever you need to fight the type system like this it points to a possible design flaw (or at least, room for improvement).
Indeed, in this case this suggests a different, IMO cleaner approach (even if needing slightly more changes): instead of loading the full file lazily, why not load only the handlers (spun off to separate files) on demand, and leave the command files loaded eagerly? This will allow the toplevel yargs to see the command shape entirely, including the builders, allowing type-preserving loading, and makingappmap <command> --helpfast too (because it won't need to load the handler module). This will also dissolve my point 2 above entirely because there will be no duplication and nothing to check and drift.
Or, combine it with the first suggestion in 2. above, and have one file that has all the specs – including the builders! – and leave the command files as just the handlers. Note preserving type safety in the handler arguments might be tricky in this case – for type safety, the handler argument type needs to be derived from the builder. This is not as useless as the top-level command parser typing, since it makes the typechecker catch any drift in the argument shapes between the handler and the builder (and by extension the real command line as parsed and documented in--help).
…patch Each command is now split in two: a light module that declares the command's name, description and options, and a handler module that does the work and holds the heavy imports. cli.ts imports every command module statically and registers it with yargs, so yargs sees each command's full shape, including its builder, and `appmap <command> --help` runs without loading the handler. The handler module is loaded with a dynamic `import()` on dispatch (lib/lazyHandler). This replaces the registry in cliCommands.ts, which duplicated each command's name and description, and the lazyCommand stub, which erased the module types. The `depends` command, which was defined inline in cli.ts, moves to its own files. Modules are registered through lib/registerCommand, which uses yargs's `command(name, describe, builder, handler)` overload. Unlike `command(module)`, whose module type is CommandModule<T, any>, that overload infers the handler's argument type from the builder, so a handler whose arguments disagree with its builder fails to type-check. That check flagged the search, install and status handlers, whose argument interfaces are written by hand rather than derived from their builders; they now cast explicitly, with a comment, and deriving them is left for a follow-up. The install, status and compare-report builders need values from modules that are expensive to load (the installer list alone costs about 100 ms), so those builders are async and import them on demand. The Navie option builder shared by navie, rpc and rpc-client moves to cmds/navie/commonNavieArgs.ts. A new test runs the real entry file under ts-node and fails if any of the heavy packages (Navie, express, jsdom, mermaid, langchain, inquirer, sequence-diagram, better-sqlite3) is loaded for `--version` or for a command's `--help`. Help output is unchanged for every command. Startup costs a few extra milliseconds for the 36 light command modules: appmap --version 37 ms -> 41 ms appmap sanitize --help 43 ms -> 46 ms appmap navie --help 42 ms -> 46 ms Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nh7xkru3re6qud66oQtHLd
|
Thanks for the review. 8a70825 does what you suggested in point 3: the command files are loaded eagerly, each handler lives in its own module and is loaded with a dynamic One thing worth flagging about the type-preserving registration. yargs's That check immediately flagged three commands: Two smaller notes from the same change:
A new test, |
| if (existsSync('openapi.yml')) tarCommand += ' openapi.yml'; | ||
|
|
||
| await new Promise<void>((resolveCB, rejectCB) => { | ||
| exec(tarCommand, (error) => { |
|
Thanks, this is what I had in mind! Re type-checking, you're right, it's been a moment since I last touched it so I didn't remember exactly how it worked. It seems like some commands (such as apply) do derive handler argument shape from builder via a |
Why
Every
appmapCLI invocation paid about half a second of startup before doing any work, and for the short commands that is nearly all of their run time.appmap --versiontook 541 ms on a machine where bare Node starts in 69 ms.The cost was module loading.
cli.tsimported all 27 command modules in order to register them with yargs, so runningsanitizeloaded the code for every other command too. The Navie and RPC commands alone pull in mermaid, jsdom, langchain and express. On top of that, three widely shared imports loaded far more than their callers needed, and telemetry read a config file from disk on every call.This matters most to tooling that calls the CLI once per file. The gold-traces engine used by the AppMap skills spawns
sanitizeandsequence-diagramthree times per trace; a dry run over 28 traces spent about a minute in CLI startup.Results
Median of 7 runs, same Node (v22), same 253 KiB recording, this branch vs
main:appmap --versionappmap sanitize <file>appmap sequence-diagram <file> --format json--versionis now within about 17 ms of the Node startup floor, and only 7 ms of that is module loading. What remains insequence-diagramis its own work: loading the modules it actually uses and about 35 ms of SQL parsing for the recording's queries.How it works
Commands load on dispatch.
packages/cli/src/cliCommands.tsdeclares each subcommand by name, description and aliases, with a loader for the module that implements it.packages/cli/src/lib/lazyCommand.tsturns each entry into a yargs command module whosebuilderandhandlerload the real module the first time yargs dispatches to it. yargs only needs the name and description to list and match commands, so--helpand argument parsing are unchanged, and a command's module is loaded only when that command, or its--help, runs. Thedependscommand, which is defined inline incli.ts, loads its helpers inside its handler the same way.Because yargs now sees the inline metadata rather than the module's, a new test (
tests/unit/cliCommands.spec.ts) loads every module and checks that itscommand,describeandaliasesmatch the declaration, so the two cannot drift.Three shared imports are made on demand.
lib/handleWorkingDirectoryimported the RPC configuration, which imports the LLM configuration, which imports the whole Navie package. Nearly every command callshandleWorkingDirectory, which only needsprocess.chdir. The RPC configuration is now loaded insideconfigureRpcDirectories.rpc/llmConfigurationimported Navie for the singleSELECTED_BACKENDconstant. It is now read insidegetLLMConfiguration.cmds/sequenceDiagramimported the browser serving helper, and with it inquirer, rxjs and lodash, which only the PNG format uses. It is now loaded on that path.Telemetry initializes on first event.
TelemetryClient.configure()created theconfstore, the session and the backend on every call, loadingconfandread-pkg-upand reading the user's config file from disk. They are now created the first time an event or the session id is needed. With telemetry disabled and debug off,sendEventreturns before building an event at all.flushonly flushes a backend that was actually created.Verification
sanitizeandsequence-diagramoutput is byte-identical to the released 3.201.3 binary on the same input, and the top-level help text is unchanged.--helpruns cleanly, including the ones that depend onreflect-metadatabeing loaded first.mainafter the ESLint 8 upgrade; lint is clean on the changed files.🤖 Generated with Claude Code
https://claude.ai/code/session_01SZureVETeSm9qMExQuCSZv