Repository navigation
AOT: sock.write() via Map-retrieved object silently drops ~100-byte packets inside 'data' callback (closure sock.write works) #91
Description
Environment
- perry 0.5.98
- macOS arm64 (darwin 25.4.0)
Problem
Inside a net.Socket 'data' event callback, calling obj.sock.write(bytes) where obj was retrieved from a Map.get() lookup silently fails to deliver the bytes to the peer, even though:
sock.write(bytes)returns immediately (withundefined— no error)- The same
bytesbuffer, written via the closure-capturedsockreference, reaches the server and gets a response - Small writes (≤5 bytes) via the Map path DO work; the failure appears only for larger writes (the driver hits it at 101 bytes)
This looks like the same class of bug as #87 (Promise resolve via property dispatch) but for Socket.write — the method call returns but the underlying I/O op never happens.
Minimal repro
// repro.ts — requires a TCP service that speaks first (any MySQL server).
import * as net from 'node:net';
import { writeHandshakeResponse41 } from '@perry/mysql/src/protocol/writer';
import { writePacket } from '@perry/mysql/src/protocol/framing';
import { DEFAULT_CLIENT_CAPABILITIES, CLIENT_CONNECT_ATTRS } from '@perry/mysql/src/protocol/capabilities';
import { sha256Scramble } from '@perry/mysql/src/auth/caching-sha2';
const STATES = new Map<number, { sock: net.Socket }>();
const sock = net.createConnection('YOUR.MYSQL.HOST' as never, 3306 as never);
STATES.set(1, { sock: sock });
let count = 0;
sock.on('data', (chunk: Buffer) => {
count += 1;
if (count === 1) {
// Extract 20-byte challenge from HandshakeV10 at known offsets, scramble, build packet...
// (elided for brevity — full repro in @perry/mysql tests/perry-aot-smoke.ts)
const bytes = /* 101-byte HandshakeResponse41 */;
const st = STATES.get(1);
if (st !== undefined) {
st.sock.write(bytes); // ← via Map-retrieved socket
}
console.log('[write] done');
} else {
console.log('[data #2] server responded!');
process.exit(0);
}
});
setTimeout(() => { console.log('timeout'); process.exit(1); }, 6000);Change the key line to:
sock.write(bytes); // ← closure-captured socket, same bytesand the program immediately receives the server's response.
Observed (Map path)
[data #1] len=95 — got HandshakeV10
[write] bytes.len=101
[write] done
timeout — server never sends AuthMoreData / OK / ERR
Expected (matches closure path and Node/Bun behaviour)
[data #1] len=95
[write] bytes.len=101
[write] done
[data #2] len=6 — server's AuthMoreData{0x03} FAST_AUTH_SUCCESS
Content verification
I verified the 101 bytes produced via the Map path are byte-identical to the bytes the closure path produces (both computed via writeHandshakeResponse41(...) + writePacket(1, payload)), and the MySQL server accepts the closure-sent version. So the bytes are valid; they're simply not being flushed when dispatched via obj.sock.write(...).
Hit in real code
@perry/mysql stores the net.Socket inside a ConnState struct keyed in CONN_STATES: Map<number, ConnState>. Every sendAuthFrame / sendCommand call does st.sock.write(bytes). Under Bun/Node this works perfectly (all 138 unit + 13 real-server integration tests pass). Under Perry 0.5.98 AOT, after #78–#88 were fixed, the driver successfully:
- Connects to MySQL 8.0.45 on webserver.skelpo.net
- Decodes HandshakeV10, receives 20-byte challenge
- Computes correct caching_sha2_password auth response (hex matches Node's output byte-for-byte)
- Builds valid 97-byte HandshakeResponse41 + 4-byte header = 101 bytes
- Calls
st.sock.write(bytes)— call returns normally - Server never responds → connect times out after 10 s
Workarounds on the @perry/mysql side are ugly (either flatten the state map out of the write path or pass the socket directly through every helper). I'd rather wait for a proper codegen fix.
Related
- AOT: Promise resolve() called via object-property/method access doesn't wake await site from socket 'data' callback #87 (resolve via property access) — same class of bug, also manifested inside
'data'callbacks. - AOT: crypto.createHash('sha256').update(buf).digest() returns Buffer with undefined length for FIRST call inside socket-callback context #88 (crypto digest length under async context) — also called from the same pipeline.
Activity
fix: gate handle dispatchers by method vocabulary (v0.5.99, #91)
proggeramlug commentedon Apr 24, 2026
Status: fixed in v0.5.145, verified in v0.5.190.
Root cause
When a
net.Socketis stored in aMapand retrieved viaMap.get(), the HIR's native-instance lookup (lower.rs:5748) requires a bareIdentreceiver but gets aMemberExpr(entry.sock). The call falls through to a genericExpr::Call { callee: PropertyGet }, which at codegen becomesjs_native_call_method→HANDLE_METHOD_DISPATCH→dispatch_net_socketinstead of theNATIVE_MODULE_TABLEfast path.The
dispatch_net_sockethandler (crates/perry-stdlib/src/common/dispatch.rs) was added in v0.5.145 and correctly services"write"calls arriving via that route — so the bug has been fixed onmainfor several versions.Regression test
A regression test (
test-files/test_sock_write_map.ts) covering the exact two-phase pattern from this issue is being added via PR #176. The test sends 4 bytes via a direct closure-captured socket (baseline), then 101 bytes via a Map-retrievedentry.sock— the shape that was silently dropping packets — and expects both echoes to round-trip correctly.Generated by Claude Code
test(net): regression test for #91 sock.write via Map-retrieved socket
fix(perry-ffi): refs #421/#420 — alloc_buffer through extern js_buffe…
fix(perry-stdlib+ext-net): refs #421/#420 — issue #91 HANDLE_METHOD_D…
fix: #797 — restructure known_failures.json with audit metadata (#819)