Skip to content

json_rpc: look up handlers with at, not bracket indexing - #1514

Merged
sergey-shandar merged 4 commits into
mainfrom
claude/epic-fermi-9wkv32
Aug 13, 2026
Merged

json_rpc: look up handlers with at, not bracket indexing#1514
sergey-shandar merged 4 commits into
mainfrom
claude/epic-fermi-9wkv32

Conversation

@sergey-shandar

@sergey-shandar sergey-shandar commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes fjs/protocol/json_rpc/todo/dispatch-at-lookup.md (deleted in this PR).

Problem

dispatch resolved a handler with handlers[method]. method is untrusted
wire data and handlers is an ordinary object, so an inherited
Object.prototype name resolves to a callable. Reproduced against main:

{method: 'toString'}    → {"jsonrpc":"2.0","error":"o","id":1}
{method: 'constructor'} → TypeError: handler is not a function or its return value is not iterable

The first destructures "[object Undefined]" character-wise (t2 = '[',
result = 'o') and emits a response that violates the JSON-RPC schema — the
error member is a string, not an error object. The second throws straight out
of a function documented as pure. A dispatcher whose contract says "unknown
method → -32601" did neither.

Fix

const handler = at(method)(handlers), testing === null. at
(fjs/types/object) goes through getOwnPropertyDescriptor, so only own
properties resolve. This is the same lookup every other dispatch site in the
repo already uses, each with a comment naming this hazard — match
(fjs/effects), cli/dispatch (fjs/cli), addRevisionToCache
(fjs/cas/evo). json_rpc was the one reading raw.

The hand-written /** @type {Handler | undefined} */ annotation, needed only
because the index-signature type claims every string key is present, is gone
along with the now-unused Handler @import.

Verification

After the change, every Object.prototype name answers -32601:

toString       -> {"jsonrpc":"2.0","error":{"code":-32601,...},"id":1}
constructor    -> {"jsonrpc":"2.0","error":{"code":-32601,...},"id":1}
__proto__      -> {"jsonrpc":"2.0","error":{"code":-32601,...},"id":1}
hasOwnProperty -> {"jsonrpc":"2.0","error":{"code":-32601,...},"id":1}
ping           -> {"jsonrpc":"2.0","result":"pong","id":1}

Three new proofs, throw-free per §3.5: inheritedToString and
inheritedConstructor assert -32601, and ownPrototypeName dispatches
against a table that genuinely owns a toString handler — pinning the
distinction the fix rests on (own property yes, inherited no) rather than just
the negative case.

npx tsc clean; npm start test 2498 pass / 0 fail, re-run after merging
main.

One change outside that scope

