Skip to content

fix(cli): reject non-numeric --timeout-seconds instead of passing NaN downstream - #73

Merged
Ray-56 merged 2 commits into
CALLE-AI:mainfrom
EazyHood:fix/timeout-nan
Aug 4, 2026
Merged

fix(cli): reject non-numeric --timeout-seconds instead of passing NaN downstream#73
Ray-56 merged 2 commits into
CALLE-AI:mainfrom
EazyHood:fix/timeout-nan

Conversation

@EazyHood

@EazyHood EazyHood commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

What

--timeout-seconds 30s makes every MCP call fail instantly and report a timeout, while auth and broker calls keep working. This validates the flag at the edge and fixes the one unguarded timeout conversion.

Why

Number("30s") is NaN, and the two HTTP paths disagree about what to do with it:

  • packages/core/lib/http.js:17Math.max(Math.ceil(Number(timeoutSeconds || 15) * 1000), 1000). NaN is falsy, so it falls back to 15. Safe by accident.
  • packages/core/lib/mcp-client.js:145Math.max(Math.ceil(config.timeoutSeconds * 1000), 1000). Math.ceil(NaN) is NaN, Math.max(NaN, 1000) is NaN. No fallback.

Node does not throw on setTimeout(fn, NaN) — it warns and substitutes 1 ms:

TimeoutNaNWarning: NaN is not a number. Timeout duration was set to 1.

So requestJsonRpc aborts its own AbortController before the request leaves, and the user sees a timeout error.

Run through resolveRuntimeConfig as it is on main:

--timeout-seconds config mcp-client:145 http.js:17
"30" 30 30000 30000
"30s" NaN NaN 15000
"1m" NaN NaN 15000
(unset) 15 15000 15000

The asymmetry is what makes this expensive to diagnose. Broker and auth calls go through http.js and keep working, so calle auth status is fine. Every MCP call dies instantly and reports a timeout, which reads as "the MCP server is down" — so you look at the server, the network and the token, everywhere except a typo in your own flag.

How

Validate at the edge. A secondsOption(value, fallback, flag) helper in config.js, applied to the four second-valued options that share the Number(x || DEFAULT) shape (timeoutSeconds, pollTimeoutSeconds, minTtlSeconds, telemetryTimeoutSeconds). It keeps the || fallback exactly as before, so falsy values still take the default and nothing that works today changes:

--timeout-seconds expects a positive number of seconds, got "30s". Use "30", not "30s".

Guard the one line that drifted. mcp-client.js:145 now uses the same || default form as its three siblings. This is not a new convention — it is the only one of four that was missing it:

http.js:17 Number(timeoutSeconds || 15) — guarded
telemetry.js:123 Number(config.telemetryTimeoutSeconds || 1.5) — guarded
broker-client.js:157 Number(config.pollTimeoutSeconds || 300) — guarded
mcp-client.js:145 config.timeoutSeconds * 1000 — was not

Tests

Two added to packages/cli/test/cli.test.js: one asserting each of the four flags is rejected with its own name in the message, one asserting valid values and defaults are unaffected.

packages/cli    37 tests, 37 pass, 0 fail   (35 existing + 2 new)
packages/core   12 tests, 12 pass, 0 fail
packages/core   syntax check + tsc --strict: pass

Note for reviewers running this on Windows: packages/codex-plugin and packages/cursor-plugin fail their frontmatter checks, and pnpm run check fails with them. I verified those are pre-existing by stashing this branch and re-running against unmodified main — same failures. They are the CRLF issue that #70 addresses, and are unrelated to this change.

… downstream

Number("30s") is NaN, and the two HTTP paths disagree about what to do with it.
http.js:17 writes Number(timeoutSeconds || 15), so NaN is falsy and it falls back
to 15 -- safe by accident. mcp-client.js:145 wrote config.timeoutSeconds * 1000
with no guard, and Math.max(NaN, 1000) stays NaN. Node does not throw on
setTimeout(fn, NaN); it warns and substitutes 1ms, so requestJsonRpc aborted its
own AbortController before the request left and surfaced a timeout error.

The asymmetry is what makes it expensive to diagnose: broker and auth calls go
through http.js and keep working, so `calle auth status` is fine while every MCP
call dies instantly and blames the network.

  --timeout-seconds   config   mcp-client:145   http.js:17
  "30"                30       30000            30000
  "30s"               NaN      NaN              15000
  (unset)             15       15000            15000

