Repository navigation
Description
Found by the package audit (compiling real npm packages from source instead of Perry's native bindings) on
Perry e6dcb62 (v0.5.1587), Linux x64. On a net.Socket, prependListener and prependOnceListener read as undefined, but calling them does not throw. The call returns undefined, registers nothing, and the socket's 'data' never reaches the listener.
Reproduction
No dependencies. A local echo server is part of the script.
main.ts
import * as net from 'node:net';
const server = net.createServer((conn) => conn.on('data', (d) => conn.write(d))); // echo
server.listen(0, '127.0.0.1', async () => {
const port = (server.address() as net.AddressInfo).port;
for (const method of ['on', 'prependListener', 'prependOnceListener']) {
await new Promise<void>((resolve) => {
const sock: any = net.createConnection({ host: '127.0.0.1', port });
const timer = setTimeout(() => { console.log(method, '-> no data after 1s'); sock.destroy(); resolve(); }, 1000);
sock.on('connect', () => {
const ret = sock[method]('data', (d: Buffer) => {
console.log(method, '-> data', JSON.stringify(d.toString()));
clearTimeout(timer); sock.destroy(); resolve();
});
console.log(method, 'typeof:', typeof sock[method], 'returns socket:', ret === sock, 'listenerCount:', sock.listenerCount('data'));
sock.resume(); // prepend*Listener does not start flowing mode by itself
sock.write('ping');
});
});
}
server.close();
});node main.ts
perry compile main.ts -o out && ./out
Expected (Node 26.5.1)
on typeof: function returns socket: true listenerCount: 1
on -> data "ping"
prependListener typeof: function returns socket: true listenerCount: 1
prependListener -> data "ping"
prependOnceListener typeof: function returns socket: true listenerCount: 1
prependOnceListener -> data "ping"
Actual (Perry)
Same output with and without PERRY_NO_AUTO_OPTIMIZE=1:
on typeof: function returns socket: true listenerCount: 1
on -> data "ping"
prependListener typeof: undefined returns socket: false listenerCount: 0
prependListener -> no data after 1s
prependOnceListener typeof: undefined returns socket: false listenerCount: 0
prependOnceListener -> no data after 1s
The static form sock.prependListener('data', fn) behaves the same way. Calling a method that does not exist at all, such as sock.noSuchMethod('x'), also returns undefined. Node throws TypeError: sock.noSuchMethod is not a function. So a missing socket method shows up as a hang, not as an error.
Impact
- ioredis 5.11.1:
built/DataHandler.js:25attaches the RESP parser with
redis.stream.prependListener("data", (data) => parser.execute(data))and then callsredis.stream.resume(). Under Perry the parser
never gets a byte. The client connects and writesCLIENT SETINFO(the server receives it), but no reply ever resolves, so
readynever fires and every command promise hangs. - iovalkey 0.4.0: same code at
built/DataHandler.js:24. - If that line is changed to
redis.stream.on("data", …), the audit's full ioredis test matches Node byte for byte. The same is true for iovalkey.
The test covers strings with EX/ttl, incr/mget, hash, list, set, zset, pipeline, multi, eval, WRONGTYPE error, getBuffer, keyPrefix, pub/sub and quit.
This was the last Perry defect on that path after the workarounds below. - Any other stream consumer that uses
prependListeneron a socket is likely affected (inferred).
Notes
- The ioredis package run also needs workarounds for separate bugs:
(1)import IORedis from 'ioredis'instead ofimport Redis from 'ioredis', becausenew Redis(...)is still routed to the native binding even withcompilePackages;
(2)built/connectors/StandaloneConnector.js:54(0, net_1.createConnection)(…)returnsundefinedwhennetis used as a module value (node:netused as a value is inert:connect()/createConnection()return undefined,newon aSocketvalue is not a socket,isIP()returns undefined #10429);
(3) the socket has nowritableproperty, which deadlocks the offline queue (net.Socket has nowritable/readable/_writableState, andreadyState/connecting/pendingare undefined on an untyped receiver #10465).
These workarounds were applied as aglobalThiswrapper around a statically importednet.createConnection, with awritablegetter defined on the returned socket. - Location:
crates/perry-ext-net/src/dispatch.rs:154(socket_method_name) listson/addListener/once/off/
removeListener/listenerCount/… but notprependListener/prependOnceListener. The codegen native table fornet
Socket(crates/perry-codegen/src/lower_call/native_table/net_events.rs:220,:499-590) has no prepend entries either,
although theeventsandstreamtables do (:1468,:1633). Fix is inferred: register the listener at the front, asjs_ext_net_socket_ondoes at the back. - The silent
undefinedfor unknown methods on a native socket handle (instead of aTypeError) is what turned this into a hang (inferred: the dispatch falls through to an undefined property read). - Related: node:events EventEmitter —
listenerCount,eventNames,once,prependListeneretc. return wrong values; static helpers broken #850 (closed; same no-op forEventEmitter.prependListenerinnode:events), node:net: Socket and Server methods read as non-callable values #3397 (closed; net Socket methods read as non-callable).
Activity
wip: net.Socket surface cluster (#10441 #10442 #10444 #10465)
fix(net): clear connect-phase flags on the turnloop connect path