Skip to content

AOT: socket 'data' events stop firing after the first await of a socket-event-resolved Promise #647

Closed

Description

@proggeramlug

Summary

In an AOT-compiled binary, after await-ing a Promise that was resolved
from a net.Socket event listener (e.g. 'connect'), subsequent 'data'
events on that same socket are never delivered to listeners, even
though setInterval callbacks continue to fire and bytes do reach the
remote peer (Redis logs the inbound command).

The pure-callback equivalent works correctly — the issue only appears
once a Promise/microtask transition has happened off a socket event.

Same source runs correctly on Node.js and Bun.

Environment

  • perry 0.5.762
  • macOS 26.4 / Apple M1 Max
  • Reachable Redis on 127.0.0.1:36400 (any TCP echo-style server reproduces the same shape; Redis is just convenient because PING is one packet each way).

Minimal repro

// repro.ts
import * as net from 'net';

async function main(): Promise<void> {
    const s = net.createConnection(36400 as never, '127.0.0.1' as never);

    s.on('data', (b: Buffer) => {
        console.log('got', b.length, JSON.stringify(b.toString('utf8')));
        (s as any).end();
        process.exit(0);
    });

    // The await is the trigger — anything that suspends on a socket-event-
    // sourced Promise puts the socket's data path into a wedged state.
    await new Promise<void>((resolve) => {
        s.on('connect', () => resolve());
    });

    (s as any).write(Buffer.from('*1\r\n$4\r\nPING\r\n'));
    setTimeout(() => { console.log('TIMEOUT'); process.exit(2); }, 2000);
}
main();
# Spin up any TCP server that replies to bytes — Redis is easiest:
redis-server --port 36400 --daemonize yes --save '' --appendonly no

perry compile repro.ts -o repro
./repro            # hangs, prints TIMEOUT after 2s, exit 2

# Same source on Node + Bun:
node --import tsx repro.ts   # got 7 "+PONG\r\n"  exit 0
bun repro.ts                 # got 7 "+PONG\r\n"  exit 0

Behavior matrix (all under perry compile AOT)

Variation Result
Pure callbacks, no await anywhere (s.on('connect', () => { s.on('data', ...); s.write(...); })) works
await connect Promise, then register 'data' hangs (no data event)
Register 'data' before await connect Promise hangs (no data event)
await connect Promise, then setInterval polling interval fires but bytes-received stays 0
Wrap data in a Promise (new Promise(r => s.on('data', r))), await it hangs

The setInterval row is the smoking gun: the event loop is alive and dispatching timers, just not socket 'data' events on a socket that was previously the source of an awaited Promise resolution.

Why this matters

Any Node-shaped TCP driver — postgres, mysql, redis, http — uses an await-able connect handshake before reading the first reply. Under Perry-AOT they all wedge at the first server-sent byte. @perryts/postgres happens to work because its connection handshake does the first server read inside the same callback that fires 'connect' (no Promise transition between them); drivers that take the more conventional await-then-read shape break.

Repro and trace also recorded at: https://github.com/PerryTS/redis/blob/main/bench/RESULTS.md (the "Perry-AOT result" section). I dug into this while building a benchmark suite for @perryts/redis; the bench can use any pure-callback driver path, so this isn't blocking that package — but it does block AOT-compiled use of any await-based TCP client.

Happy to test patches — let me know.

Activity

  1. added a commit that references this issue on May 10, 2026
  2. proggeramlug commented on May 10, 2026

    @proggeramlug
    ContributorAuthor

    Investigated in 3ef40d3 (v0.5.770) but not fixed — needs deeper investigation.

    The agent's microtask-drain hypothesis turned out to be wrong. With step-logging instrumentation in the repro, the trace shows:

    step 1: createConnection
    step 2: setting up Promise
    step 3: executor running
    step 6: executor returning
    [net-dbg] process_pending events=1
    [net-dbg]   Connect id=1 listeners=1
    step 4: connect cb running, calling resolve()
    step 5: resolve() returned
    step 7: await done
    step 8: write returned
    TIMEOUT
    

    The await actually completes (step 7 fires), resolve() runs, and s.write(...) returns (step 8). But the data response from the server never reaches the user's 'data' listener.

    Adding instrumentation to perry-ext-net's js_net_socket_write (the function the static dispatch table at crates/perry-codegen/src/lower_call.rs:6701 routes s.write through) showed the print never fires under PERRY_NET_DBG=1. So either (s as any).write(buf) after the await is going down a different dispatch path than the static one, or the instrumented binary isn't picking up perry-ext-net's symbol due to the perry-stdlib/perry-ext-net symbol collision noted in v0.5.764's changelog (both expose #[no_mangle] js_net_socket_write — only one wins the link race).

    Filed for follow-up. The (s as any).write(...) cast in the repro may be relevant — without the cast, codegen has Socket type info; with the cast, it falls through to dynamic dispatch via js_native_call_method → HANDLE_METHOD_DISPATCH → dispatch_external_net_socket. That last hop's behavior is the bisection target.

  3. added a commit that references this issue on May 10, 2026
    0442a59
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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions