Skip to content

http: no route slot takes a callback (#877) - #890

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix/877-route-slot-callable
Aug 5, 2026
Merged

http: no route slot takes a callback (#877)#890
InauguralPhysicist merged 1 commit into
mainfrom
fix/877-route-slot-callable

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

The bug

http_route's third element is a response body, but the signature named it handler — which means "callback" in every mainstream HTTP framework. Passing a function stringified it into the route table, so a live endpoint answered 200 with a debug repr, silently and remote-visibly:

/fn       -> <fn hello>    [HTTP 200]

Wider than filed

The issue reports the 3-element body slot. Testing every slot found the 4-element code form has the same hole — a function as the source stringifies to <fn hello>, gets compiled as EigenScript, and serves null with a 200:

correct   -> pong          [200]
fn        -> <fn hello>    [200]   <- as filed
builtin   -> <builtin>     [200]   <- as filed (anticipated)
code-fn   -> null          [200]   <- NOT in the issue

So the guard covers every slot — method, path, kind, body/source — each with a message naming its own next step. VAL_FN/VAL_BUILTIN are the only value types with no sensible rendering; dicts, lists, numbers, buffers and text-builders all stringify to something a client can use, so that is the whole of the class.

One constraint the issue didn't mention

rt_error sets the error flag and returns rather than unwinding. Raising after the existing value_to_string calls would strand the strdup'd method/path in a route slot that route_count never reaches — a leak on the error path. The guard therefore runs before any allocation.

Registration time is also the right moment, per the issue: the error lands on the line the author wrote, before the socket is listening, instead of on a client's request.

Naming

Since the name is what invites the mistake: handlerbody in the BUILTINS signature, in the four lib/http.eigs wrappers (which propagated it verbatim into the stdlib) and their STDLIB entries, and in http_route's arity error.

The test, and two things the planted fault caught in it

Suite HS35 pins all six rejections, that all four valid body forms still register, and that an uncaught callable aborts before http_serve. Both of the following were found by running the fault, not by reading the test:

  1. The startup case is bounded by timeout, and that is load-bearing. With the guard regressed, http_serve IS reached and the server runs forever — unbounded, the case would hang the suite rather than fail it. It now reports rc=124 as an explicit failure.
  2. A fourth assertion was written and then removed. "Nothing listens on the port" passed under the planted fault, because timeout had already killed the server before the check ran. A test that cannot fail when it matters is noise, so it is gone; the three remaining assertions carry the detection.

Planted fault: with route_slot_is_callable stubbed to return 0 and the binary rebuilt, all three HS35 assertions fail in ~5s.

Gates

gate result
release suite 3786/3786, 0 failed
asan-http + UBSan, detect_leaks=1 3916/3916, 0 failed — LeakSanitizer tally at its 0 floor
HTTP integration suite 45/45, 0 failed

(ext_http.c is sanitized via make asan-http, not make asan — the latter compiles with EIGENSCRIPT_EXT_HTTP=0.)

Behavior change

A function in a route slot previously registered and served a 200; it now raises type_mismatch at registration. That is the point of the fix, and there are no external consumers to migrate.

Closes #877

🤖 Generated with Claude Code

`http_route`'s third element is a response BODY, but the signature named it
`handler` — which means "callback" in every mainstream framework. Passing a
function stringified it into the route table, so a live endpoint answered
200 with the debug repr `<fn hello>`: silent, remote-visible, and logged
nowhere.

Wider than filed. The issue reports the 3-element body slot; testing every
slot found the 4-element code form has the same hole — a function as the
source stringifies to `<fn hello>`, gets compiled as EigenScript, and
serves `null` with a 200:

    /fn       -> <fn hello>    [200]   as filed
    /builtin  -> <builtin>     [200]   as filed
    /code-fn  -> null          [200]   NOT in the issue

So the guard covers every slot — method, path, kind, body/source. VAL_FN
and VAL_BUILTIN are the only value types with no sensible rendering; dicts,
lists, numbers, buffers and text-builders all stringify to something a
client can use, so that is the whole of the class, and each slot gets a
message naming its own next step.

The check runs BEFORE anything is allocated or stored. rt_error sets the
error flag and returns rather than unwinding, so raising after the existing
value_to_string calls would strand method/path in a route slot that
route_count never reaches — a leak on the error path. Registration time is
also the right moment: the error lands on the line the author wrote, before
the socket is listening, instead of on a client's request.

Naming, since that is what invites the mistake: `handler` -> `body` in the
BUILTINS signature, in the four lib/http.eigs wrappers (which propagated it
verbatim) and their STDLIB entries, and in http_route's arity error.

Suite HS35 in test_http_server.sh pins all six rejections, that all four
valid body forms still register, and that an uncaught callable aborts
before http_serve. Two notes on the test itself, both found by the planted
fault rather than by reading it:

- The startup case is bounded by `timeout` because a regression makes
  http_serve reachable and the server runs forever — unbounded, the case
  would HANG the suite instead of failing it. It now reports rc=124.
- A fourth assertion ("nothing listens on the port") was written and then
  removed: it PASSED under the planted fault, because timeout had already
  killed the server before the check ran. A test that cannot fail when it
  matters is noise.

Validated with a planted fault: with route_slot_is_callable stubbed to 0
and the binary rebuilt, all three HS35 assertions fail in ~5s.

Gates: release 3786/3786, asan-http + UBSan detect_leaks=1 3916/3916 with
the LeakSanitizer tally at its 0 floor, HTTP integration suite 45/45.

Closes #877

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 5, 2026 16:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a class of silent, remote-visible HTTP routing bugs by rejecting callable values (functions/builtins) in any http_route slot at registration time, and aligns the public-facing naming/docs to clarify that the 3rd element is a literal response body (not a callback). It also adds an integration test to pin the new behavior and prevent regressions that could otherwise hang the test suite.

Changes:

  • Add registration-time validation in http_route to reject callable values in method/path/kind/body/source slots.
  • Rename the documented/stdlib-facing parameter from handlerbody to reduce “callback” confusion.
  • Add HS35 integration coverage for all callable-slot rejections and for abort-before-http_serve behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/ext_http.c Adds callable-slot guard and updates http_route arity message.
tests/test_http_server.sh Adds HS35 integration assertions for callable-slot rejection and startup abort behavior.
lib/http.eigs Renames stdlib wrapper parameters to body and updates wrapper comments.
docs/STDLIB.md Updates stdlib docs to reflect body parameter and “not a callback” guidance.
docs/BUILTINS.md Updates builtin signatures/docs to use body and document the new rejection behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread docs/BUILTINS.md
Comment on lines +570 to +571
| `http_route` | `http_route of [method, path, body]` or `[method, path, "code", source]` | Register a route. `body` is a literal response body, **not** a callback — passing a function raises (#877); use the `code` form for per-request logic |
| `http_route_authed` | `http_route_authed of [method, path, body]` or `[method, path, "code", source]` | Register authenticated route; auth source published via `shared_set of ["require_auth", "<source>"]` |
Comment thread tests/test_http_server.sh
Comment on lines +822 to +833
# `timeout` is load-bearing, not belt-and-braces: if this guard ever regresses,
# http_serve IS reached and serves forever, so an unbounded run would HANG the
# suite instead of failing it. Verified against a planted fault — without the
# guard this block reports rc=124 and fails, in ~5s.
OUT5=$(timeout 5 "$EIGS" "$SRV5" 2>&1); RC5=$?
if [ "$RC5" = "124" ]; then
fail "HS35 callable body reached http_serve — server ran until killed" "output: $OUT5"
elif [ "$RC5" != "0" ] && ! printf '%s\n' "$OUT5" | grep -q 'UNREACHABLE-SERVE'; then
ok "HS35 an uncaught callable body aborts before http_serve (rc=$RC5)"
else
fail "HS35 callable body did not stop startup" "rc=$RC5 output: $OUT5"
fi
Comment thread src/ext_http.c
Comment on lines +250 to +274
for (int i = 0; i < 2; i++) {
if (route_slot_is_callable(arg->data.list.items[i])) {
rt_error(EK_TYPE, 0, "http_route: %s must be a string, not a function",
i == 0 ? "method" : "path");
return make_null();
}
}
if (arg->data.list.count >= 4) {
if (route_slot_is_callable(arg->data.list.items[2])) {
rt_error(EK_TYPE, 0, "http_route: kind must be a string, not a function "
"(expected \"code\" or \"static\")");
return make_null();
}
if (route_slot_is_callable(arg->data.list.items[3])) {
rt_error(EK_TYPE, 0, "http_route: the code form's source must be a string of "
"EigenScript source, not a function — "
"http_route of [method, path, \"code\", \"return 42\"]");
return make_null();
}
} else if (route_slot_is_callable(arg->data.list.items[2])) {
rt_error(EK_TYPE, 0, "http_route: body must be a value, not a function — pass a "
"literal body (\"pong\"), or use the code form: "
"http_route of [method, path, \"code\", \"<source>\"]");
return make_null();
}
@InauguralPhysicist
InauguralPhysicist merged commit 58fe307 into main Aug 5, 2026
19 checks passed
@InauguralPhysicist
InauguralPhysicist deleted the fix/877-route-slot-callable branch August 5, 2026 16:30
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.

http_route serves a function's debug repr as the response body — ["GET", "/x", handler_fn] returns 200 with &lt;fn hello&gt; instead of raising

2 participants