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. A client net.Socket has none of the stream state properties
(writable, readable, writableEnded, readableEnded, _writableState, _readableState). When the socket is
read through an untyped value, which is how every JS driver holds it (this.stream), the #2549 state properties
(readyState, connecting, pending) are undefined too, and destroyed stays false after 'close'.
Reproduction
server.mjs (plain Node echo server, run separately):
import net from "node:net";
net.createServer((s) => { s.on("data", (d) => s.write(d)); s.on("end", () => s.end()); })
.listen(Number(process.env.PORT), "127.0.0.1", () => console.log("READY"));main.ts:
import net from "node:net";
const keys = ["writable", "readable", "readyState", "connecting", "pending", "destroyed", "writableEnded", "readableEnded"];
function show(when: string, s: any) {
console.log(when.padEnd(12), keys.map((k) => `${k}=${s[k]}`).join(" "), `_writableState=${typeof s._writableState}`, `_readableState=${typeof s._readableState}`);
}
const fresh = new net.Socket();
show("new Socket", fresh);
const s = net.connect(Number(process.env.PORT), "127.0.0.1");
show("connecting", s);
s.on("connect", () => {
show("connected", s);
s.write("hi");
});
s.on("data", () => { show("data", s); s.end(); show("after end()", s); });
s.on("end", () => show("'end'", s));
s.on("close", () => show("'close'", s));PORT=18123 node server.mjs &
PORT=18123 node main.ts
perry compile main.ts -o main && PORT=18123 ./mainExpected (Node 26.5.1)
new Socket writable=true readable=true readyState=open connecting=false pending=true destroyed=false writableEnded=false readableEnded=false _writableState=object _readableState=object
connecting writable=true readable=true readyState=opening connecting=true pending=true destroyed=false writableEnded=false readableEnded=false _writableState=object _readableState=object
connected writable=true readable=true readyState=open connecting=false pending=false destroyed=false writableEnded=false readableEnded=false _writableState=object _readableState=object
data writable=true readable=true readyState=open connecting=false pending=false destroyed=false writableEnded=false readableEnded=false _writableState=object _readableState=object
after end() writable=false readable=true readyState=readOnly connecting=false pending=false destroyed=false writableEnded=true readableEnded=false _writableState=object _readableState=object
'end' writable=false readable=false readyState=closed connecting=false pending=false destroyed=false writableEnded=true readableEnded=true _writableState=object _readableState=object
'close' writable=false readable=false readyState=closed connecting=false pending=true destroyed=true writableEnded=true readableEnded=true _writableState=object _readableState=object
Actual (Perry)
Same output with and without PERRY_NO_AUTO_OPTIMIZE=1:
new Socket writable=undefined readable=undefined readyState=undefined connecting=undefined pending=undefined destroyed=false writableEnded=undefined readableEnded=undefined _writableState=undefined _readableState=undefined
connecting writable=undefined readable=undefined readyState=undefined connecting=undefined pending=undefined destroyed=false writableEnded=undefined readableEnded=undefined _writableState=undefined _readableState=undefined
connected writable=undefined readable=undefined readyState=undefined connecting=undefined pending=undefined destroyed=false writableEnded=undefined readableEnded=undefined _writableState=undefined _readableState=undefined
data writable=undefined readable=undefined readyState=undefined connecting=undefined pending=undefined destroyed=false writableEnded=undefined readableEnded=undefined _writableState=undefined _readableState=undefined
after end() writable=undefined readable=undefined readyState=undefined connecting=undefined pending=undefined destroyed=false writableEnded=undefined readableEnded=undefined _writableState=undefined _readableState=undefined
'end' writable=undefined readable=undefined readyState=undefined connecting=undefined pending=undefined destroyed=false writableEnded=undefined readableEnded=undefined _writableState=undefined _readableState=undefined
'close' writable=undefined readable=undefined readyState=undefined connecting=undefined pending=undefined destroyed=false writableEnded=undefined readableEnded=undefined _writableState=undefined _readableState=undefined
Impact
Network drivers check these properties before every write, so a falsy undefined makes them drop or queue writes
forever. Nothing throws.
- pg 8.22.0:
Connection._send(lib/connection.js:165) isif (!this.stream.writable) return false. Every
protocol message, including the startup message, is silently dropped, and the audit's client hung until its
connection timeout. With_sendpatched, two other blockers patched and trust auth, the audit's pg run matched
Node.lib/connection.js:191and:212have the same check. - ioredis 5.11.1 (
built/Redis.js:365) and iovalkey 0.4.0 (built/Redis.js:354):sendCommandtreats
!this.stream.writableas not writable and pushes every command into the offline queue. In the audit the commands
stayed queued and the client hung after connecting. - @redis/client (
dist/lib/client/socket.js:183,:298) checksreadable/writable/destroyedin the same way
(inferred, not run). - Likely also affects anything that checks
socket.writableorsocket._writableStatebefore writing: mysql2,
mongodb, SMTP/AMQP/MQTT clients.
Notes
- With a statically typed receiver (
const s: net.Socket = net.connect(...),s.readyState),
readyState/connecting/pending/destroyedresolve, butwritable/readableare stillundefined. The
typed values are also wrong while connecting: right afternet.connect(), Perry givesreadyState=open connecting=falsewhere Node givesopening true. Afterdestroy(), the typed read correctly gives
destroyed=true readyState=closed. Object.keys(socket)is empty in Perry.- Code (verified by reading):
- Typed reads go through the
netnative-table rows in
crates/perry-codegen/src/lower_call/native_table/net_events.rs:352-389. There is nowritable/readablerow
fornet. - Untyped reads go through
js_ext_net_handle_property_dispatch(crates/perry-ext-net/src/dispatch.rs:526).
It handlesdestroyed/bytesWritten/TLS fields and bound methods, but has no arm for
readyState/connecting/pending/writable/readable/writableEnded/_writableState. js_net_socket_get_connectingalways returnsfalse(crates/perry-ext-net/src/lifecycle.rs:138), and
js_net_socket_get_ready_stateonly returns"open"/"closed"(lifecycle.rs:160).destroyedstayingfalseafter a peer-initiated'end'/'close'suggests the peer-close path does not set
destroyed(inferred).
- Typed reads go through the
- Related: node:net: expose Socket state, address, and byte-counter properties #2549 (closed) added
readyState/connecting/pending/destroyedfor typed receivers. It did not cover
writable/readable/_writableState. node:net: Socket and Server methods read as non-callable values #3397 covers method reads onnet.Socket. Open issues from the same audit about other missingnet.Socketstream surface:net.Sockethas noprependListener/prependOnceListener: calling them silently does nothing, so the listener never sees data (ioredis/iovalkey hang waiting for replies) #10441 (prependListener),socket.on()/socket.addListener()returnundefinedwhen the receiver is typednet.Socket, sosock.on(...).on(...)throws #10442 (on()return value),net.Sockethas nopipe():socket.pipe(dest)silently returnsundefinedand no data flows (mongodbConnectionconstructor throws) #10444 (pipe()). No existing issue found for this
gap (searched: "net.Socket writable", "socket readyState", "net Socket connecting", "_writableState",
"socket.writable undefined").
Activity
wip: net.Socket surface cluster (#10441 #10442 #10444 #10465)
Merge origin/main into turnloop/integration
fix(net): clear connect-phase flags on the turnloop connect path