Summary
Calls to sendToClient(handle, msg) and closeClient(handle) — both named imports from 'ws' — are silently compiled to no-ops. Args are evaluated (so side effects like JSON.stringify(...) run) but no FFI call to the ws runtime is emitted, so no WebSocket frame ever leaves the process.
This affects the common server-side WS pattern:
import { WebSocketServer, sendToClient, closeClient } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (clientHandle: any) => {
sendToClient(clientHandle, '{"hello":"world"}'); // no-op in current perry
// closeClient(clientHandle); // also no-op
});
Repro (minimal)
server.ts:
import { WebSocketServer, sendToClient } from 'ws';
const wss = new WebSocketServer({ port: 8765 });
wss.on('connection', (clientHandle: any) => {
console.log('server: client connected');
sendToClient(clientHandle, '{"hello":"world"}');
console.log('server: called sendToClient');
});
setInterval(() => { /* keep process alive */ }, 1000);
client.ts:
import { WebSocket } from 'ws';
const ws = new WebSocket('ws://127.0.0.1:8765');
ws.on('message', (data: any) => {
console.log('client: RECEIVED', String(data));
});
ws.on('open', () => console.log('client: open'));
setInterval(() => { /* keep alive */ }, 1000);
Steps:
perry compile server.ts -o server
perry compile client.ts -o client
./server &
./client
Expected: client: RECEIVED {"hello":"world"} prints.
Actual: Only client: open prints. The message is never delivered.
Evidence (static, from the compiler source)
crates/perry-codegen/src/lower_call.rs at HEAD (6b07a7e, 0.5.158) registers only 5 ws-module functions in the native dispatch table:
// ========== WebSocket (ws) ==========
NativeModSig { module: "ws", has_receiver: false, method: "Server", runtime: "js_ws_server_new", ... }, // line 4504
NativeModSig { module: "ws", has_receiver: false, method: "WebSocket", runtime: "js_ws_connect", ... }, // line 4507
NativeModSig { module: "ws", has_receiver: true, method: "on", runtime: "js_ws_on", ... }, // line 4510
NativeModSig { module: "ws", has_receiver: true, method: "send", runtime: "js_ws_send", ... }, // line 4513
NativeModSig { module: "ws", has_receiver: true, method: "close", runtime: "js_ws_close", ... }, // line 4516
sendToClient and closeClient are not in the table.
When perry lowers a receiver-less call to an unknown module function, it falls through to the silent stub at crates/perry-codegen/src/lower_call.rs:2592-2599:
// Receiver-less native method calls (e.g. plugin::setConfig(...)
// as a static module function): lower args for side effects and
// return TAG_UNDEFINED.
let Some(recv) = object else {
for a in args {
let _ = lower_expr(ctx, a)?;
}
return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)));
};
So the generated code evaluates the args (observable side effects) but emits no call to any js_ws_* function. Grepping the final binary shows no reference to js_ws_send from the sendToClient call sites — confirmed by comparing [WS-send] / [WS-srv-io] sending entries in /tmp/hone-ws-macos.log (debug logging inside perry-stdlib/src/ws.rs): zero sends logged over the lifetime of a running server despite many sendToClient(...) calls being executed.
Impact
Any program following the server-side ws.on('connection', (handle) => sendToClient(handle, msg)) pattern will appear to work (no compile error, console.log runs, args execute) but silently never talks back to clients. This silently broke the perry-hub WebSocket dispatch and the update_perry / job_assign flows there.
Proposed fix
Two small additions. I can send a PR if helpful.
1. crates/perry-stdlib/src/ws.rs — add two thin f64→i64 bridges next to the existing functions:
#[cfg(not(target_os = "ios"))]
#[no_mangle]
pub unsafe extern "C" fn js_ws_send_to_client(handle_f64: f64, message_ptr: *const StringHeader) {
js_ws_send(handle_f64 as i64, message_ptr);
}
#[cfg(not(target_os = "ios"))]
#[no_mangle]
pub unsafe extern "C" fn js_ws_close_client(handle_f64: f64) {
js_ws_close(handle_f64 as i64);
}
(Server-side client handles are delivered to the user via ws.on('connection', cb) as a number, so the caller passes an f64; js_ws_send / js_ws_close already handle the i64 form internally.)
2. crates/perry-codegen/src/lower_call.rs — register them in the ws block:
NativeModSig { module: "ws", has_receiver: false, method: "sendToClient",
class_filter: None,
runtime: "js_ws_send_to_client", args: &[NA_F64, NA_STR], ret: NR_VOID },
NativeModSig { module: "ws", has_receiver: false, method: "closeClient",
class_filter: None,
runtime: "js_ws_close_client", args: &[NA_F64], ret: NR_VOID },
Suggested follow-up (separate, larger)
To prevent this whole class of bug: when a named import is resolved from a module that perry has a native-dispatch table for (e.g. ws, fastify, mysql2, …), emit a compile-time warning if the imported identifier is used in a call position and is not found in the corresponding table. Right now the silent TAG_UNDEFINED stub masks typos and missing-wrapper bugs with no signal to the user.
Environment
- perry version: 0.5.158 (also observed on 0.5.155, 0.5.156, 0.5.157 and older — this has existed at least since the v0.5.0 LLVM cutover referenced in the dispatch-table comment at lower_call.rs:2582)
- Host: Linux x86_64, macOS arm64 — platform-independent; it's a compiler codegen issue.
Summary
Calls to
sendToClient(handle, msg)andcloseClient(handle)— both named imports from'ws'— are silently compiled to no-ops. Args are evaluated (so side effects likeJSON.stringify(...)run) but no FFI call to the ws runtime is emitted, so no WebSocket frame ever leaves the process.This affects the common server-side WS pattern:
Repro (minimal)
server.ts:client.ts:Steps:
Expected:
client: RECEIVED {"hello":"world"}prints.Actual: Only
client: openprints. The message is never delivered.Evidence (static, from the compiler source)
crates/perry-codegen/src/lower_call.rsat HEAD (6b07a7e, 0.5.158) registers only 5ws-module functions in the native dispatch table:sendToClientandcloseClientare not in the table.When perry lowers a receiver-less call to an unknown module function, it falls through to the silent stub at
crates/perry-codegen/src/lower_call.rs:2592-2599:So the generated code evaluates the args (observable side effects) but emits no call to any
js_ws_*function. Grepping the final binary shows no reference tojs_ws_sendfrom thesendToClientcall sites — confirmed by comparing[WS-send]/[WS-srv-io] sendingentries in/tmp/hone-ws-macos.log(debug logging insideperry-stdlib/src/ws.rs): zero sends logged over the lifetime of a running server despite manysendToClient(...)calls being executed.Impact
Any program following the server-side
ws.on('connection', (handle) => sendToClient(handle, msg))pattern will appear to work (no compile error, console.log runs, args execute) but silently never talks back to clients. This silently broke the perry-hub WebSocket dispatch and the update_perry / job_assign flows there.Proposed fix
Two small additions. I can send a PR if helpful.
1.
crates/perry-stdlib/src/ws.rs— add two thin f64→i64 bridges next to the existing functions:(Server-side client handles are delivered to the user via
ws.on('connection', cb)as a number, so the caller passes an f64;js_ws_send/js_ws_closealready handle the i64 form internally.)2.
crates/perry-codegen/src/lower_call.rs— register them in thewsblock:Suggested follow-up (separate, larger)
To prevent this whole class of bug: when a named import is resolved from a module that perry has a native-dispatch table for (e.g.
ws,fastify,mysql2, …), emit a compile-time warning if the imported identifier is used in a call position and is not found in the corresponding table. Right now the silent TAG_UNDEFINED stub masks typos and missing-wrapper bugs with no signal to the user.Environment