Validate at the edge in resolveRuntimeConfig, naming the flag, and apply it to
all four second-valued options, which share the same Number(x || DEFAULT) shape.
Falsy values still fall back to the default, so existing behaviour is unchanged.

Also give mcp-client.js:145 the same `|| default` guard the rest of the codebase
already uses -- it is the only one of four that was missing it:

  http.js:17            Number(timeoutSeconds || 15)                   guarded
  telemetry.js:123      Number(config.telemetryTimeoutSeconds || 1.5)  guarded
  broker-client.js:157  Number(config.pollTimeoutSeconds || 300)       guarded
  mcp-client.js:145     config.timeoutSeconds * 1000                   <- was not

@Ray-56 Ray-56 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NaN diagnosis is valid and the local CLI/Core checks and tests pass, but the shared validator introduces two correctness gaps and the release metadata is missing.

[P1] Preserve the existing --min-ttl-seconds 0 behavior. Current main resolves { minTtlSeconds: "0" } to numeric zero, which is useful for disabling the minimum remaining-lifetime window. This PR rejects it because all four settings share a strictly-positive validator. Please make the constraints option-specific: allow a non-negative value for min TTL while keeping timer/request durations positive, and add a regression test for zero.

[P2] Bound timer-backed values as well as checking that they are finite. Node sets any setTimeout delay greater than 2,147,483,647 ms to 1 ms, so a value such as --timeout-seconds 2147484 passes this validator and recreates the same immediate-abort failure class that the PR is intended to prevent. Add a maximum for timeout-backed settings or safely clamp/reject them, with a boundary test. Node documents this behavior here: https://nodejs.org/api/timers.html#settimeoutcallback-delay-args

[P2] Add patch changesets for both @call-e/cli and @call-e/core. Both published packages change behavior in this PR. Verify with pnpm run check:versions and both package dry-runs.

Addresses the review on CALLE-AI#73.

[P1] --min-ttl-seconds 0 works again. Zero disables the minimum remaining
lifetime window, and the shared strictly-positive validator had taken that
away. The bounds are now per option, and a numeric 0 is no longer swallowed
by the falsy check on the incoming value.

[P2] Timer-backed durations are capped at 2147483 seconds. setTimeout holds
its delay in a signed 32-bit int and silently replaces anything larger with
1ms, so --timeout-seconds 2147484 passed the validator and reproduced the
immediate abort it was added to prevent. --min-ttl-seconds never reaches a
timer, so it is not capped.

[P2] Patch changesets added for @call-e/cli and @call-e/core.

Reverting to the shared validator fails 3 of the 39 CLI tests.
@EazyHood

EazyHood commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

All three addressed.

[P1] The bounds are per option now, so --min-ttl-seconds 0 resolves to zero again while the timeout flags stay strictly positive. One thing your note surfaced that I had not spotted: the value was read as value || fallback, so a numeric 0 was falsy and got replaced by the default even before the validator saw it. That is fixed too, and both the string and numeric forms are covered.

[P2] Good catch on the 32-bit delay. Timer-backed durations are capped at 2147483 seconds, with a boundary test at the limit and one past it. --min-ttl-seconds never reaches a timer, so it is not capped, and a test pins that.

[P2] Patch changesets added for @call-e/cli and @call-e/core.

Reverting to the shared validator fails 3 of the 39 CLI tests, so the new ones do exercise the change. Core is 12/12 and check:versions is in sync.

One thing I could not verify: pnpm --filter @call-e/cli pack:dry-run does not run on my machine. The script is POSIX shell (tmpdir=$(mktemp -d) && trap ...) and cmd.exe answers 'tmpdir' is not recognized. It fails the same way on the branch without these commits, so it looks environmental rather than caused by the change, but I have not been able to confirm the CLI dry-run itself. The @call-e/core one runs fine.

@Ray-56 Ray-56 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current head resolves all previous findings: @call-e/cli preserves both string and numeric min-TTL zero, timer-backed values are bounded below Node's maximum delay, and patch changesets are present for both @call-e/cli and @call-e/core. GitHub CI is green, the branch remains clean against the latest main, and local CLI/Core checks, tests, version validation, and package dry-runs all pass. I did not find another merge blocker.

@Ray-56
Ray-56 merged commit 89a5237 into CALLE-AI:main Aug 4, 2026
1 check passed
@github-actions github-actions Bot mentioned this pull request Aug 4, 2026
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.

2 participants