The 0.44.0 release commit (#1513) wrote its new CHANGELOG section as a bare
0.44.0 line, without the ## prefix. It is therefore not a heading, so
every entry from 0.44.0 down to ## 0.43.1 renders inside ## Unreleased
including this PR's entry, which is why it is fixed here rather than left for a
separate pass. §8.3's "don't rewrite a released section" still holds: no
released wording changes, only the missing ## . Merging main also left a
whitespace-only line where the blank separator belongs; that is cleaned up too.

Notes

fjs/protocol/json_rpc/todo/effectful-dispatch-skeleton.md quotes these exact
lines; its snippet is updated to the new code. That issue's own proposal is
untouched — it said "whichever lands first should carry the fix".

mcpStep (fjs/protocol/mcp) routes methods with explicit === comparisons
and a validated tool table, so it never had this hazard.

🤖 Generated with Claude Code

https://claude.ai/code/session_018h9EovzEiFnrBj5m7kg4MU

claude added 2 commits August 13, 2026 06:30
`dispatch` resolved a handler with `handlers[method]`, where `method` is
untrusted wire data and `handlers` is an ordinary object, so inherited
`Object.prototype` names resolved to callables:

- `{method: 'constructor'}` threw a `TypeError` out of `dispatch`
- `{method: 'toString'}` destructured `"[object Undefined]"` character-wise
  into `{"jsonrpc":"2.0","error":"o","id":1}`, a schema-violating response

Switch to `at` from `fjs/types/object` — the same own-property lookup every
other dispatch site in the repo already uses — and test for `null`. The
hand-written `Handler | undefined` annotation, needed only because the
index-signature type lied, goes away.

Proofs cover `toString` / `constructor` answering `-32601`, plus a handler
table that genuinely owns a `toString` key still dispatching normally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018h9EovzEiFnrBj5m7kg4MU
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018h9EovzEiFnrBj5m7kg4MU
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
functionalscript 3dd34d0 Commit Preview URL

Branch Preview URL
Aug 13 2026, 06:47 AM

sergey-shandar and others added 2 commits August 12, 2026 23:43
The 0.44.0 release commit wrote the new section as a bare `0.44.0` line,
so it never became a heading: every entry from 0.44.0 down to 0.43.1
rendered inside `## Unreleased`, this PR's entry among them. Merging main
also left a whitespace-only line in place of the blank separator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018h9EovzEiFnrBj5m7kg4MU

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. I treated this as a behaviour change rather than a refactor and reproduced the before/after directly instead of reasoning about it.

The bug is real, and worse than "unsafe indexing". Same probe (dispatch({ping}), request {jsonrpc:'2.0', method:<name>, id:4}), run against origin/main (37db36c) and against this head:

method main this PR
ping {result:"pong"} {result:"pong"}
nope -32601 -32601
toString {"jsonrpc":"2.0","error":"o","id":4} -32601
constructor TypeError thrown out of dispatch -32601
__proto__ TypeError -32601
hasOwnProperty TypeError -32601
valueOf TypeError -32601
isPrototypeOf TypeError -32601

So main is not merely permissive: five of the six prototype names throw out of a function documented as pure and total, and toString emits a response that violates the errorResponse schema (error is the string "o""[object Undefined]" destructured character-wise). Every one of these is reachable from untrusted wire data. The todo file this PR deletes described exactly the two shapes I reproduced.

Not over-corrected either: an own toString handler still dispatches ({result:"own"}) on both main and this head, and a notification (id absent) still short-circuits to null before the lookup on both. at uses getOwnPropertyDescriptor + fromUndefined, so an own property whose value is undefined still yields -32601, matching the old === undefined test — the only behavioural delta is the inherited-name one.

The new proofs are not vacuous. I mutated the fix back (const handler = handlers[method] ?? null, verified the edit landed) and re-ran node ./fjs/module.mjs t: 2 failuresinheritedToString ("assertion failed") and inheritedConstructor ("TypeError: handler is not a function..."), i.e. the two distinct failure shapes each get their own guard. Restored, 0 failures. ownPrototypeName passes under the mutation too, but it is a regression guard for the opposite direction, which is the right thing for it to be.

Worth saying explicitly since coverage is now measurable: coverage could not have caught this. npm run cov reports fjs/protocol/json_rpc/module.f.mjs at 100.00 / 100.00 / 100.00 on main as well — the buggy line was already fully covered. The behavioural cases are the only thing that could have found it, which is an argument for this PR's shape rather than against it.

Verified at 3dd34d0, both trees clean before testing:

  • npx tsc --noEmit — exit 0 (also 0 on main).
  • npm run prepack from a freshly cleaned tree — exit 0 (both passes). Also 0 on main.
  • node ./fjs/module.mjs t2498 pass / 0 fail vs 2495 / 0 on main. +3 is exactly the three added cases, nothing else moved.
  • npm run covtests 2434, pass 2434, fail 0 (main: 2431), all files 99.93 / 98.07 / 99.73 — byte-identical totals to main.
  • Dual-axis public surface (prepack in both trees, then extract.mjs / consts.mjs, outputs 45.9 KB / 64.7 KB so non-empty): zero differences in exported type aliases and zero in exported const signatures. No new export type — so no §6.2 _-prefix question arises — and dispatch's emitted signature is unchanged, no widening to any. Dropping the hand-written /** @type {Handler | undefined} */ cast costs nothing at the boundary; Handler is still exported from types.ts via Handlers.
  • linkcheck.mjs — broken-link sets identical to main, not just counts. Deleting todo/dispatch-at-lookup.md strands nothing: grep -rn dispatch-at-lookup --include='*.md' finds no remaining reference (the cross-link ran the other way, from the deleted file).
  • Rust gates skipped — nanvm-lib/ untouched.

Two smaller things, both correct:

  • The ## 0.44.0 heading restore is a genuine fix, not a rewrite of a released section. #1513 added the line as bare text 0.44.0 under ## Unreleased; every other release in the file is a ## heading (## 0.43.1, ## 0.43.0, …). Restoring it is what makes the new Unreleased entry land in the right section.
  • CHANGELOG entry links only /pull/1514, no issue or todo/ link, and sits under ## Unreleased. No **BREAKING CHANGES:** prefix, which I agree with: the exported surface is provably identical and the change only replaces a throw / malformed response with the documented -32601.

Nothing to fix. Comment in module.f.mjs naming the hazard matches the three existing at dispatch sites (effects/match, cli/dispatch, cas/evo), so json_rpc is no longer the odd one out.

@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit 6dd0d9a Aug 13, 2026
19 checks passed
@sergey-shandar
sergey-shandar deleted the claude/epic-fermi-9wkv32 branch August 13, 2026 21:10
@sergey-shandar sergey-shandar mentioned this pull request Aug 14, 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.

3 participants