Repository navigation
AOT: socket 'data' events stop firing after the first await of a socket-event-resolved Promise #647
Description
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 0Behavior 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
fix: closes #645 #648 #649 + partial #650 — refs #647 (v0.5.770)
proggeramlug commentedon May 10, 2026
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:
The
awaitactually completes (step 7 fires),resolve()runs, ands.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 atcrates/perry-codegen/src/lower_call.rs:6701routess.writethrough) showed the print never fires underPERRY_NET_DBG=1. So either(s as any).write(buf)after theawaitis 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 viajs_native_call_method→HANDLE_METHOD_DISPATCH→dispatch_external_net_socket. That last hop's behavior is the bisection target.fix: closes PerryTS#647 — `await`-on-socket-event-Promise no longer w…
fix(net): socket.write() from inside a 'data' handler reaches the wire (