Skip to content

sendToClient / closeClient from 'ws' silently compile to no-ops (no FFI call emitted) #136

@proggeramlug

Description

@proggeramlug

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions