Skip to content

AOT: sock.write() via Map-retrieved object silently drops ~100-byte packets inside 'data' callback (closure sock.write works) #91

Closed

Description

@proggeramlug

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 (with undefined — no error)
  • The same bytes buffer, written via the closure-captured sock reference, 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 bytes

and 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:

  1. Connects to MySQL 8.0.45 on webserver.skelpo.net
  2. Decodes HandshakeV10, receives 20-byte challenge
  3. Computes correct caching_sha2_password auth response (hex matches Node's output byte-for-byte)
  4. Builds valid 97-byte HandshakeResponse41 + 4-byte header = 101 bytes
  5. Calls st.sock.write(bytes) — call returns normally
  6. 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

Activity

  1. proggeramlug commented on Apr 24, 2026

    @proggeramlug
    ContributorAuthor

    Status: fixed in v0.5.145, verified in v0.5.190.

    Root cause

    When a net.Socket is stored in a Map and retrieved via Map.get(), the HIR's native-instance lookup (lower.rs:5748) requires a bare Ident receiver but gets a MemberExpr (entry.sock). The call falls through to a generic Expr::Call { callee: PropertyGet }, which at codegen becomes js_native_call_method → HANDLE_METHOD_DISPATCH → dispatch_net_socket instead of the NATIVE_MODULE_TABLE fast path.

    The dispatch_net_socket handler (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 on main for 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-retrieved entry.sock — the shape that was silently dropping packets — and expects both echoes to round-trip correctly.


    Generated by Claude Code

  2. added a commit that references this issue on Apr 24, 2026
  3. added 2 commits that reference this issue on May 6, 2026
  4. added a commit that references this issue on May 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions