diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index d353ffaa97c6..434da30c9641 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -37,6 +37,8 @@ For worker/subprocess-shaped changes, spawn a subprocess (still `-e`) so worker formula shadows the pinned nightly, and `bun bd` dies with `the option 'Z' is only accepted on the nightly compiler`. `bun bd` re-runs cargo on every invocation, so this is needed for follow-up runs too, not just the first build. +- `node:cluster` changes can't be driven with `-e`: `cluster.fork()` re-execs `argv[1]`, so workers need a real file on disk. Write a scratch script and run `./build/debug/bun-debug `. +- Only one `bun bd` per worktree at a time — a second one blocks on the build lock and looks like a runtime hang. Build once, then drive `./build/debug/bun-debug` directly under `timeout`. - `BUN_DEBUG_QUIET_LOGS=1` suppresses debug-build log spam. - Debug builds print `[cachefs]`/`[sys]` lines to stdout; filter them before diffing output against `node`. diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index bee84866b337..35c1ae1bf461 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -135,7 +135,8 @@ int bsd_sendmmsg(LIBUS_SOCKET_DESCRIPTOR fd, struct udp_sendbuf* sendbuf, int fl #endif } -int bsd_recvmmsg(LIBUS_SOCKET_DESCRIPTOR fd, struct udp_recvbuf *recvbuf, int flags) { +int bsd_recvmmsg(LIBUS_SOCKET_DESCRIPTOR fd, struct udp_recvbuf *recvbuf, int flags, int max_packets) { + if (max_packets > LIBUS_UDP_RECV_COUNT) max_packets = LIBUS_UDP_RECV_COUNT; #if defined(_WIN32) for (int i = 0; i < LIBUS_UDP_RECV_COUNT; i++) { while (1) { @@ -162,12 +163,12 @@ int bsd_recvmmsg(LIBUS_SOCKET_DESCRIPTOR fd, struct udp_recvbuf *recvbuf, int fl #elif defined(__APPLE__) if (Bun__doesMacOSVersionSupportSendRecvMsgX()) { while (1) { - int ret = recvmsg_x(fd, recvbuf->msgvec, LIBUS_UDP_RECV_COUNT, flags); + int ret = recvmsg_x(fd, recvbuf->msgvec, max_packets, flags); if (ret >= 0 || errno != EINTR) return ret; } } - for (int i = 0; i < LIBUS_UDP_RECV_COUNT; ++i) { + for (int i = 0; i < max_packets; ++i) { while (1) { ssize_t ret = recvmsg(fd, &recvbuf->msgvec[i].msg_hdr, flags); if (ret < 0) { @@ -179,10 +180,10 @@ int bsd_recvmmsg(LIBUS_SOCKET_DESCRIPTOR fd, struct udp_recvbuf *recvbuf, int fl break; } } - return LIBUS_UDP_RECV_COUNT; + return max_packets; #else while (1) { - int ret = recvmmsg(fd, (struct mmsghdr *)&recvbuf->msgvec, LIBUS_UDP_RECV_COUNT, flags, 0); + int ret = recvmmsg(fd, (struct mmsghdr *)&recvbuf->msgvec, max_packets, flags, 0); if (ret >= 0 || errno != EINTR) return ret; } #endif @@ -1216,6 +1217,140 @@ int bsd_set_defer_accept(LIBUS_SOCKET_DESCRIPTOR listenFd) { // return LIBUS_SOCKET_ERROR or the fd that represents listen socket // listen both on ipv6 and ipv4 +int bsd_socket_export_size(void) { +#ifdef _WIN32 + return (int) sizeof(WSAPROTOCOL_INFOW); +#else + return 0; +#endif +} + +int bsd_socket_export(LIBUS_SOCKET_DESCRIPTOR fd, unsigned int target_pid, void *info_out) { +#ifdef _WIN32 + if (WSADuplicateSocketW(fd, (DWORD) target_pid, (WSAPROTOCOL_INFOW *) info_out) != 0) { + return WSAGetLastError(); + } + return 0; +#else + (void) fd; (void) target_pid; (void) info_out; + return ENOTSUP; +#endif +} + +LIBUS_SOCKET_DESCRIPTOR bsd_socket_import(void *info, int *err) { +#ifdef _WIN32 + SOCKET s = WSASocketW(FROM_PROTOCOL_INFO, FROM_PROTOCOL_INFO, FROM_PROTOCOL_INFO, + (WSAPROTOCOL_INFOW *) info, 0, WSA_FLAG_OVERLAPPED); + if (s == INVALID_SOCKET) { + *err = WSAGetLastError(); + return LIBUS_SOCKET_ERROR; + } + return s; +#else + (void) info; + *err = ENOTSUP; + return LIBUS_SOCKET_ERROR; +#endif +} + +/* Windows rejects listen() on a duplicate of an already-listening socket, where POSIX + * no-ops it; cluster workers each listen on their own dup of one shared fd. libuv + * sidesteps this by listening before the xfer (UV_HANDLE_SHARED_TCP_SOCKET, win/tcp.c). */ +int bsd_socket_listen_error_is_benign(LIBUS_SOCKET_DESCRIPTOR fd) { +#ifdef _WIN32 + int listening = 0; + int optlen = (int) sizeof(listening); + if (getsockopt(fd, SOL_SOCKET, SO_ACCEPTCONN, (char *) &listening, &optlen) != 0) { + return 0; + } + return listening != 0; +#else + (void) fd; + return 0; +#endif +} + +LIBUS_SOCKET_DESCRIPTOR bsd_create_bound_socket(const char *host, int port, int options, int *out_port, int *error) { + struct addrinfo hints, *result; + memset(&hints, 0, sizeof(struct addrinfo)); + hints.ai_flags = AI_PASSIVE; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + + char port_string[16]; + snprintf(port_string, 16, "%d", port); + + int gai = getaddrinfo(host, port_string, &hints, &result); + if (gai != 0) { +#ifdef _WIN32 + *error = gai; +#else + *error = EINVAL; +#endif + return LIBUS_SOCKET_ERROR; + } + + LIBUS_SOCKET_DESCRIPTOR fd = LIBUS_SOCKET_ERROR; + for (int family = AF_INET6; fd == LIBUS_SOCKET_ERROR && family >= AF_INET; family -= (AF_INET6 - AF_INET)) { + for (struct addrinfo *a = result; a != NULL; a = a->ai_next) { + if (a->ai_family != family) { + continue; + } + fd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, NULL); + if (fd == LIBUS_SOCKET_ERROR) { + *error = LIBUS_ERR; + continue; + } +#if defined(SO_REUSEADDR) && !defined(_WIN32) + int one = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); +#endif +#ifdef IPV6_V6ONLY + if (a->ai_family == AF_INET6) { + int enabled = (options & LIBUS_SOCKET_IPV6_ONLY) != 0; + setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, (void *) &enabled, sizeof(enabled)); + } +#endif + int rc; + do + rc = bind(fd, a->ai_addr, (socklen_t) a->ai_addrlen); + while (IS_EINTR(rc)); + if (rc != 0) { + *error = LIBUS_ERR; + bsd_close_socket(fd); + fd = LIBUS_SOCKET_ERROR; + continue; + } + break; + } + } + freeaddrinfo(result); + if (fd == LIBUS_SOCKET_ERROR) { + return LIBUS_SOCKET_ERROR; + } +#ifdef _WIN32 + /* Windows rejects listen() on a duplicate of an already-listening socket + * only after another duplicate has listened, so two workers racing on the + * same shared fd can observe listen() -> WSAEINVAL while SO_ACCEPTCONN + * still reads 0 (the benign check in us_socket_group_listen_fd then + * fails). libuv's approach (UV_HANDLE_SHARED_TCP_SOCKET, win/tcp.c) is to + * listen in the primary before WSADuplicateSocket; every worker's + * duplicate is then already listening and SO_ACCEPTCONN is reliably set. */ + if (listen(fd, 511) != 0) { + *error = LIBUS_ERR; + bsd_close_socket(fd); + return LIBUS_SOCKET_ERROR; + } +#endif + struct bsd_addr_t tmp; + if (bsd_local_addr(fd, &tmp) == 0) { + *out_port = bsd_addr_get_port(&tmp); + } else { + *out_port = port; + } + return fd; +} + LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int options, int* error) { struct addrinfo hints, *result; memset(&hints, 0, sizeof(struct addrinfo)); diff --git a/packages/bun-usockets/src/context.c b/packages/bun-usockets/src/context.c index 68b4216266b3..6005f46f7e26 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -418,6 +418,40 @@ struct us_listen_socket_t *us_socket_group_listen(struct us_socket_group_t *grou return ls; } +struct us_listen_socket_t *us_socket_group_listen_fd(struct us_socket_group_t *group, + unsigned char kind, struct ssl_ctx_st *ssl_ctx, + LIBUS_SOCKET_DESCRIPTOR fd, int backlog, int options, int socket_ext_size, int *error) { + /* Validate with listen(2) before touching the descriptor's flags: on failure the caller keeps + * the fd (it may be its stdio), and a non-socket must come back untouched. */ + if (listen(fd, backlog > 0 ? backlog : 512)) { + int listen_err = LIBUS_ERR; + if (!bsd_socket_listen_error_is_benign(fd)) { + *error = listen_err; + return 0; + } + } + apple_no_sigpipe(fd); + bsd_set_nonblocking(fd); + + struct us_poll_t *p = us_create_poll(group->loop, 0, sizeof(struct us_listen_socket_t)); + us_poll_init(p, fd, POLL_TYPE_SEMI_SOCKET); + if (us_poll_start_rc(p, group->loop, LIBUS_SOCKET_READABLE) != 0) { + int saved_errno = LIBUS_ERR; + us_poll_free(p, group->loop); + *error = saved_errno; + return 0; + } + + struct us_listen_socket_t *ls = (struct us_listen_socket_t *) p; + us_internal_init_listen_socket(ls, group, kind, ssl_ctx, options, socket_ext_size); + + if (options & LIBUS_LISTEN_DEFER_ACCEPT) { + ls->deferred_accept = bsd_set_defer_accept(fd); + } + + return ls; +} + struct us_listen_socket_t *us_socket_group_listen_unix(struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, const char *path, size_t pathlen, int options, int socket_ext_size, int *error) { diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index e1eb0551b3ef..93737d33bdd2 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -574,4 +574,4 @@ int us_socket_get_error(struct us_socket_t *s) { return error; } -#endif \ No newline at end of file +#endif diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 8444fa14b641..8b9b46e22b1f 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -390,6 +390,7 @@ struct us_udp_socket_t { uint16_t port; uint16_t closed : 1; uint16_t connected : 1; + uint16_t shared_fd : 1; struct us_udp_socket_t *next; }; diff --git a/packages/bun-usockets/src/internal/networking/bsd.h b/packages/bun-usockets/src/internal/networking/bsd.h index 239d0979d754..4637ddde065e 100644 --- a/packages/bun-usockets/src/internal/networking/bsd.h +++ b/packages/bun-usockets/src/internal/networking/bsd.h @@ -160,7 +160,7 @@ struct udp_sendbuf { }; int bsd_sendmmsg(LIBUS_SOCKET_DESCRIPTOR fd, struct udp_sendbuf* sendbuf, int flags); -int bsd_recvmmsg(LIBUS_SOCKET_DESCRIPTOR fd, struct udp_recvbuf *recvbuf, int flags); +int bsd_recvmmsg(LIBUS_SOCKET_DESCRIPTOR fd, struct udp_recvbuf *recvbuf, int flags, int max_packets); void bsd_udp_setup_recvbuf(struct udp_recvbuf *recvbuf, void *databuf, size_t databuflen); int bsd_udp_setup_sendbuf(struct udp_sendbuf *buf, size_t bufsize, void** payloads, size_t* lengths, void** addresses, int num); int bsd_udp_packet_buffer_payload_length(struct udp_recvbuf *msgvec, int index); @@ -243,6 +243,13 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket(struct sockaddr_storage *addr, LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket_unix(const char *server_path, size_t pathlen, int options); +int bsd_socket_export_size(void); +int bsd_socket_export(LIBUS_SOCKET_DESCRIPTOR fd, unsigned int target_pid, void *info_out); +LIBUS_SOCKET_DESCRIPTOR bsd_socket_import(void *info, int *err); +int bsd_socket_listen_error_is_benign(LIBUS_SOCKET_DESCRIPTOR fd); + +LIBUS_SOCKET_DESCRIPTOR bsd_create_bound_socket(const char *host, int port, int options, int *out_port, int *error); + #ifndef MSG_DONTWAIT #define MSG_DONTWAIT 0 #endif diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index cf62e75eee44..00a2a76c936d 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -227,6 +227,7 @@ struct us_udp_packet_buffer_t *us_create_udp_packet_buffer(); struct us_udp_socket_t *us_create_udp_socket(us_loop_r loop, void (*data_cb)(struct us_udp_socket_t *, void *, int), void (*drain_cb)(struct us_udp_socket_t *), void (*close_cb)(struct us_udp_socket_t *), void (*recv_error_cb)(struct us_udp_socket_t *, int, int), const char *host, unsigned short port, int flags, int *err, void *user); + void us_udp_socket_close(struct us_udp_socket_t *s); int us_udp_socket_set_broadcast(struct us_udp_socket_t *s, int enabled); @@ -242,7 +243,7 @@ LIBUS_SOCKET_DESCRIPTOR us_udp_socket_fd(struct us_udp_socket_t *s); /* Adopts an already created (and usually already bound) UDP socket descriptor * instead of creating a new one. The fd is made non-blocking and the standard * receive-path options are applied. Returns null with *err set on failure. */ -struct us_udp_socket_t *us_create_udp_socket_from_fd(us_loop_r loop, void (*data_cb)(struct us_udp_socket_t *, void *, int), void (*drain_cb)(struct us_udp_socket_t *), void (*close_cb)(struct us_udp_socket_t *), void (*recv_error_cb)(struct us_udp_socket_t *, int, int), LIBUS_SOCKET_DESCRIPTOR fd, int *err, void *user); +struct us_udp_socket_t *us_create_udp_socket_from_fd(us_loop_r loop, void (*data_cb)(struct us_udp_socket_t *, void *, int), void (*drain_cb)(struct us_udp_socket_t *), void (*close_cb)(struct us_udp_socket_t *), void (*recv_error_cb)(struct us_udp_socket_t *, int, int), LIBUS_SOCKET_DESCRIPTOR fd, int shared, int *err, void *user); /* This one is ugly, should be ext! not user */ void *us_udp_socket_user(struct us_udp_socket_t *s); @@ -395,6 +396,10 @@ struct us_listen_socket_t *us_socket_group_listen_unix(us_socket_group_r group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, const char *path, size_t pathlen, int options, int socket_ext_size, int *error) __attribute__((nonnull(1, 4, 8))); /* ssl_ctx nullable */ +struct us_listen_socket_t *us_socket_group_listen_fd(us_socket_group_r group, + unsigned char kind, struct ssl_ctx_st *ssl_ctx, + LIBUS_SOCKET_DESCRIPTOR fd, int backlog, int options, int socket_ext_size, int *error) + __attribute__((nonnull(1, 8))); /* ssl_ctx nullable */ void us_listen_socket_close(struct us_listen_socket_t *ls) nonnull_fn_decl; /* SNI: tree hangs off the listen socket. ssl_ctx is up_ref'd; user is opaque @@ -706,7 +711,7 @@ LIBUS_SOCKET_DESCRIPTOR us_socket_get_fd(us_socket_r s) nonnull_fn_decl; /* Bun extras */ struct us_socket_t *us_socket_pair(us_socket_group_r group, unsigned char kind, int socket_ext_size, LIBUS_SOCKET_DESCRIPTOR *fds) nonnull_fn_decl; -struct us_socket_t *us_socket_from_fd(us_socket_group_r group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, int socket_ext_size, LIBUS_SOCKET_DESCRIPTOR fd, int ipc) +struct us_socket_t *us_socket_from_fd(us_socket_group_r group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, int socket_ext_size, LIBUS_SOCKET_DESCRIPTOR fd, int options, int ipc) __attribute__((nonnull(1))); /* ssl_ctx nullable */ struct us_socket_t *us_socket_open(struct us_socket_t *s, int is_client, char *ip, int ip_length); int us_raw_root_certs(struct us_cert_string_t **out); diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 7df70af56ea2..594e4906ac92 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -961,7 +961,7 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in do { struct udp_recvbuf recvbuf; bsd_udp_setup_recvbuf(&recvbuf, u->loop->data.recv_buf, LIBUS_RECV_BUFFER_LENGTH); - int npackets = bsd_recvmmsg(us_poll_fd(p), &recvbuf, MSG_DONTWAIT); + int npackets = bsd_recvmmsg(us_poll_fd(p), &recvbuf, MSG_DONTWAIT, u->shared_fd ? 1 : LIBUS_UDP_RECV_COUNT); if (npackets > 0) { u->on_data(u, &recvbuf, npackets); } else { diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index b731ca98a373..d2db3d7fbdf6 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -405,7 +405,7 @@ struct us_socket_t *us_socket_pair(struct us_socket_group_t *group, unsigned cha return 0; } - return us_socket_from_fd(group, kind, NULL, socket_ext_size, fds[0], 0); + return us_socket_from_fd(group, kind, NULL, socket_ext_size, fds[0], 0, 0); #endif } @@ -445,10 +445,7 @@ int us_socket_write2(struct us_socket_t *s, const char *header, int header_lengt return written < 0 ? 0 : written; } -struct us_socket_t *us_socket_from_fd(struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, int socket_ext_size, LIBUS_SOCKET_DESCRIPTOR fd, int ipc) { -#if defined(LIBUS_USE_LIBUV) || defined(WIN32) - return 0; -#else +struct us_socket_t *us_socket_from_fd(struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, int socket_ext_size, LIBUS_SOCKET_DESCRIPTOR fd, int options, int ipc) { struct us_poll_t *p1 = us_create_poll(group->loop, 0, sizeof(struct us_socket_t) + socket_ext_size); us_poll_init(p1, fd, POLL_TYPE_SOCKET); int rc = us_poll_start_rc(p1, group->loop, LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE); @@ -464,7 +461,7 @@ struct us_socket_t *us_socket_from_fd(struct us_socket_group_t *group, unsigned s->timeout = 255; s->long_timeout = 255; s->flags.low_prio_state = 0; - s->flags.allow_half_open = 0; + s->flags.allow_half_open = (options & LIBUS_SOCKET_ALLOW_HALF_OPEN) != 0; s->flags.is_paused = 0; s->flags.is_ipc = ipc; s->flags.is_closed = 0; @@ -490,7 +487,6 @@ struct us_socket_t *us_socket_from_fd(struct us_socket_group_t *group, unsigned } return s; -#endif } void *us_socket_get_native_handle(struct us_socket_t *s) { @@ -690,12 +686,21 @@ int us_socket_ipc_write_fd(struct us_socket_t *s, const char *data, int length, int sent = bsd_sendmsg(us_poll_fd(&s->p), &msg, 0); + if (sent < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == ENOBUFS) { + s->flags.last_write_failed = 1; + us_poll_change(&s->p, s->group->loop, LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE); + return 0; + } + return -1; + } + if (sent != length) { s->flags.last_write_failed = 1; us_internal_rearm_writable(s); } - return sent < 0 ? 0 : sent; + return sent; } #endif diff --git a/packages/bun-usockets/src/udp.c b/packages/bun-usockets/src/udp.c index 17b8ddaf7175..9e898a037348 100644 --- a/packages/bun-usockets/src/udp.c +++ b/packages/bun-usockets/src/udp.c @@ -171,25 +171,26 @@ int us_udp_socket_set_source_specific_membership(struct us_udp_socket_t *s, cons return bsd_socket_set_source_specific_membership(us_poll_fd(&s->p), source, group, iface, drop); } -struct us_udp_socket_t *us_create_udp_socket( + +struct us_udp_socket_t *us_create_udp_socket_from_fd( struct us_loop_t *loop, void (*data_cb)(struct us_udp_socket_t *, void *, int), void (*drain_cb)(struct us_udp_socket_t *), void (*close_cb)(struct us_udp_socket_t *), void (*recv_error_cb)(struct us_udp_socket_t *, int, int), - const char *host, - unsigned short port, - int flags, + LIBUS_SOCKET_DESCRIPTOR fd, + int shared, int *err, void *user ) { - - /* IP_RECVERR is only useful when there is an on_recv_error handler to - * drain the error queue; without one it only poisons subsequent sends. */ - if (recv_error_cb) flags |= LIBUS_UDP_LINUX_RECVERR; - else flags &= ~LIBUS_UDP_LINUX_RECVERR; - LIBUS_SOCKET_DESCRIPTOR fd = bsd_create_udp_socket(host, port, flags, err); - if (fd == LIBUS_SOCKET_ERROR) { + if (bsd_prepare_adopted_udp_socket(fd)) { + if (err != NULL) { +#ifdef _WIN32 + *err = WSAGetLastError(); +#else + *err = errno; +#endif + } return 0; } @@ -198,28 +199,18 @@ struct us_udp_socket_t *us_create_udp_socket( struct us_poll_t *p = us_create_poll(loop, fallthrough, sizeof(struct us_udp_socket_t) + ext_size); us_poll_init(p, fd, POLL_TYPE_UDP); - if (us_poll_start_rc(p, loop, LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE) != 0) { - int saved_errno = errno; - bsd_close_socket(fd); - us_poll_free(p, loop); - if (err) *err = saved_errno; - errno = saved_errno; - return 0; - } struct us_udp_socket_t *udp = (struct us_udp_socket_t *)p; - /* Get and store the port once */ struct bsd_addr_t tmp = {0}; bsd_local_addr(fd, &tmp); udp->port = bsd_addr_get_port(&tmp); udp->loop = loop; - /* There is no udp socket context, only user data */ - /* This should really be ext like everything else */ udp->user = user; udp->closed = 0; + udp->shared_fd = shared ? 1 : 0; udp->connected = 0; udp->on_data = data_cb; udp->on_drain = drain_cb; @@ -227,27 +218,36 @@ struct us_udp_socket_t *us_create_udp_socket( udp->on_recv_error = recv_error_cb; udp->next = NULL; + if (us_poll_start_rc((struct us_poll_t *) udp, udp->loop, LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE) != 0) { + int saved_errno = errno; + us_poll_free(p, loop); + if (err) *err = saved_errno; + errno = saved_errno; + return 0; + } + return (struct us_udp_socket_t *) udp; } -struct us_udp_socket_t *us_create_udp_socket_from_fd( +struct us_udp_socket_t *us_create_udp_socket( struct us_loop_t *loop, void (*data_cb)(struct us_udp_socket_t *, void *, int), void (*drain_cb)(struct us_udp_socket_t *), void (*close_cb)(struct us_udp_socket_t *), void (*recv_error_cb)(struct us_udp_socket_t *, int, int), - LIBUS_SOCKET_DESCRIPTOR fd, + const char *host, + unsigned short port, + int flags, int *err, void *user ) { - if (bsd_prepare_adopted_udp_socket(fd)) { - if (err != NULL) { -#ifdef _WIN32 - *err = WSAGetLastError(); -#else - *err = errno; -#endif - } + + /* IP_RECVERR is only useful when there is an on_recv_error handler to + * drain the error queue; without one it only poisons subsequent sends. */ + if (recv_error_cb) flags |= LIBUS_UDP_LINUX_RECVERR; + else flags &= ~LIBUS_UDP_LINUX_RECVERR; + LIBUS_SOCKET_DESCRIPTOR fd = bsd_create_udp_socket(host, port, flags, err); + if (fd == LIBUS_SOCKET_ERROR) { return 0; } @@ -256,20 +256,29 @@ struct us_udp_socket_t *us_create_udp_socket_from_fd( struct us_poll_t *p = us_create_poll(loop, fallthrough, sizeof(struct us_udp_socket_t) + ext_size); us_poll_init(p, fd, POLL_TYPE_UDP); + if (us_poll_start_rc(p, loop, LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE) != 0) { + int saved_errno = errno; + bsd_close_socket(fd); + us_poll_free(p, loop); + if (err) *err = saved_errno; + errno = saved_errno; + return 0; + } struct us_udp_socket_t *udp = (struct us_udp_socket_t *)p; - /* Get and store the port. An adopted-but-unbound descriptor reports 0 — - * bound_port() re-queries in that case so socket.address() reflects the - * ephemeral port the kernel assigns on the first sendto(). */ + /* Get and store the port once */ struct bsd_addr_t tmp = {0}; bsd_local_addr(fd, &tmp); udp->port = bsd_addr_get_port(&tmp); udp->loop = loop; + /* There is no udp socket context, only user data */ + /* This should really be ext like everything else */ udp->user = user; udp->closed = 0; + udp->shared_fd = 0; udp->connected = 0; udp->on_data = data_cb; udp->on_drain = drain_cb; @@ -277,17 +286,5 @@ struct us_udp_socket_t *us_create_udp_socket_from_fd( udp->on_recv_error = recv_error_cb; udp->next = NULL; - /* Unlike us_create_udp_socket we don't own the adopted fd, so on failure - * only the poll is freed and the caller keeps the descriptor. */ - if (us_poll_start_rc((struct us_poll_t *) udp, udp->loop, LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE) != 0) { - int saved_errno = errno; - us_poll_free((struct us_poll_t *) udp, loop); - if (err) { - *err = saved_errno; - } - errno = saved_errno; - return 0; - } - return (struct us_udp_socket_t *) udp; } diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index c2efbaa08d25..fb23b64664d8 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -108,6 +108,8 @@ using namespace JSC; macro(ignoreBOM) \ macro(importer) \ macro(inherits) \ + macro(internal) \ + macro(internalMessage) \ macro(internalModuleRegistry) \ macro(internalRequire) \ macro(isAbortSignal) \ @@ -172,6 +174,7 @@ using namespace JSC; macro(sameSite) \ macro(secure) \ macro(self) \ + macro(sharedFd) \ macro(signal) \ macro(size) \ macro(specifier) \ diff --git a/src/js/builtins/Ipc.ts b/src/js/builtins/Ipc.ts index ec1c567c253c..0e27f37a3c91 100644 --- a/src/js/builtins/Ipc.ts +++ b/src/js/builtins/Ipc.ts @@ -2,7 +2,6 @@ /** * @typedef {Object} Serialized * @property {"NODE_HANDLE"} cmd - * @property {unknown} message * @property {"net.Socket" | "net.Server" | "dgram.Socket"} type */ /** @@ -11,68 +10,37 @@ /** * @param {unknown} message * @param {Handle} handle - * @param {{ keepOpen?: boolean } | undefined} options * @returns {[unknown, Serialized] | null} */ -export function serialize(_message, _handle, _options) { - // sending file descriptors is not supported yet - return null; // send the message without the file descriptor - - /* +export function serialize(message, handle, options) { const net = require("node:net"); - const dgram = require("node:dgram"); if (handle instanceof net.Server) { - // this one doesn't need a close function, but the fd needs to be kept alive until it is sent - const server = handle as unknown as (typeof net)["Server"] & { _handle: Bun.TCPSocketListener }; - return [server._handle, { cmd: "NODE_HANDLE", message, type: "net.Server" }]; - } else if (handle instanceof net.Socket) { - const new_message: { cmd: "NODE_HANDLE"; message: unknown; type: "net.Socket"; key?: string } = { - cmd: "NODE_HANDLE", - message, - type: "net.Socket", - }; - const socket = handle as unknown as (typeof net)["Socket"] & { - _handle: Bun.Socket; - server: (typeof net)["Server"] | null; - setTimeout(timeout: number): void; - }; - if (!socket._handle) return null; // failed - - // If the socket was created by net.Server - if (socket.server) { - // The worker should keep track of the socket - new_message.key = socket.server._connectionKey; - - const firstTime = !this[kChannelHandle].sockets.send[message.key]; - const socketList = getSocketList("send", this, message.key); - - // The server should no longer expose a .connection property - // and when asked to close it should query the socket status from - // the workers - if (firstTime) socket.server._setupWorker(socketList); - - // Act like socket is detached - if (!options?.keepOpen) socket.server._connections--; - } - - const internal_handle = socket._handle; - - // Remove handle from socket object, it will be closed when the socket - // will be sent + const native = handle._handle; + if (!native) return null; + return [native, { cmd: "NODE_HANDLE", msg: message, type: "net.Server" }]; + } + if (handle instanceof net.Socket) { + // Only plain TCP sockets cross processes; a TLS session cannot (node: ERR_INVALID_HANDLE_TYPE). + if (typeof handle[Symbol.for("::buntls::")] === "function") throw $ERR_INVALID_HANDLE_TYPE(); + const native = handle._handle; + if (!native) return null; if (!options?.keepOpen) { - // we can use a $newRustFunction to have it unset the callback - internal_handle.onread = nop; - socket._handle = null; - socket.setTimeout(0); + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/child_process.js#L120-L148 + const { server } = handle; + if (server) server._connections--; + handle.setTimeout(0); + native.data = undefined; + handle._handle = null; } - return [internal_handle, new_message]; - } else if (handle instanceof dgram.Socket) { - // this one doesn't need a close function, but the fd needs to be kept alive until it is sent - throw new Error("todo serialize dgram.Socket"); - } else { - throw $ERR_INVALID_HANDLE_TYPE(); + return [native, { cmd: "NODE_HANDLE", msg: message, type: "net.Socket" }]; + } + if (handle instanceof require("node:dgram").Socket) { + const { kStateSymbol } = require("internal/dgram"); + const native = handle[kStateSymbol]?.handle?.socket; + if (!native) return null; + return [native, { cmd: "NODE_HANDLE", msg: message, type: "dgram.Socket", dgramType: handle.type }]; } - */ + throw $ERR_INVALID_HANDLE_TYPE(); } /** * @param {Serialized} serialized @@ -87,13 +55,16 @@ export function parseHandle(target, serialized, fd) { switch (serialized.type) { case "net.Server": { const server = new net.Server(); - server.listen({ fd }, () => { - emit(target, serialized.message, server); + server.listen({ fd, exclusive: true }, () => { + emit(target, serialized.msg, server); }); return; } case "net.Socket": { - throw new Error("TODO case net.Socket"); + const socket = new net.Socket({ readable: true, writable: true }); + socket.connect({ fd, fdIsRawSocket: true }); + emit(target, serialized.msg, socket); + return; } case "dgram.Native": { // A non-reading UDP handle (cluster-shared dgram socket): wrap the @@ -106,11 +77,17 @@ export function parseHandle(target, serialized, fd) { require("node:fs").closeSync(fd); throw new Error(`failed to open received dgram handle: ${err}`); } - emit(target, serialized.message, wrap); + emit(target, serialized.msg, wrap); return; } case "dgram.Socket": { - throw new Error("TODO case dgram.Socket"); + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/child_process.js handleConversion['dgram.Socket'].got + const dgram = require("node:dgram"); + const socket = new dgram.Socket(serialized.dgramType || "udp4"); + socket.bind({ fd, exclusive: true }, () => { + emit(target, serialized.msg, socket); + }); + return; } default: { throw new Error("failed to parse handle"); diff --git a/src/js/internal/cluster/RoundRobinHandle.ts b/src/js/internal/cluster/RoundRobinHandle.ts index 6df1ef306384..c5e85176bea7 100644 --- a/src/js/internal/cluster/RoundRobinHandle.ts +++ b/src/js/internal/cluster/RoundRobinHandle.ts @@ -4,13 +4,12 @@ const { kHandle } = require("internal/shared"); let net; const sendHelper = $newRustFunction("node_cluster_binding.rs", "sendHelperPrimary", 4); +const uvTranslateSysError = $newRustFunction("node_util_binding.rs", "uvTranslateSysError", 1); +const { UV_EINVAL } = process.binding("uv"); const ArrayIsArray = Array.isArray; const UV_TCP_IPV6ONLY = 1; -const assert_fail = () => { - throw new Error("ERR_INTERNAL_ASSERTION"); -}; export default class RoundRobinHandle { key; @@ -19,6 +18,8 @@ export default class RoundRobinHandle { handles; handle; server; + listening; + inFlight; constructor(key, address, { port, fd, flags, backlog, readableAll, writableAll }) { net ??= require("node:net"); @@ -27,7 +28,12 @@ export default class RoundRobinHandle { this.free = new Map(); this.handles = init(Object.create(null)); this.handle = null; - this.server = net.createServer(assert_fail); + this.listening = false; + this.inFlight = new Map(); + this.server = net.createServer( + { pauseOnConnect: true, allowHalfOpen: true }, + RoundRobinHandle.prototype.onServerConnection.bind(this), + ); if (fd >= 0) this.server.listen({ fd, backlog }); else if (port >= 0) { @@ -45,20 +51,40 @@ export default class RoundRobinHandle { readableAll, writableAll, }); // UNIX socket path. - this.server.once("listening", () => { - this.handle = this.server._handle; - this.handle.onconnection = (err, handle) => this.distribute(err, handle); - this.server._handle = null; - this.server = null; - }); + this.server.once("listening", RoundRobinHandle.prototype.onServerListening.bind(this)); + } + + onServerConnection(socket) { + const handle = makeAcceptedHandle(socket); + socket.on("error", noop); + socket.once("close", RoundRobinHandle.prototype.onAcceptedSocketClose.bind(this, handle)); + this.distribute(0, handle); + } + + onAcceptedSocketClose(handle) { + remove(handle); + const inFlight = this.inFlight; + for (const [id, pending] of inFlight) { + if (pending === handle) { + inFlight.delete(id); + const worker = this.all.get(id); + if (worker !== undefined) this.handoff(worker); + break; + } + } + } + + onServerListening() { + this.listening = true; + this.handle = this.server._handle; } add(worker, send) { - // $assert(this.all.has(worker.id) === false); + $assert(this.all.has(worker.id) === false); this.all.set(worker.id, worker); const done = () => { - if (this.handle.getsockname) { + if (this.handle.getsockname && typeof this.server.address() === "object") { const out = {}; this.handle.getsockname(out); // TODO(bnoordhuis) Check err. @@ -70,16 +96,32 @@ export default class RoundRobinHandle { this.handoff(worker); // In case there are connections pending. }; - if (this.server === null) return done(); + if (this.listening) return done(); // Still busy binding. this.server.once("listening", done); this.server.once("error", err => { - send(err.errno, null); + const raw = typeof err.errno === "number" && err.errno !== 0 ? err.errno : null; + send(raw != null ? uvTranslateSysError(raw) : UV_EINVAL, null, null); }); } - remove(worker) { + has(worker) { + return this.all.has(worker.id); + } + + // With the channel still up the unacked newconn is settled by its ack; once it is gone, a crashed worker's goes to another worker and a disconnected worker's (already settled by it) is dropped. + remove(worker, channelGone = false) { + if (channelGone) { + const pending = this.inFlight.get(worker.id); + if (pending !== undefined) { + this.inFlight.delete(worker.id); + const others = this.all.size - (this.all.has(worker.id) ? 1 : 0); + if (!worker.exitedAfterDisconnect && others > 0) this.distribute(0, pending); + else pending.close(); + } + } + const existed = this.all.delete(worker.id); if (!existed) return false; @@ -88,13 +130,18 @@ export default class RoundRobinHandle { if (this.all.size !== 0) return false; + // Winding down: whatever is still in flight is the workers' now; drop the primary's copies. + for (const pending of this.inFlight.values()) pending.close(); + this.inFlight.clear(); + while (!isEmpty(this.handles)) { const handle = peek(this.handles); handle.close(); remove(handle); } - this.handle?.stop(false); + this.server?.close(); + this.server = null; this.handle = null; return true; } @@ -120,22 +167,56 @@ export default class RoundRobinHandle { return; // Worker is closing (or has closed) the server. } - const handle = peek(this.handles); + for (;;) { + const handle = peek(this.handles); - if (handle === null) { - this.free.set(worker.id, worker); // Add to ready queue again. - return; - } + if (handle === null) { + this.free.set(worker.id, worker); // Add to ready queue again. + return; + } - remove(handle); + remove(handle); - const message = { act: "newconn", key: this.key }; + const message = { act: "newconn", key: this.key }; - sendHelper(worker.process[kHandle], message, handle, reply => { - if (reply.accepted) handle.close(); - else this.distribute(0, handle); // Worker is shutting down. Send to another. + this.inFlight.set(worker.id, handle); + const sent = sendHelper(worker.process[kHandle], message, handle, reply => { + if (this.inFlight.get(worker.id) !== handle) return; + this.inFlight.delete(worker.id); + if (reply.accepted) handle.close(); + else this.distribute(0, handle); // Worker is shutting down. Send to another. - this.handoff(worker); - }); + this.handoff(worker); + }); + if (sent !== null) return; + + const { id } = worker; + this.inFlight.delete(id); + if (handle.fd < 0) { + // Peer went away while queued: drop it and move on to the next connection. + handle.close(); + continue; + } + this.distribute(0, handle); + if (this.all.has(id)) { + this.free.set(id, worker); + } + return; + } } } + +function noop() {} + +function makeAcceptedHandle(socket) { + return { + get fd() { + const nativeSocket = socket._handle; + return socket.destroyed || !nativeSocket ? -1 : nativeSocket.fd; + }, + close(cb?) { + socket.destroy(); + if (typeof cb === "function") process.nextTick(cb); + }, + }; +} diff --git a/src/js/internal/cluster/SharedHandle.ts b/src/js/internal/cluster/SharedHandle.ts index 3e9c09378196..1f2c2a3c1c6b 100644 --- a/src/js/internal/cluster/SharedHandle.ts +++ b/src/js/internal/cluster/SharedHandle.ts @@ -1,23 +1,39 @@ -// Port of Node's lib/internal/cluster/shared_handle.js, limited to dgram -// sockets: the primary creates one bound, non-reading handle per key and every -// worker that asks for it receives the same descriptor over IPC. -const { _createSocketHandle } = require("internal/dgram"); +const clusterRawBind = $newRustFunction("node_cluster_binding.rs", "clusterRawBind", 4); +const closeRawHandle = $newRustFunction("node_cluster_binding.rs", "clusterCloseHandle", 1); +const validateFd = $newRustFunction("node_cluster_binding.rs", "clusterValidateFd", 1); export default class SharedHandle { key; - workers = new Map(); - handle = null; - errno = 0; - data = undefined; + workers; + handle; + errno; + data; + sharedOnly; - constructor(key, address, { port, addressType, fd, flags }) { + constructor(key, address, { port, addressType, fd, flags, sharedOnly }) { this.key = key; + this.workers = new Map(); + this.handle = null; + this.errno = 0; + this.data = undefined; + this.sharedOnly = sharedOnly === true; - const rval = _createSocketHandle(address, port, addressType, fd, flags); - if (typeof rval === "number") { - this.errno = rval; - } else { + if (typeof fd === "number" && fd >= 0) { + const err = validateFd(fd); + if (err !== 0) { + this.errno = err; + } else { + this.handle = { fd, port }; + } + return; + } + const rval = clusterRawBind(addressType, address, typeof port === "number" ? port : 0, flags | 0); + if (typeof rval === "number") this.errno = rval; + else { this.handle = rval; + if (addressType === -1 && (typeof address !== "string" || address.charCodeAt(0) !== 0)) { + this.handle.path = address; + } } } @@ -27,19 +43,29 @@ export default class SharedHandle { send(this.errno, null, this.handle); } + has(worker) { + return this.workers.has(worker.id); + } + remove(worker) { - if (!this.workers.has(worker.id)) { - return false; - } + const workers = this.workers; + if (!workers.has(worker.id)) return false; - this.workers.delete(worker.id); + workers.delete(worker.id); - if (this.workers.size !== 0) { - return false; - } + if (workers.size !== 0) return false; - this.handle.close(); - this.handle = null; + const handle = this.handle; + if (handle) { + const { fd, path } = handle; + closeRawHandle(fd); + if (path) { + try { + require("node:fs").unlinkSync(path); + } catch {} + } + this.handle = null; + } return true; } } diff --git a/src/js/internal/cluster/child.ts b/src/js/internal/cluster/child.ts index 56fc5d7ee0bc..931814704426 100644 --- a/src/js/internal/cluster/child.ts +++ b/src/js/internal/cluster/child.ts @@ -1,9 +1,10 @@ const EventEmitter = require("node:events"); const Worker = require("internal/cluster/Worker"); const path = require("node:path"); +const { kClusterOwner: owner_symbol, kInternalSendOptions } = require("internal/shared"); -const sendHelper = $newRustFunction("node_cluster_binding.rs", "sendHelperChild", 3); const onInternalMessage = $newRustFunction("node_cluster_binding.rs", "onInternalMessageChild", 2); +const closeRawHandle = $newRustFunction("node_cluster_binding.rs", "clusterCloseHandle", 1); const FunctionPrototype = Function.prototype; const ArrayPrototypeJoin = Array.prototype.join; @@ -15,7 +16,22 @@ const indexes = new Map(); const noop = FunctionPrototype; const TIMEOUT_MAX = 2 ** 31 - 1; const kNoFailure = 0; -const { owner_symbol } = require("internal/async_hooks").symbols; +let seq = 0; +const callbacks = new Map(); + +function makeConnectionHandle(fd) { + let closed = false; + return { + fd, + close(cb?) { + if (!closed) { + closed = true; + closeRawHandle(fd); + } + if (typeof cb === "function") process.nextTick(cb); + }, + }; +} export default cluster; @@ -52,8 +68,30 @@ cluster._setupWorker = function () { send({ act: "online" }); function onmessage(message, handle) { - if (message.act === "newconn") onconnection(message, handle); - else if (message.act === "disconnect") worker._disconnect(true); + const ack = message.ack; + if (ack !== undefined) { + const callback = callbacks.$get(ack); + if (callback !== undefined) { + callbacks.$delete(ack); + callback.$call(this, message, handle); + return; + } + } + if (message.act === "newconn" && typeof handle === "number" && handle >= 0) { + handle = makeConnectionHandle(handle); + } + try { + process.emit("internalMessage", message, handle); + } catch (e) { + process.nextTick(() => { + throw e; + }); + } + if (message.act === "newconn") { + onconnection(message, handle); + } else if (message.act === "disconnect") { + worker._disconnect(true); + } } }; @@ -87,9 +125,14 @@ cluster._getServer = function (obj, options, cb) { // Set custom data on handle (i.e. tls tickets key) if (obj._getServerData) message.data = obj._getServerData(); + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/cluster/child.js#L105-L115 send(message, (reply, handle) => { if (typeof obj._setServerData === "function") obj._setServerData(reply.data); + if (typeof handle === "number" && handle >= 0) { + handle = makeSharedHandle(handle); + } + if (handle) { // Shared listen socket shared(reply, { handle, indexesKey, index }, cb); @@ -124,28 +167,49 @@ function removeIndexesKey(indexesKey, index) { } } +function makeSharedHandle(fd) { + let fdOpen = true; + const handle = { + sharedFd: fd, + adopted: false, + close(cb?) { + // A close() that ran while `adopted` was set leaves the fd to the adopter; a later release (adopted cleared) still closes it. + if (fdOpen && !handle.adopted) { + fdOpen = false; + closeRawHandle(fd); + } + if (typeof cb === "function") process.nextTick(cb); + }, + }; + return handle; +} + // Shared listen socket. function shared(message, { handle, indexesKey, index }, cb) { const key = message.key; // Monkey-patch the close() method so we can keep track of when it's // closed. Avoids resource leaks when the handle is short-lived. const close = handle.close; + let released = false; handle.close = function () { - send({ act: "close", key }); - handles.delete(key); - removeIndexesKey(indexesKey, index); + if (!released) { + released = true; + send({ act: "close", key }); + handles.delete(key); + removeIndexesKey(indexesKey, index); + } return close.$apply(handle, arguments); }; $assert(handles.has(key) === false); handles.set(key, handle); - cb(message.errno, handle); + cb(message.errno, handle, message); } // Round-robin. Master distributes handles across workers. function rr(message, { indexesKey, index }, cb) { const errno = message.errno; - if (errno) return cb(errno, null); + if (errno) return cb(errno, null, message); let key = message.key; @@ -205,7 +269,7 @@ function rr(message, { indexesKey, index }, cb) { $assert(handles.has(key) === false); handles.set(key, handle); - cb(0, handle); + cb(0, handle, message); } // Round-robin connection. @@ -228,7 +292,11 @@ function onconnection(message, handle) { } function send(message, cb?) { - return sendHelper(message, null, cb); + if (!process.connected) return false; + const wire = { __proto__: null, cmd: "NODE_CLUSTER", ...message, seq }; + if (typeof cb === "function") callbacks.$set(seq, cb); + seq += 1; + return process.send(wire, undefined, kInternalSendOptions); } // Extend generic Worker with methods specific to worker processes. @@ -254,9 +322,11 @@ Worker.prototype._disconnect = function (this: typeof Worker, primaryInitiated?) // it's primary initiated there's no need to send the // exitedAfterDisconnect message if (primaryInitiated) { - process.disconnect(); + if (process.connected) process.disconnect(); } else { - send({ act: "exitedAfterDisconnect" }, () => process.disconnect()); + send({ act: "exitedAfterDisconnect" }, () => { + if (process.connected) process.disconnect(); + }); } } } diff --git a/src/js/internal/cluster/isPrimary.ts b/src/js/internal/cluster/isPrimary.ts index f7116833fb00..4885e3df72b2 100644 --- a/src/js/internal/cluster/isPrimary.ts +++ b/src/js/internal/cluster/isPrimary.ts @@ -1,4 +1,4 @@ // tiny module to shortcut getting access to this boolean without loading the entire node:cluster module export default { - isPrimary: Bun.env.NODE_UNIQUE_ID == null, + isPrimary: !Object.prototype.hasOwnProperty.$call(process.env, "NODE_UNIQUE_ID"), }; diff --git a/src/js/internal/cluster/primary.ts b/src/js/internal/cluster/primary.ts index 2fda340f37db..d4840fccbcbf 100644 --- a/src/js/internal/cluster/primary.ts +++ b/src/js/internal/cluster/primary.ts @@ -1,14 +1,14 @@ const EventEmitter = require("node:events"); const Worker = require("internal/cluster/Worker"); -const RoundRobinHandle = require("internal/cluster/RoundRobinHandle"); -const SharedHandle = require("internal/cluster/SharedHandle"); -const path = require("node:path"); -const { throwNotImplemented, kHandle } = require("internal/shared"); +const { kHandle } = require("internal/shared"); const sendHelper = $newRustFunction("node_cluster_binding.rs", "sendHelperPrimary", 4); const onInternalMessage = $newRustFunction("node_cluster_binding.rs", "onInternalMessagePrimary", 3); +const { UV_EINVAL, UV_ENOBUFS } = process.binding("uv"); let child_process; +let RoundRobinHandle; +let SharedHandle; const ArrayPrototypeSlice = Array.prototype.slice; const ObjectValues = Object.values; @@ -39,13 +39,7 @@ const schedulingPolicyEnv = process.env.NODE_CLUSTER_SCHED_POLICY; let schedulingPolicy = 0; if (schedulingPolicyEnv === "rr") schedulingPolicy = SCHED_RR; else if (schedulingPolicyEnv === "none") schedulingPolicy = SCHED_NONE; -else if (process.platform === "win32") { - // // Round-robin doesn't perform well on - // // Windows due to the way IOCP is wired up. - // schedulingPolicy = SCHED_NONE; - // TODO - schedulingPolicy = SCHED_RR; -} else schedulingPolicy = SCHED_RR; +else schedulingPolicy = SCHED_RR; cluster.schedulingPolicy = schedulingPolicy; cluster.setupPrimary = function (options) { @@ -105,11 +99,12 @@ function removeWorker(worker) { } } -function removeHandlesForWorker(worker) { +// channelGone: the channel is closed, so nothing the worker still holds can be acked (a primary disconnect() keeps it up until the acks arrive). +function removeHandlesForWorker(worker, channelGone) { if (!worker) throw new Error("ERR_INTERNAL_ASSERTION"); handles.forEach((handle, key) => { - if (handle.remove(worker)) handles.delete(key); + if (handle.remove(worker, channelGone)) handles.delete(key); }); } @@ -136,7 +131,7 @@ cluster.fork = function (env) { * still want to access it. */ if (!worker.isConnected()) { - removeHandlesForWorker(worker); + removeHandlesForWorker(worker, true); removeWorker(worker); } @@ -153,7 +148,7 @@ cluster.fork = function (env) { * associated with this worker because it is * not connected to the primary anymore. */ - removeHandlesForWorker(worker); + removeHandlesForWorker(worker, true); /* * Remove the worker from the workers list only @@ -225,15 +220,42 @@ function queryServer(worker, message) { // Stop processing if worker already disconnecting if (worker.exitedAfterDisconnect) return; - const key = `${message.address}:${message.port}:${message.addressType}:` + `${message.fd}:${message.index}`; - let handle = handles.get(key); + RoundRobinHandle ??= require("internal/cluster/RoundRobinHandle"); + SharedHandle ??= require("internal/cluster/SharedHandle"); + + const key = + `${message.address}:${message.port}:${message.addressType}:${message.fd}` + + (message.port === 0 ? `:${message.index}` : ""); + const cachedHandle = handles.get(key); + let handle; + if (cachedHandle && !cachedHandle.has(worker)) handle = cachedHandle; + + const kSharedOnlyHint = + "TLS and non-TLS cluster workers cannot share the same address:port under SCHED_RR " + + "(Bun's TLS accept is native and cannot adopt round-robin connection fds)"; + if (handle !== undefined && message.sharedOnly === true && handle instanceof RoundRobinHandle) { + send(worker, { errno: UV_EINVAL, key, ack: message.seq, data: handle.data, bunHint: kSharedOnlyHint }, null); + return; + } + if ( + schedulingPolicy === SCHED_RR && + handle !== undefined && + message.sharedOnly !== true && + handle instanceof SharedHandle && + handle.sharedOnly && + message.addressType !== "udp4" && + message.addressType !== "udp6" + ) { + send(worker, { errno: UV_EINVAL, key, ack: message.seq, data: handle.data, bunHint: kSharedOnlyHint }, null); + return; + } if (handle === undefined) { let address = message.address; // Find shortest path for unix sockets because of the ~100 byte limit if (message.port < 0 && typeof address === "string" && process.platform !== "win32") { - address = path.relative(process.cwd(), address); + address = require("node:path").relative(process.cwd(), address); if (message.address.length < address.length) address = message.address; } @@ -241,37 +263,36 @@ function queryServer(worker, message) { // UDP is exempt from round-robin connection balancing for what should // be obvious reasons: it's connectionless. There is nothing to send to // the workers except raw datagrams and that's pointless. - if (message.addressType === "udp4" || message.addressType === "udp6") { - if (process.platform === "win32") { - // Sharing a dgram descriptor with a worker is not supported on - // Windows. Node's write of the handle fails with ENOTSUP on the - // primary-side Worker object and the worker never gets a reply — - // node's test-dgram-bind-shared-ports.js asserts exactly that. - const error = new Error(`write ENOTSUP - cannot share a dgram socket with a worker on Windows`); - error.code = "ENOTSUP"; - error.syscall = "write"; - worker.emit("error", error); - return; - } + if (process.platform === "win32" && (message.addressType === "udp4" || message.addressType === "udp6")) { + const error = new Error(`write ENOTSUP - cannot share a dgram socket with a worker on Windows`); + error.code = "ENOTSUP"; + error.syscall = "write"; + worker.emit("error", error); + return; + } + if ( + schedulingPolicy !== SCHED_RR || + message.sharedOnly === true || + message.addressType === "udp4" || + message.addressType === "udp6" + ) { handle = new SharedHandle(key, address, message); - } else if (schedulingPolicy !== SCHED_RR) { - throwNotImplemented("node:cluster SCHED_NONE"); } else { handle = new RoundRobinHandle(key, address, message); } - handles.set(key, handle); + if (!cachedHandle) handles.set(key, handle); } if (!handle.data) handle.data = message.data; // Set custom server data - handle.add(worker, (errno, reply, handle) => { - const { data } = handles.get(key); + handle.add(worker, (errno, reply, serverHandle) => { + const data = handles.get(key)?.data; - if (errno) handles.delete(key); // Gives other workers a chance to retry. + if (errno && !cachedHandle) handles.delete(key); - send( + const sent = send( worker, { errno, @@ -280,8 +301,14 @@ function queryServer(worker, message) { data, ...reply, }, - handle, + serverHandle, ); + if (sent === null && serverHandle !== null && serverHandle !== undefined) { + send(worker, { errno: UV_ENOBUFS, key, ack: message.seq, data }, null); + // The worker never got the handle, so it will never send act:close for it. + if (handle.remove(worker) && handles.get(key) === handle) handles.delete(key); + } + if (cachedHandle && handle !== cachedHandle && !errno) handle.remove(worker); }); } @@ -308,13 +335,6 @@ function close(worker, message) { } function send(worker, message, handle?, cb?) { - if (handle) { - // Descriptor-bearing replies travel as a NODE_HANDLE envelope so the - // worker pairs the descriptor with the message and acks it; the inner - // message is marked NODE_CLUSTER so it is dispatched as a cluster-internal - // message rather than a process 'message' event. - message = { cmd: "NODE_HANDLE", type: "dgram.Native", message: { ...message, cmd: "NODE_CLUSTER" } }; - } return sendHelper(worker.process[kHandle], message, handle, cb); } @@ -323,7 +343,7 @@ Worker.prototype.disconnect = function () { this.exitedAfterDisconnect = true; send(this, { act: "disconnect" }); this.process.disconnect(); - removeHandlesForWorker(this); + removeHandlesForWorker(this, false); removeWorker(this); return this; }; diff --git a/src/js/internal/shared.ts b/src/js/internal/shared.ts index 26eba1d3b031..861066c73058 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -147,6 +147,11 @@ function once(callback, { preserveReturnValue = false } = kEmptyObject) { const kEmptyObject = ObjectFreeze(Object.create(null)); +// process.send() options marking cluster-internal traffic; the flag is a private name so user code cannot set it. +const kInternalSendOptions: any = Object.create(null); +$putByIdDirectPrivate(kInternalSendOptions, "internal", true); +ObjectFreeze(kInternalSendOptions); + // Node invokes fs/dns callbacks via InternalMakeCallback, so a throw becomes uncaughtException // (not unhandledRejection); Bun runs them from a promise reaction so we reroute the throw. // https://github.com/nodejs/node/blob/main/src/api/callback.cc @@ -422,8 +427,10 @@ export default { PerformanceNodeEntry, kHandle: Symbol("kHandle"), + kClusterOwner: Symbol("kClusterOwner"), kAutoDestroyed: Symbol("kAutoDestroyed"), kWeakHandler: Symbol("kWeak"), kGetNativeReadableProto: Symbol("kGetNativeReadableProto"), kEmptyObject, + kInternalSendOptions, }; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 7ea18c049190..89b6db00bacd 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -18,7 +18,7 @@ const { validateFunction, validateOneOf, } = require("internal/validators"); -const { ConnResetException, hasObserver, startPerf, stopPerf } = require("internal/shared"); +const { ConnResetException, hasObserver, startPerf, stopPerf, kInternalSendOptions } = require("internal/shared"); const kServerResponseStatistics = Symbol("ServerResponseStatistics"); const { isPrimary } = require("internal/cluster/isPrimary"); @@ -110,7 +110,6 @@ function traceServerRequestEnd() { } const getBunServerAllClosedPromise = $newRustFunction("node_http_binding.rs", "getBunServerAllClosedPromise", 1); -const sendHelper = $newRustFunction("node_cluster_binding.rs", "sendHelperChild", 3); const kServerResponse = Symbol("ServerResponse"); const kChunkedEncoding = Symbol("kChunkedEncoding"); @@ -321,6 +320,10 @@ function Server(options, callback): void { this.on("listening", setupConnectionsTracking); this.on("connection", connectionListener); + this.prependListener("connection", socket => { + if (socket != null && typeof socket === "object") socket.server = this; + }); + this.listening = false; this._unref = false; this.timeout = 0; @@ -641,8 +644,6 @@ Server.prototype.listen = function () { if (cluster === undefined) cluster = require("node:cluster"); - // TODO: our net.Server and http.Server use different Bun APIs and our IPC doesnt support sending and receiving handles yet. use reusePort instead for now. - // const serverQuery = { // // address: address, // port: port, @@ -664,15 +665,21 @@ Server.prototype.listen = function () { // }); server.once("listening", () => { + // No channel (NODE_UNIQUE_ID inherited by a plain child, or already disconnected): nothing to notify. + if (!process.connected) return; cluster.worker.state = "listening"; const address = server.address(); + const isObjectAddress = address !== null && typeof address === "object"; + const boundHost = host && isObjectAddress ? address : null; const message = { + cmd: "NODE_CLUSTER", act: "listening", - port: (address && address.port) || port, + port: socketPath ? -1 : (isObjectAddress && address.port) || port, data: null, - addressType: 4, + address: socketPath ?? (boundHost && boundHost.address) ?? null, + addressType: socketPath ? -1 : boundHost && boundHost.family === "IPv6" ? 6 : 4, }; - sendHelper(message, null); + process.send(message, undefined, kInternalSendOptions); }); server[kRealListen](tls, port, host, socketPath, true, onListen); diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 4af87463bd8f..4513246f98f1 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -36,7 +36,9 @@ const ArrayPrototypeSplice = Array.prototype.splice; var ArrayBufferIsView = ArrayBuffer.isView; var NumberIsInteger = Number.isInteger; +var ObjectHasOwn = Object.hasOwn; var StringPrototypeIncludes = String.prototype.includes; +var StringPrototypeStartsWith = String.prototype.startsWith; var Uint8ArrayPrototypeIncludes = Uint8Array.prototype.includes; const MAX_BUFFER = 1024 * 1024; @@ -1500,7 +1502,7 @@ class ChildProcess extends EventEmitter { } #emitIpcMessage(message, _, handle) { - this.emit("message", message, handle); + this.emit(isInternalIpcMessage(message) ? "internalMessage" : "message", message, handle); } #send(message, handle, options, callback) { @@ -1675,6 +1677,35 @@ const nodeToBunLookup = { ipc: "ipc", }; +const INTERNAL_IPC_PREFIX = "NODE_"; + +function isInternalIpcMessage(message) { + if (message === null || typeof message !== "object") return false; + if (!ObjectHasOwn(message, "cmd")) return false; + const cmd = message.cmd; + if (typeof cmd !== "string" || cmd.length <= INTERNAL_IPC_PREFIX.length) return false; + return StringPrototypeStartsWith.$call(cmd, INTERNAL_IPC_PREFIX); +} + +function streamFdOf(item): number | undefined { + const itemFd = ObjectHasOwn(item, "fd") ? item.fd : undefined; + if (typeof itemFd === "number") return itemFd; + + const handle = item._handle; + const handleFd = handle ? handle.fd : undefined; + if (typeof handleFd === "number") return handleFd; + + if (item.destroyed) return undefined; + + const sink = item[require("internal/fs/streams").kWriteStreamFastPath]; + if (sink && sink !== true) { + const fd = sink._getFd(); + if (typeof fd === "number" && fd >= 0) return fd; + } + + return undefined; +} + function nodeToBun(item: string, index: number): string | number | null | NodeJS.TypedArray | ArrayBufferView { // If not defined, use the default. // For stdin/stdout/stderr, it's pipe. For others, it's ignore. @@ -1686,21 +1717,13 @@ function nodeToBun(item: string, index: number): string | number | null | NodeJS if (typeof item === "number") { return item; } - if (isNodeStreamReadable(item)) { - const itemFd = Object.hasOwn(item, "fd") ? item.fd : undefined; - if (typeof itemFd === "number") return itemFd; - const handle = item._handle; - const handleFd = handle ? handle.fd : undefined; - if (typeof handleFd === "number") return handleFd; - throw new Error(`TODO: stream.Readable stdio @ ${index}`); - } - if (isNodeStreamWritable(item)) { - const itemFd = Object.hasOwn(item, "fd") ? item.fd : undefined; - if (typeof itemFd === "number") return itemFd; - const handle = item._handle; - const handleFd = handle ? handle.fd : undefined; - if (typeof handleFd === "number") return handleFd; - throw new Error(`TODO: stream.Writable stdio @ ${index}`); + if (isNodeStreamReadable(item) || isNodeStreamWritable(item)) { + const fd = streamFdOf(item); + if (fd !== undefined) return fd; + const kind = isNodeStreamReadable(item) ? "Readable" : "Writable"; + throw new Error( + `Passing a stream.${kind} without an underlying file descriptor as stdio[${index}] is not yet implemented in Bun`, + ); } const result = nodeToBunLookup[item]; if (result === undefined) { diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 6ed0fe9b9b56..2324ae3ef9bc 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -684,26 +684,34 @@ function bindServerHandle(self, options, errCb) { const closeWrap = handle.close; handle.close = function () { handle.close = closeWrap; - if (state.handle) { + if (state.sharedHandle === handle) { // Detach first so Socket#close() doesn't re-enter this handle and // invoke the original close twice. state.sharedHandle = undefined; - self.close(); + if (state.handle) self.close(); } return closeWrap.$apply(this, arguments); }; state.sharedHandle = handle; - startBunSocket(self, state, { fd: handle.fd }); + // Set before the async adoption so a close() racing it cannot free the fd; releaseSharedHandle() undoes it on failure. + handle.adopted = true; + startBunSocket(self, state, { fd: handle.sharedFd ?? handle.fd }, handle); }); } +function releaseSharedHandle(state, handle) { + if (state.sharedHandle === handle) state.sharedHandle = undefined; + handle.adopted = false; + handle.close(); +} + // Creates the underlying Bun.udpSocket for `self` and completes the bind: // either from a resolved hostname/port or by adopting an existing descriptor // (`{ fd }`). Mirrors what Node's startListening() makes observable before // 'listening' fires. -function startBunSocket(self, state, createOptions) { +function startBunSocket(self, state, createOptions, sharedHandle?) { try { - Bun.udpSocket({ + const udpOptions: any = { ...createOptions, socket: { data: (_socket, data, port, address, flags) => { @@ -741,7 +749,10 @@ function startBunSocket(self, state, createOptions) { self.emit("error", error); }, }, - }).$then( + }; + // Private name: a cluster-shared descriptor is read one datagram at a time so workers share the load. + if (sharedHandle) $putByIdDirectPrivate(udpOptions, "sharedFd", true); + Bun.udpSocket(udpOptions).$then( socket => { if (!state.handle) { // Closed while the bind was in flight. @@ -770,11 +781,13 @@ function startBunSocket(self, state, createOptions) { }, err => { state.bindState = BIND_STATE_UNBOUND; + if (sharedHandle) releaseSharedHandle(state, sharedHandle); self.emit("error", err); }, ); } catch (err) { state.bindState = BIND_STATE_UNBOUND; + if (sharedHandle) releaseSharedHandle(state, sharedHandle); self.emit("error", err); } } @@ -1131,11 +1144,12 @@ Socket.prototype.close = function (callback) { handle.sendQueueHead = 0; for (let i = head; i < queue.length; i++) completeQueuedSend(handle, queue[i], UV_ECANCELED); } - if (state.sharedHandle) { + const sharedHandle = state.sharedHandle; + if (sharedHandle) { // Tells the cluster primary this worker no longer uses the shared // descriptor (the descriptor itself was owned and closed by the socket). - state.sharedHandle.close(); state.sharedHandle = undefined; + sharedHandle.close(); } defaultTriggerAsyncIdScope(this[async_id_symbol], process.nextTick, socketCloseNT, this); diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 81f7b6317db9..562ff3a6f59a 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -1882,10 +1882,11 @@ Socket.prototype.connect = function connect(...args) { if (socket) { connection = socket; } - if (fd) { + if (fd != null) { doConnect(this._handle, { data: this, fd: fd, + ...(options.fdIsRawSocket === true ? { fdIsRawSocket: true } : {}), socket: SocketHandlers, // Always half-open natively; see kConnect. allowHalfOpen: true, @@ -1910,9 +1911,9 @@ Socket.prototype.connect = function connect(...args) { // attached stays buffered instead of being emitted to nobody. if (!this.isPaused()) this.read(0); }); - this.connecting = true; + if (fd == null) this.connecting = true; } - if (fd) { + if (fd != null) { return this; } if ( @@ -3548,6 +3549,7 @@ Server.prototype.unref = function unref() { }; Server.prototype.close = function close(callback) { + this[kClusterListeningId] = (this[kClusterListeningId] || 0) + 1; if (typeof callback === "function") { if (!this._handle) { this.once("close", function close() { @@ -3559,7 +3561,18 @@ Server.prototype.close = function close(callback) { } if (this._handle) { - this._handle.stop(false); + if (typeof this._handle.stop === "function") { + this._handle.stop(false); + // Released here, not on 'close': https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2434-L2437 + const clusterHandle = this[kClusterHandle]; + if (clusterHandle) { + this[kClusterHandle] = null; + this[kClusterUnixPath] = undefined; + clusterHandle.close(); + } + } else { + this._handle.close(); + } this._handle = null; } @@ -3593,7 +3606,7 @@ Server.prototype._emitCloseIfDrained = function _emitCloseIfDrained() { Server.prototype.address = function address() { const server = this._handle; if (server) { - const unix = server.unix; + const unix = server.unix || this[kClusterUnixPath]; if (unix) { return unix; } @@ -3759,6 +3772,10 @@ Server.prototype.listen = function listen(port, hostname, onListen) { error.code = "ERR_INVALID_ARG_VALUE"; throw error; } + if (reusePort === true) { + exclusive = true; + } + var clusterHost = typeof hostname === "string" && hostname.length > 0 ? hostname : null; hostname = hostname || "::"; } @@ -3792,11 +3809,27 @@ Server.prototype.listen = function listen(port, hostname, onListen) { options[kSocketClass] = Socket; } + const flags = (ipv6Only === true ? 1 : 0) | (reusePort === true ? 2 : 0); + let queryAddress = null; + let queryPort = port; + let queryAddressType = 4; + if (path) { + queryAddress = path; + queryPort = -1; + queryAddressType = -1; + } else if (typeof fd === "number" && fd >= 0) { + queryPort = null; + queryAddressType = null; + } else if (typeof clusterHost === "string") { + queryAddress = clusterHost; + queryAddressType = isIP(clusterHost) || 4; + } + listenInCluster( this, - null, - port, - 4, + queryAddress, + queryPort, + queryAddressType, backlog, fd, exclusive, @@ -3804,7 +3837,7 @@ Server.prototype.listen = function listen(port, hostname, onListen) { reusePort, readableAll, writableAll, - undefined, + flags, undefined, path, hostname, @@ -3836,6 +3869,9 @@ Server.prototype[kRealListen] = function ( // NOTE: accepted sockets are always allowHalfOpen:true at the native layer // (hardcoded below); the stream layer implements allowHalfOpen=false // semantics itself, so the server option is consumed in JS only. + if (reusePort) { + exclusive = false; + } if (path) { this._handle = Bun.listen({ unix: path, @@ -3994,6 +4030,48 @@ function listenInCluster( if (cluster === undefined) cluster = require("node:cluster"); + if ( + !cluster.isPrimary && + !exclusive && + typeof address === "string" && + address.length > 0 && + typeof port === "number" && + port >= 0 && + isIP(address) === 0 + ) { + const lookupListeningId = (server[kClusterListeningId] = (server[kClusterListeningId] || 0) + 1); + // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2259-L2278 + require("node:dns").lookup(address, (err, ip, family) => { + if (lookupListeningId !== server[kClusterListeningId]) return; + if (err) { + // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2268-L2269 + server.emit("error", err); + return; + } + listenInCluster( + server, + ip, + port, + family === 6 ? 6 : 4, + backlog, + fd, + exclusive, + ipv6Only, + reusePort, + readableAll, + writableAll, + flags, + options, + path, + hostname, + tls, + contexts, + onListen, + ); + }); + return; + } + if (cluster.isPrimary || exclusive) { server[kRealListen]( path, @@ -4019,30 +4097,125 @@ function listenInCluster( fd: fd, flags, backlog, + readableAll, + writableAll, ...options, + sharedOnly: tls ? true : undefined, }; - cluster._getServer(server, serverQuery, function listenOnPrimaryHandle(err, handle) { + const listeningId = (server[kClusterListeningId] = (server[kClusterListeningId] || 0) + 1); + // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2080-L2102 + cluster._getServer(server, serverQuery, function listenOnPrimaryHandle(err, handle, _reply) { + if (listeningId !== server[kClusterListeningId]) { + handle?.close(); + return; + } err = checkBindError(err, port, handle); if (err) { - throw new ExceptionWithHostPort(err, "bind", address, port); + const ex = new ExceptionWithHostPort(err, "bind", address, port); + if (typeof _reply?.bunHint === "string") ex.message += `\n note: ${_reply.bunHint}`; + server.emit("error", ex); + return; } - server[kRealListen]( - path, - port, - hostname, - exclusive, - ipv6Only, - reusePort, - readableAll, - writableAll, - tls, - contexts, - onListen, - fd, - ); + const sharedFd = handle?.sharedFd; + if (handle && typeof sharedFd === "number") { + server[kClusterHandle] = handle; + handle[kClusterOwner] = server; + // The primary owns the socket file; the adopted fd only needs to report it from address(). + server[kClusterUnixPath] = path; + try { + server[kRealListen]( + undefined, + port, + hostname, + exclusive, + ipv6Only, + reusePort, + readableAll, + writableAll, + tls, + contexts, + onListen, + sharedFd, + ); + handle.adopted = true; + } catch (err) { + server[kClusterHandle] = null; + server[kClusterUnixPath] = undefined; + handle[kClusterOwner] = null; + handle.close(); + setTimeout(emitErrorNextTick, 1, server, err); + } + return; + } + server[kClusterFauxListen](handle, backlog, path); }); } +const kClusterListeningId = Symbol("kClusterListeningId"); +const kClusterHandle = Symbol("kClusterHandle"); +const kClusterUnixPath = Symbol("kClusterUnixPath"); +const kClusterFauxListen = Symbol("kClusterFauxListen"); +const { kClusterOwner } = require("internal/shared"); + +Server.prototype[kClusterFauxListen] = function (handle, backlog, path) { + this[kClusterHandle] = handle; + this._handle = handle; + if (path) { + handle.unix = path; + } + handle.onconnection = onClusterConnection; + handle[kClusterOwner] = this; + handle.listen(backlog || 511); + if (this._unref) this.unref(); + setTimeout(emitListeningNextTick, 1, this); +}; + +function onClusterConnection(err, clientHandle) { + const self = this[kClusterOwner]; + if (!self || self[kClusterHandle] !== this) { + clientHandle?.close(); + return; + } + if (err) { + self.emit("error", new ErrnoException(err, "accept")); + return; + } + if (self.maxConnections != null && self._connections >= self.maxConnections) { + self.emit("drop"); + clientHandle.close(); + return; + } + const socket = new Socket({ + allowHalfOpen: self.allowHalfOpen, + highWaterMark: self.highWaterMark, + }); + socket.isServer = true; + if (self.noDelay) socket[kSetNoDelay] = true; + if (self.keepAlive) { + socket[kSetKeepAlive] = true; + socket[kSetKeepAliveInitialDelay] = self.keepAliveInitialDelay; + } + socket.connect({ fd: clientHandle.fd, fdIsRawSocket: true, pauseOnConnect: self.pauseOnConnect }); + const blockList = self.blockList; + if (blockList) { + const remote = socket.remoteAddress; + const t = isIP(remote); + if (t && blockList.check(remote, `ipv${t}`)) { + // node's onconnection closes a blocked peer silently; 'drop' is for maxConnections only. + socket.destroy(); + return; + } + } + socket.server = self; + socket._server = self; + self._connections++; + const connectionListener = self[bunSocketServerOptions]?.connectionListener; + if (typeof connectionListener === "function" && typeof self[bunTlsSymbol] !== "function") { + self.prependOnceListener("connection", connectionListener); + } + self.emit("connection", socket); +} + function createServer(options, connectionListener) { return new Server(options, connectionListener); } diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index cac125f29df3..10210178ea06 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -4776,15 +4776,29 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionEmitHelper, (JSGlobalObject * globalObj return JSValue::encode(ret); } +static constexpr auto kInternalIpcPrefix = "NODE_"_s; + extern "C" void Process__emitMessageEvent(Zig::GlobalObject* global, EncodedJSValue value, EncodedJSValue handle) { auto* process = global->processObject(); auto& vm = JSC::getVM(global); + auto& names = WebCore::builtinNames(vm); auto ident = vm.propertyNames->message; + JSValue message = JSValue::decode(value); + if (auto* object = message.getObject()) { + JSValue cmd = object->getDirect(vm, names.cmdPublicName()); + if (cmd && cmd.isString()) { + auto cmdString = JSC::asString(cmd)->tryGetValue(); + if (cmdString->length() > kInternalIpcPrefix.length() && cmdString->startsWith(kInternalIpcPrefix)) { + ident = names.internalMessagePublicName(); + } + } + } + if (process->wrapped().hasEventListeners(ident)) { JSC::MarkedArgumentBuffer args; - args.append(JSValue::decode(value)); + args.append(message); args.append(JSValue::decode(handle)); process->wrapped().emit(ident, args); } diff --git a/src/jsc/bindings/IPC.cpp b/src/jsc/bindings/IPC.cpp index 458861018d7d..ae5b0898b15a 100644 --- a/src/jsc/bindings/IPC.cpp +++ b/src/jsc/bindings/IPC.cpp @@ -4,7 +4,7 @@ #include "WebCoreJSBuiltins.h" #include "ZigGlobalObject.h" -extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue IPCSerialize(Zig::GlobalObject* global, JSC::EncodedJSValue message, JSC::EncodedJSValue handle) +extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue IPCSerialize(Zig::GlobalObject* global, JSC::EncodedJSValue message, JSC::EncodedJSValue handle, JSC::EncodedJSValue options) { auto& vm = JSC::getVM(global); auto scope = DECLARE_THROW_SCOPE(vm); @@ -14,6 +14,7 @@ extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue IPCSerialize(Zig::G JSC::MarkedArgumentBuffer args; args.append(JSC::JSValue::decode(message)); args.append(JSC::JSValue::decode(handle)); + args.append(JSC::JSValue::decode(options)); auto result = JSC::call(global, serializeFunction, callData, JSC::jsUndefined(), args); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index c084a885ee86..697570290da2 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -5293,6 +5293,9 @@ enum class BuiltinNamesMap : uint8_t { type, signal, cmd, + // Private names below: set by builtins via $putByIdDirectPrivate, unreachable from user code. + internal, + sharedFd, }; static inline const JSC::Identifier& builtinNameMap(JSC::VM& vm, unsigned char name) @@ -5372,6 +5375,12 @@ static inline const JSC::Identifier& builtinNameMap(JSC::VM& vm, unsigned char n case BuiltinNamesMap::cmd: { return clientData->builtinNames().cmdPublicName(); } + case BuiltinNamesMap::internal: { + return clientData->builtinNames().internalPrivateName(); + } + case BuiltinNamesMap::sharedFd: { + return clientData->builtinNames().sharedFdPrivateName(); + } default: { ASSERT_NOT_REACHED(); __builtin_unreachable(); diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index b59ace1f259f..377b657f5ab7 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -991,6 +991,10 @@ pub enum BuiltinName { type_, signal, cmd, + /// Private name (`$internal` in builtins); user code cannot set it. + internal, + /// Private name (`$sharedFd` in builtins); user code cannot set it. + sharedFd, } #[allow(non_upper_case_globals)] diff --git a/src/libuv_sys/libuv.rs b/src/libuv_sys/libuv.rs index 658f13a690b0..2275aa16f8cf 100644 --- a/src/libuv_sys/libuv.rs +++ b/src/libuv_sys/libuv.rs @@ -1282,6 +1282,11 @@ pub struct Pipe { pub type uv_pipe_t = Pipe; impl Pipe { + #[inline] + pub fn ipc_remote_pid(&self) -> DWORD { + // SAFETY: `conn` is the active variant for a connected IPC pipe (init + unsafe { self.pipe.conn.ipc_remote_pid } + } /// `uv_pipe_init` wrapper. Returns the raw `ReturnCode`; callers /// in higher tiers map to `bun_sys::Result` themselves so this crate stays /// free of `bun_sys`. diff --git a/src/resolve_builtins/HardcodedModule.rs b/src/resolve_builtins/HardcodedModule.rs index 074d920cef4b..b25b60751d9b 100644 --- a/src/resolve_builtins/HardcodedModule.rs +++ b/src/resolve_builtins/HardcodedModule.rs @@ -182,6 +182,8 @@ pub enum HardcodedModule { /// This is gated behind '--expose-internals' #[strum(serialize = "bun:internal-for-testing")] BunInternalForTesting, + #[strum(serialize = "internal:cluster/RoundRobinHandle")] + InternalClusterRoundRobinHandle, // Node internal modules exposed for the vendored Node.js test suite. // Gated like `bun:internal-for-testing` (debug builds / --expose-internals). #[strum(serialize = "internal:repl")] @@ -213,6 +215,7 @@ bun_core::comptime_string_map! { b"bun:sqlite" => HardcodedModule::BunSqlite, b"bun:wrap" => HardcodedModule::BunWrap, b"bun:internal-for-testing" => HardcodedModule::BunInternalForTesting, + b"internal:cluster/RoundRobinHandle" => HardcodedModule::InternalClusterRoundRobinHandle, b"internal/repl" => HardcodedModule::NodeInternalRepl, b"internal/repl/await" => HardcodedModule::NodeInternalReplAwait, b"internal/repl/history" => HardcodedModule::NodeInternalReplHistory, @@ -725,6 +728,15 @@ const BUN_EXTRA_ALIAS_KVS: &[AliasKv] = &[ entry!("bun:sqlite"), entry!("bun:wrap"), entry!("bun:internal-for-testing"), + ( + b"internal/cluster/round_robin_handle", + Alias { + path: zstr!("internal:cluster/RoundRobinHandle"), + tag: import_record::Tag::Builtin, + node_builtin: false, + node_only_prefix: false, + }, + ), // Node internal modules for the vendored Node.js test suite (gated in // jsc_hooks like bun:internal-for-testing: debug / --expose-internals). entry!("internal/repl"), diff --git a/src/runtime/api/bun/js_bun_spawn_bindings.rs b/src/runtime/api/bun/js_bun_spawn_bindings.rs index c9045837f228..79a685337d47 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -1525,6 +1525,7 @@ fn spawn_maybe_sync( None, core::mem::size_of::<*mut IPC::SendQueue>() as core::ffi::c_int, posix_ipc_fd.native(), + 0, true, ); if !raw_socket.is_null() { diff --git a/src/runtime/api/bun/subprocess.rs b/src/runtime/api/bun/subprocess.rs index e8797b6454b1..85098e028179 100644 --- a/src/runtime/api/bun/subprocess.rs +++ b/src/runtime/api/bun/subprocess.rs @@ -835,7 +835,7 @@ impl Subprocess<'_> { }; // `ipc()` centralises the single unsafe `JsCell` deref; `do_send` may // re-enter JS, but only the SendQueue is borrowed, not `*self`. - crate::ipc_host::do_send(this.ipc(), global, call_frame, context) + crate::ipc_host::do_send(this.ipc(), global, call_frame, context, this.pid() as u32) } pub(crate) fn disconnect_ipc(&self, next_tick: bool) { @@ -849,7 +849,9 @@ impl Subprocess<'_> { _global_this: &JSGlobalObject, _callframe: &CallFrame, ) -> JsResult { - this.disconnect_ipc(true); + if let Some(ipc_data) = this.ipc() { + ipc_data.disconnect(); + } Ok(JSValue::UNDEFINED) } @@ -1473,9 +1475,7 @@ impl Subprocess<'_> { self.update_has_pending_activity(); if !this_jsvalue.is_empty() { - // Avoid keeping the callback alive longer than necessary - js::ipc_callback_set_cached(this_jsvalue, global_this, JSValue::ZERO); - + // The ipc callback is kept: a server/dgram handle still adopting at EOF is delivered afterwards, as in node. // Call the onDisconnectCallback if it exists and prevent it from being kept alive longer than necessary if let Some(callback) = js::on_disconnect_callback_take_cached(this_jsvalue, global_this) diff --git a/src/runtime/hw_exports.rs b/src/runtime/hw_exports.rs index 1582c0a12abf..152b56b7dfeb 100644 --- a/src/runtime/hw_exports.rs +++ b/src/runtime/hw_exports.rs @@ -172,7 +172,7 @@ pub fn close_child_ipc(global: &JSGlobalObject) { let vm = global.bun_vm().as_mut(); if let Some(current_ipc) = crate::ipc_host::get_ipc_instance(vm) { // SAFETY: `get_ipc_instance` returns the live boxed `IPCInstance`. - unsafe { (*current_ipc).data().close_socket_next_tick(true) }; + unsafe { (*current_ipc).data().disconnect() }; } } diff --git a/src/runtime/ipc.rs b/src/runtime/ipc.rs index 95f9d1e6c0bb..0d97b065e3d1 100644 --- a/src/runtime/ipc.rs +++ b/src/runtime/ipc.rs @@ -97,26 +97,6 @@ impl InternalMsgHolder { let event_loop = global.bun_vm().event_loop_mut(); - if let Some(p) = message.get(global, "ack")? { - if !p.is_undefined() { - let ack = p.to_int32(); - // Note: peek the JSValue first (ending the immutable borrow), - // then swap_remove (which drops the Strong). - let entry = self.callbacks.get(&ack).map(|s| s.get()); - if let Some(callback_opt) = entry { - if let Some(callback) = callback_opt { - self.callbacks.swap_remove(&ack); - event_loop.run_callback( - callback, - global, - self.worker.get().unwrap(), - &[message, handle], - ); - } - return Ok(()); - } - } - } event_loop.run_callback(cb, global, worker, &[message, handle]); Ok(()) } @@ -684,8 +664,15 @@ pub(crate) fn get_nack_packet(mode: Mode) -> &'static [u8] { pub type Socket = bun_uws::SocketHandler; pub struct Handle { - pub(crate) fd: Fd, + pub fd: Fd, pub js: Protected, + pub close_on_complete: bool, + pub owns_fd: bool, + pub cluster_seq: Option, + #[cfg(windows)] + pub win_export_hex: Option>, + #[cfg(windows)] + pub peer_pid: u32, } impl Handle { @@ -693,6 +680,51 @@ impl Handle { Self { fd, js: js.protected(), + close_on_complete: false, + owns_fd: false, + cluster_seq: None, + #[cfg(windows)] + win_export_hex: None, + #[cfg(windows)] + peer_pid: 0, + } + } + + pub fn init_close_on_complete(fd: Fd, js: JSValue) -> Self { + Self { + fd, + js: js.protected(), + close_on_complete: true, + owns_fd: false, + cluster_seq: None, + #[cfg(windows)] + win_export_hex: None, + #[cfg(windows)] + peer_pid: 0, + } + } + + pub fn init_dup(fd: Fd, js: JSValue, close_on_complete: bool) -> Result { + let wire_fd = bun_sys::dup(fd)?; + Ok(Self { + fd: wire_fd, + js: js.protected(), + close_on_complete, + owns_fd: true, + cluster_seq: None, + #[cfg(windows)] + win_export_hex: None, + #[cfg(windows)] + peer_pid: 0, + }) + } +} + +impl Drop for Handle { + fn drop(&mut self) { + if self.owns_fd { + // Owned dup/received descriptors may legitimately be 0-2 (stdio closed); close them regardless. + let _ = self.fd.close_allowing_standard_io(None); } } } @@ -777,9 +809,51 @@ impl SendHandle { /// Call the callback and deinit pub(crate) fn complete(mut self, global: &JSGlobalObject) { + if let Some(handle) = &self.handle { + if handle.close_on_complete { + let js = handle.js.value(); + if js.is_object() { + let _ = JSValue::call_next_tick_1(close_sent_handle_fn(global), global, js); + } + } + } let _ = self.callbacks.call_next_tick(global); // TODO: properly propagate exception upwards // self drops here → data/callbacks/handle Drop. } + + pub fn abort_unsent(self, global: &JSGlobalObject) { + if let Some(handle) = &self.handle { + if handle.close_on_complete { + let js = handle.js.value(); + if js.is_object() { + let _ = JSValue::call_next_tick_1(close_sent_handle_fn(global), global, js); + } + } + } + } +} + +#[bun_jsc::host_fn] +fn close_sent_handle(global: &JSGlobalObject, callframe: &jsc::CallFrame) -> JsResult { + let [js] = callframe.arguments_as_array::<1>(); + if js.is_object() { + if let Some(f) = js.get(global, "close")? { + if f.is_callable() { + f.call(global, js, &[])?; + } + } + } + Ok(JSValue::UNDEFINED) +} + +fn close_sent_handle_fn(global: &JSGlobalObject) -> JSValue { + jsc::JSFunction::create( + global, + BunString::empty(), + __jsc_host_close_sent_handle, + 1, + Default::default(), + ) } // SendHandle.deinit: all fields Drop; no explicit impl needed. @@ -863,6 +937,8 @@ pub struct SendQueue { pub(crate) deferred_scheduled: Cell, pub(crate) pending_close: Cell, + /// A user disconnect() waiting for the handle queue to flush; reported as disconnected meanwhile, cleared by any real close. + pub(crate) close_after_flush: Cell, pub(crate) pending_after_close: Cell, pub(crate) write_in_progress: Cell, pub close_event_sent: Cell, @@ -982,6 +1058,7 @@ impl SendQueue { owner: Cell::new(owner), deferred_scheduled: Cell::new(false), pending_close: Cell::new(false), + close_after_flush: Cell::new(false), pending_after_close: Cell::new(false), write_in_progress: Cell::new(false), close_event_sent: Cell::new(false), @@ -1012,7 +1089,7 @@ impl SendQueue { if self.windows.get().try_close_after_write { return false; } - self.socket_is_open() && !self.pending_close.get() + self.socket_is_open() && !self.pending_close.get() && !self.close_after_flush.get() } fn close_socket(&self, reason: CloseReason, from: CloseFrom) { @@ -1122,6 +1199,14 @@ impl SendQueue { if sq.pending_after_close.replace(false) { log!("SendQueue#_onAfterIPCClosed"); if !sq.close_event_sent.replace(true) { + let global = sq.get_global_this(); + if let Some(item) = sq.waiting_for_ack.with_mut(|w| w.take()) { + item.complete(&global); + } + // on_write_complete already dequeued everything fully written; the rest was never delivered. + for item in sq.queue.with_mut(std::mem::take) { + item.abort_unsent(&global); + } if let Some(owner) = sq.owner.get() { owner.handle_ipc_close(); } @@ -1181,6 +1266,8 @@ impl SendQueue { self.socket.set(SocketUnion::Closed); return; } + // Peer-gone and exit paths land here too: a postponed disconnect never outranks them. + self.close_after_flush.set(false); if self.pending_close.get() { return; // close already requested } @@ -1192,6 +1279,18 @@ impl SendQueue { self.schedule_deferred(); } + /// User disconnect(): reports disconnected now but, like node, closes only once a handle awaiting its ack and the queue behind it have gone out. + pub fn disconnect(&self) { + if self.socket_is_open() + && !self.pending_close.get() + && self.waiting_for_ack.get().is_some() + { + self.close_after_flush.set(true); + return; + } + self.close_socket_next_tick(true); + } + fn start_message( &self, global: &JSGlobalObject, @@ -1237,13 +1336,13 @@ impl SendQueue { #[cfg(debug_assertions)] debug_assert!(self.has_written_version.get() == 1); let write_in_progress = self.write_in_progress.get(); + let waiting_for_ack = self.waiting_for_ack.get().is_some(); self.queue.with_mut(|queue| { if (queue.is_empty() || queue[0].data.cursor == 0) && !write_in_progress { // prepend (we have not started sending the next message yet because we are waiting for the ack/nack) queue.insert(0, message); } else { - // insert at index 1 (we are in the middle of sending a message to the other process) - debug_assert!(queue[0].is_ack_nack()); + debug_assert!(!waiting_for_ack || queue[0].is_ack_nack()); queue.insert(1, message); } }); @@ -1274,6 +1373,27 @@ impl SendQueue { .with_mut(|w| w.take()) .map(|mut item| { item.data.cursor = 0; + #[cfg(windows)] + { + let handle = item.handle.as_mut().unwrap(); + if handle.peer_pid != 0 { + if let Some(old_hex) = handle.win_export_hex.take() { + if let Some(new_hex) = + windows_export_socket_hex(handle.fd, handle.peer_pid) + { + if let Some(pos) = + bun_core::memmem(&item.data.list, &old_hex) + { + item.data.list[pos..pos + new_hex.len()] + .copy_from_slice(&new_hex); + } + handle.win_export_hex = Some(new_hex); + } else { + handle.win_export_hex = Some(old_hex); + } + } + } + } item }) .unwrap(); @@ -1281,6 +1401,23 @@ impl SendQueue { log!("IPC call continueSend() from onAckNack retry"); return self.continue_send(global, ContinueSendReason::NewMessageAppended); } + let cluster_seq = self + .waiting_for_ack + .with_mut(|w| w.as_ref().and_then(|i| i.handle.as_ref()?.cluster_seq)); + if let Some(seq) = cluster_seq { + let cb = self.internal_msg_queue.with_mut(|q| { + let entry = q.callbacks.get(&seq).map(|s| s.get()); + if entry.is_some() { + q.callbacks.swap_remove(&seq); + } + entry.flatten() + }); + if let Some(cb) = cb { + let reply = JSValue::create_empty_object(global, 1); + reply.put(global, b"accepted", JSValue::FALSE); + let _ = JSValue::call_next_tick_1(cb, global, reply); + } + } // too many retries; give up - emit warning if possible let mut warning = BunString::static_(b"Handle did not reach the receiving process correctly"); @@ -1301,6 +1438,7 @@ impl SendQueue { } // consume the message and continue sending if let Some(item) = self.waiting_for_ack.with_mut(|w| w.take()) { + self.retry_count.set(0); item.complete(global); // call the callback & deinit } log!("IPC call continueSend() from onAckNack success"); @@ -1311,19 +1449,11 @@ impl SendQueue { if self.waiting_for_ack.get().is_some() { return true; // waiting to receive an ack/nack from the other side } - let sending = self - .queue - .with_mut(|q| q.first().map(|first| first.data.cursor > 0)); - let Some(cursor_advanced) = sending else { + if self.queue.get().is_empty() { return false; // nothing to send - }; - if cursor_advanced { - return true; // send in progress, waiting on writable } - if self.write_in_progress.get() { - return true; // send in progress (windows), waiting on writable - } - false // error state. + // Anything still queued (including head with cursor==0 under backpressure) must keep the loop alive; a closed socket does not. + self.socket_is_open() } pub(crate) fn update_ref(&self, global: &JSGlobalObject) { @@ -1381,10 +1511,17 @@ impl SendQueue { // item's length is 0, remove it and continue sending. this should rarely (never?) happen. return Next::EmptyItem(queue.remove(0)); } - Next::Send(first.handle.as_ref().map(|h| h.fd)) + Next::Send(if first.data.cursor == 0 { + first.handle.as_ref().map(|h| h.fd) + } else { + None + }) }); match next { Next::Nothing => { + if self.close_after_flush.get() && !waiting_for_ack && self.queue.get().is_empty() { + self.close_socket_next_tick(true); + } self.update_ref(global); } Next::EmptyItem(itm) => { @@ -1557,6 +1694,15 @@ impl SendQueue { } } + #[cfg(windows)] + pub fn ipc_peer_pid(&self) -> u32 { + match *self.socket.get() { + // SAFETY: `p` is a live uv_pipe_t owned until _windowsOnClosed. + SocketUnion::Open(p) => unsafe { (*p).ipc_remote_pid() as u32 }, + _ => 0, + } + } + /// starts a write request. on posix, this always calls _onWriteComplete immediately. on windows, it may /// call _onWriteComplete later. /// @@ -1568,9 +1714,7 @@ impl SendQueue { }; #[cfg(windows)] { - if let Some(_) = fd { - // TODO: send fd on windows - } + let _ = fd; let pipe: *mut uv::Pipe = socket; let write_req_slice: Box<[u8]> = self.queue.with_mut(|queue| { @@ -1837,13 +1981,88 @@ impl Drop for SendQueue { // An SCM_RIGHTS fd can be stashed by `onFd` and not yet consumed by // the `NODE_HANDLE` decoder when the socket closes. if let Some(fd) = self.incoming_fd.take() { - FdExt::close(fd); + let _ = fd.close_allowing_standard_io(None); } } } const MAX_HANDLE_RETRANSMISSIONS: u32 = 3; +#[cfg(windows)] +pub fn windows_export_socket_hex(fd: Fd, peer_pid: u32) -> Option> { + let size = bun_uws::socket_transfer::bsd_socket_export_size() as usize; + let mut info = vec![0u8; size]; + // SAFETY: `info` is `size` bytes as required; `fd.native()` is the SOCKET. + let rc = unsafe { + bun_uws::socket_transfer::bsd_socket_export( + fd.native() as bun_uws::LIBUS_SOCKET_DESCRIPTOR, + peer_pid, + info.as_mut_ptr().cast::(), + ) + }; + if rc != 0 { + return None; + } + let mut hex = vec![0u8; size * 2]; + let n = bun_core::strings::encode_bytes_to_hex(&mut hex, &info); + debug_assert!(n == size * 2); + hex.truncate(n); + Some(hex.into_boxed_slice()) +} + +pub const WIN_SOCKET_INFO_KEY: &[u8] = b"$winSocketInfo"; + +#[cfg(windows)] +fn import_windows_socket_payload(global: &JSGlobalObject, msg_data: JSValue) -> Option { + let info_value = match msg_data.get(global, WIN_SOCKET_INFO_KEY) { + Ok(Some(v)) if v.is_string() => v, + Ok(_) => return None, + Err(_) => { + global.clear_exception(); + return None; + } + }; + let hex = jsc::JSString::opaque_ref(info_value.as_string()).to_slice(global); + let expected = bun_uws::socket_transfer::bsd_socket_export_size() as usize; + let mut info = vec![0u8; expected]; + let decoded = strings::decode_hex_to_bytes_truncate(&mut info, hex.slice()); + if decoded != expected { + log!( + "importWindowsSocketPayload: bad blob length {} (want {})", + decoded, + expected + ); + return None; + } + let mut err: c_int = 0; + // SAFETY: `info` is a live buffer of export_size() bytes holding the + let sock = unsafe { + bun_uws::socket_transfer::bsd_socket_import(info.as_mut_ptr().cast::(), &mut err) + }; + if sock == bun_uws::LIBUS_SOCKET_DESCRIPTOR::MAX { + log!("importWindowsSocketPayload: WSASocketW failed: {}", err); + return None; + } + msg_data.delete_property(global, WIN_SOCKET_INFO_KEY); + Some(Fd::from_system(sock as *mut c_void)) +} + +fn received_fd_to_js(fd: Fd) -> JSValue { + #[cfg(windows)] + { + let v = fd.native() as u64; + if v <= i32::MAX as u64 { + JSValue::js_number_from_int32(v as i32) + } else { + JSValue::js_number_from_uint64(v) + } + } + #[cfg(not(windows))] + { + JSValue::js_number_from_int32(fd.uv()) + } +} + enum IPCCommand { Handle(JSValue), Ack, @@ -1913,7 +2132,11 @@ fn handle_ipc_message( if let Some(icmd) = internal_command { match icmd { IPCCommand::Handle(msg_data) => { - // Handle NODE_HANDLE message + #[cfg(windows)] + let imported = import_windows_socket_payload(global_this, msg_data); + #[cfg(windows)] + let ack = imported.is_some(); + #[cfg(not(windows))] let ack = send_queue.incoming_fd.get().is_some(); let packet = if ack { @@ -1940,10 +2163,13 @@ fn handle_ipc_message( } // Get file descriptor and clear it + #[cfg(windows)] + let fd: Fd = imported.unwrap(); + #[cfg(not(windows))] let fd: Fd = send_queue.incoming_fd.take().unwrap(); let Some(owner) = send_queue.owner_ref() else { - FdExt::close(fd); + let _ = fd.close_allowing_standard_io(None); return; }; let target: JSValue = match owner.kind() { @@ -1954,13 +2180,11 @@ fn handle_ipc_message( // RAII: `enter()` now, `exit()` on drop — covers both the // early-error return and the fall-through. let _scope = global_this.bun_vm().enter_event_loop_scope(); - // FD.toJS — `uv()` is the user-visible numeric fd on both - // platforms (posix == native, windows == uv_file). - let fd_js = JSValue::js_number_from_int32(fd.uv()); + let fd_js = received_fd_to_js(fd); let res = ipc_parse(global_this, target, msg_data, fd_js); if let Err(e) = res { // ack written already, that's okay. - FdExt::close(fd); + let _ = fd.close_allowing_standard_io(None); global_this.report_active_exception_as_unhandled(e); return; } @@ -1981,8 +2205,62 @@ fn handle_ipc_message( return; } } - } else if let Some(owner) = send_queue.owner.get() { - owner.handle_ipc_message(&message, JSValue::UNDEFINED); + } else { + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/cluster/utils.js#L33-L49 + let mut handle_js = JSValue::UNDEFINED; + let mut received_fd: Option = None; + if let DecodedIPCMessage::Internal(msg_data) = &message { + let msg_data = *msg_data; + if msg_data.is_object() { + match msg_data.get(global_this, "$hasHandle") { + Ok(Some(marker)) if marker.to_boolean() => { + #[cfg(windows)] + let imported = import_windows_socket_payload(global_this, msg_data); + #[cfg(windows)] + let ack = imported.is_some(); + #[cfg(not(windows))] + let ack = send_queue.incoming_fd.get().is_some(); + let packet = if ack { + get_ack_packet(send_queue.mode) + } else { + get_nack_packet(send_queue.mode) + }; + let mut reply = SendHandle { + data: StreamBuffer::default(), + handle: None, + callbacks: CallbackList::AckNack, + }; + handle_oom(reply.data.write(packet)); + send_queue.insert_message(reply); + log!("IPC call continueSend() from internal $hasHandle ack"); + send_queue + .continue_send(global_this, ContinueSendReason::NewMessageAppended); + if !ack { + return; + } + #[cfg(windows)] + let fd = imported.unwrap(); + #[cfg(not(windows))] + let fd = send_queue.incoming_fd.take().unwrap(); + received_fd = Some(fd); + handle_js = received_fd_to_js(fd); + } + Ok(_) => {} + Err(_) => { + global_this.clear_exception(); + } + } + } + } + match send_queue.owner.get() { + Some(owner) => owner.handle_ipc_message(&message, handle_js), + // Owner already torn down: nobody will adopt the descriptor we just acked. + None => { + if let Some(fd) = received_fd { + let _ = fd.close_allowing_standard_io(None); + } + } + } } } @@ -2177,7 +2455,7 @@ pub mod IPCHandlers { log!("onFd: {}", fd); if let Some(existing_fd) = send_queue.incoming_fd.take() { log!("onFd: incoming_fd already set; overwriting"); - FdExt::close(existing_fd); + let _ = existing_fd.close_allowing_standard_io(None); } send_queue.incoming_fd.set(Some(Fd::from_native(fd))); } @@ -2297,9 +2575,10 @@ pub fn ipc_serialize( global_object: &JSGlobalObject, message: JSValue, handle: JSValue, + options: JSValue, ) -> JsResult { // `[[ZIG_EXPORT(zero_is_throw)]]` - bun_jsc::cpp::IPCSerialize(global_object, message, handle) + bun_jsc::cpp::IPCSerialize(global_object, message, handle, options) } #[track_caller] diff --git a/src/runtime/ipc_host.rs b/src/runtime/ipc_host.rs index 34fa202e98cb..663e55f0f963 100644 --- a/src/runtime/ipc_host.rs +++ b/src/runtime/ipc_host.rs @@ -28,6 +28,28 @@ pub(crate) enum FromEnum { Process, } +#[cfg(windows)] +pub(crate) fn attach_windows_socket_payload( + global: &JSGlobalObject, + message: JSValue, + fd: bun_sys::Fd, + peer_pid: u32, +) -> Option> { + if peer_pid == 0 { + return None; + } + let Some(hex) = IPC::windows_export_socket_hex(fd, peer_pid) else { + log!("attachWindowsSocketPayload: WSADuplicateSocketW failed"); + return None; + }; + let Ok(str_js) = bun_jsc::bun_string_jsc::create_utf8_for_js(global, &hex) else { + global.clear_exception(); + return None; + }; + message.put(global, IPC::WIN_SOCKET_INFO_KEY, str_js); + Some(hex) +} + #[bun_jsc::host_fn] fn emit_process_error_event( global_this: &JSGlobalObject, @@ -70,9 +92,13 @@ pub(crate) fn do_send( global_object: &JSGlobalObject, call_frame: &CallFrame, from: FromEnum, + peer_pid: u32, ) -> JsResult { let [mut message, mut handle, options_, mut callback] = call_frame.arguments_as_array::<4>(); + #[cfg(not(windows))] + let _ = peer_pid; + let mut is_internal = IsInternal::External; if handle.is_callable() { callback = handle; handle = JSValue::UNDEFINED; @@ -80,6 +106,12 @@ pub(crate) fn do_send( callback = options_; } else if !options_.is_undefined() { global_object.validate_object("options", options_, Default::default())?; + if options_ + .fast_get(global_object, bun_jsc::BuiltinName::internal)? + .is_some_and(|v| v.to_boolean()) + { + is_internal = IsInternal::Internal; + } } let connected = ipc.as_ref().is_some_and(|i| i.is_connected()); @@ -118,8 +150,10 @@ pub(crate) fn do_send( )); } + let original_message = message; if !handle.is_undefined_or_null() { - let serialized_array: JSValue = IPC::ipc_serialize(global_object, message, handle)?; + let serialized_array: JSValue = + IPC::ipc_serialize(global_object, message, handle, options_)?; if serialized_array.is_undefined_or_null() { handle = JSValue::UNDEFINED; } else { @@ -131,6 +165,9 @@ pub(crate) fn do_send( } let mut zig_handle: Option = None; + let mut pause_target = JSValue::UNDEFINED; + #[cfg_attr(windows, allow(unused_mut, unused_variables))] + let mut dup_err: Option = None; if !handle.is_undefined_or_null() { if let Some(listener) = Listener::from_js(handle) { log!("got listener"); @@ -143,25 +180,118 @@ pub(crate) fn do_send( // owned by uSockets; `get_socket` only reinterpret-casts to // `&mut us_socket_t` and `get_fd` is a read-only FFI call. let fd = unsafe { &mut *socket_uws }.get_socket().get_fd(); - zig_handle = Some(Handle::init(fd, handle)); + #[cfg(not(windows))] + match Handle::init_dup(fd, handle, false) { + Ok(h) => zig_handle = Some(h), + Err(e) => dup_err = Some(e), + } + #[cfg(windows)] + { + zig_handle = Some(Handle::init(fd, handle)); + } } crate::socket::listener::ListenerType::NamedPipe(_named_pipe) => {} crate::socket::listener::ListenerType::None => {} } - } else { - // + } else if let Some(socket) = crate::socket::TCPSocket::from_js(handle) { + // SAFETY: from_js returned a non-null pointer; the JS wrapper + let fd = unsafe { (*socket).socket.get().fd() }; + if fd != bun_sys::Fd::INVALID { + log!("got tcp socket fd"); + let keep_open = !options_.is_undefined_or_null() + && options_ + .get(global_object, "keepOpen")? + .is_some_and(|v| v.to_boolean()); + if !keep_open { + pause_target = handle; + } + #[cfg(not(windows))] + match Handle::init_dup(fd, handle, !keep_open) { + Ok(h) => zig_handle = Some(h), + Err(e) => dup_err = Some(e), + } + #[cfg(windows)] + { + zig_handle = Some(if keep_open { + Handle::init(fd, handle) + } else { + Handle::init_close_on_complete(fd, handle) + }); + } + } + } else if let Some(udp) = handle.as_class_ref::() { + if let Some(fd) = udp.native_fd() { + log!("got udp socket fd"); + #[cfg(not(windows))] + match Handle::init_dup(fd, handle, false) { + Ok(h) => zig_handle = Some(h), + Err(e) => dup_err = Some(e), + } + #[cfg(windows)] + { + zig_handle = Some(Handle::init(fd, handle)); + } + } + } + } + // serialize() already detached a non-keepOpen net.Socket; if it is not sent after all, close it here (node: postSend on error). + let close_detached = |global_object: &JSGlobalObject, target: JSValue| { + if target.is_object() { + match target.get(global_object, "close") { + Ok(Some(f)) if f.is_callable() => { + if let Err(e) = f.call(global_object, target, &[]) { + global_object.report_active_exception_as_unhandled(e); + } + } + Ok(_) => {} + Err(e) => global_object.report_active_exception_as_unhandled(e), + } + } + }; + + #[cfg(not(windows))] + if let Some(e) = dup_err { + use bun_jsc::SysErrorJsc as _; + close_detached(global_object, pause_target); + return do_send_err(global_object, callback, e.to_js(global_object), from); + } + + #[cfg(windows)] + if let Some(h) = &mut zig_handle { + match attach_windows_socket_payload(global_object, message, h.fd, peer_pid) { + Some(hex) => { + h.win_export_hex = Some(hex); + h.peer_pid = peer_pid; + } + None => zig_handle = None, } } + if zig_handle.is_none() { + message = original_message; + close_detached(global_object, pause_target); + pause_target = JSValue::UNDEFINED; + } - let status = ipc_data.serialize_and_send( - global_object, - message, - IsInternal::External, - callback, - zig_handle, - ); + let status = + ipc_data.serialize_and_send(global_object, message, is_internal, callback, zig_handle); + + if status != SerializeAndSendResult::Failure + && !pause_target.is_undefined() + && pause_target.is_object() + { + match pause_target.get(global_object, "pause") { + Ok(Some(f)) if f.is_callable() => { + if let Err(e) = f.call(global_object, pause_target, &[]) { + global_object.report_active_exception_as_unhandled(e); + } + } + Ok(_) => {} + Err(e) => global_object.report_active_exception_as_unhandled(e), + } + } if status == SerializeAndSendResult::Failure { + close_detached(global_object, pause_target); let ex = global_object.create_type_error_instance(format_args!("process.send() failed")); ex.put( global_object, @@ -207,6 +337,8 @@ pub(crate) fn emit_handle_ipc_message( } let vm = global_this.bun_vm().as_mut(); let Some(ipc) = get_ipc_instance(vm) else { + // Channel already gone: a handle that finished adopting after EOF is still delivered, as in node. + Process__emitMessageEvent(global_this, message, handle); return Ok(JSValue::UNDEFINED); }; // SAFETY: `get_ipc_instance` returns the live boxed IPCInstance. @@ -239,7 +371,19 @@ fn Bun__Process__send(global: &JSGlobalObject, frame: &CallFrame) -> JsResult bool { - CHANNEL.get().is_some() || vm.pending_ipc.is_some() -} - /// Returns the initialized IPC instance, lazily creating it from the VM's /// recorded `PendingIpc`. pub fn get_ipc_instance( diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 60b81f6a0412..e4367bb22bf3 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1408,18 +1408,18 @@ fn load_standalone_sourcemap( /// # Safety /// `global` is the live VM global; called on the JS thread inside an /// `event_loop.enter()` scope. -pub(crate) unsafe fn handle_ipc_internal_child(global: *mut JSGlobalObject, data: JSValue) { +pub(crate) unsafe fn handle_ipc_internal_child( + global: *mut JSGlobalObject, + data: JSValue, + handle: JSValue, +) { // SAFETY: per fn contract. let global = unsafe { &*global }; // Spec discards a JS exception here (`catch |err| switch (err) { // error.JSError => {} }`); the low tier already wrapped this call in // `event_loop.enter()/exit()` which clears any pending exception, so // dropping the `Err` is correct. - let _ = crate::node::node_cluster_binding::handle_internal_message_child( - global, - data, - JSValue::UNDEFINED, - ); + let _ = crate::node::node_cluster_binding::handle_internal_message_child(global, data, handle); } /// `node_cluster_binding.child_singleton.deinit()` — @@ -3843,6 +3843,7 @@ fn get_hardcoded_module( Some(js_synthetic_module(b"node:zlib/iter", specifier)) } HardcodedModule::BunInternalForTesting + | HardcodedModule::InternalClusterRoundRobinHandle | HardcodedModule::NodeInternalRepl | HardcodedModule::NodeInternalReplAwait | HardcodedModule::NodeInternalReplHistory diff --git a/src/runtime/node/node_cluster_binding.rs b/src/runtime/node/node_cluster_binding.rs index 2af8b0d38d80..c9c22e06194f 100644 --- a/src/runtime/node/node_cluster_binding.rs +++ b/src/runtime/node/node_cluster_binding.rs @@ -4,9 +4,8 @@ // at all. It should happen in the protocol before it reaches JS. // - We should not be creating JSFunction's in process.nextTick. -use crate::ipc::{Handle, IsInternal, SerializeAndSendResult}; -use bun_core::String as BunString; -use bun_jsc::{CallFrame, JSGlobalObject, JSValue, JsResult, StringJsc as _, StrongOptional}; +use crate::ipc::{IsInternal, SerializeAndSendResult}; +use bun_jsc::{CallFrame, JSGlobalObject, JSValue, JsResult, StrongOptional}; use crate::api::bun::subprocess::Subprocess; @@ -17,15 +16,6 @@ pub use crate::ipc::InternalMsgHolder; bun_output::declare_scope!(IPC, visible); -// `JSGlobalObject` is `#[repr(C)]` with `UnsafeCell<[u8; 0]>` — `&JSGlobalObject` -// is ABI-identical to a non-null pointer with no `readonly`/`noalias`. Both -// shims take only the global plus by-value `JSValue`s, so the validity proof -// lives in the type signature. -unsafe extern "C" { - pub safe fn Bun__Process__queueNextTick1(global: &JSGlobalObject, f: JSValue, arg: JSValue); - pub(crate) safe fn Process__emitErrorEvent(global: &JSGlobalObject, value: JSValue); -} - // ArrayHashMap::new() is not const, so the global is lazily seeded on first // access via `child_singleton()`. // PORTING.md §Global mutable state: JS-thread-only singleton with `!Sync` @@ -49,91 +39,6 @@ fn child_singleton<'a>() -> &'a mut InternalMsgHolder { unsafe { (*CHILD_SINGLETON.get()).get_or_insert_with(Default::default) } } -#[bun_jsc::host_fn] -pub(crate) fn send_helper_child(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { - bun_output::scoped_log!(IPC, "sendHelperChild"); - - let [message, handle, callback] = frame.arguments_as_array::<3>(); - - let vm = global.bun_vm().as_mut(); - // SAFETY: `bun_vm()` never returns null for a Bun-owned global; sole &mut on JS thread. - - if !crate::ipc_host::has_ipc(vm) { - return Ok(JSValue::FALSE); - } - if message.is_undefined() { - return Err(global.throw_missing_arguments_value(&["message"])); - } - if !handle.is_null() { - return Err(global.throw(format_args!("passing 'handle' not implemented yet"))); - } - if !message.is_object() { - return Err(global.throw_invalid_argument_type_value("message", "object", message)); - } - let singleton = child_singleton(); - if callback.is_function() { - // TODO: remove this strong. This is expensive and would be an easy way to create a memory leak. - // These sequence numbers shouldn't exist from JavaScript's perspective at all. - let _ = singleton - .callbacks - .put(singleton.seq, StrongOptional::create(callback, global)); - } - - // sequence number for InternalMsgHolder - message.put(global, b"seq", JSValue::js_number(singleton.seq as f64)); - singleton.seq = singleton.seq.wrapping_add(1); - - // similar code as Bun__Process__send - #[cfg(debug_assertions)] - { - let mut formatter = bun_jsc::console_object::Formatter::new(global); - bun_output::scoped_log!( - IPC, - "child: {}", - bun_jsc::console_object::formatter::ZigFormatter::new(&mut formatter, message) - ); - } - - let ipc_instance = crate::ipc_host::get_ipc_instance(vm).unwrap(); - // SAFETY: `get_ipc_instance` returns a live owned IPCInstance pointer. - let ipc_instance = unsafe { &*ipc_instance }; - - #[bun_jsc::host_fn] - fn impl_(global_: &JSGlobalObject, frame_: &CallFrame) -> JsResult { - let arguments_ = frame_.arguments(); - let ex = arguments_[0]; - Process__emitErrorEvent(global_, ex.to_error().unwrap_or(ex)); - Ok(JSValue::UNDEFINED) - } - - let good = ipc_instance.data().serialize_and_send( - global, - message, - IsInternal::Internal, - JSValue::NULL, - None, - ); - - if good == SerializeAndSendResult::Failure { - let ex = global.create_type_error_instance(format_args!("sendInternal() failed")); - ex.put( - global, - b"syscall", - BunString::static_str("write").to_js(global)?, - ); - let fnvalue = - bun_jsc::JSFunction::create(global, "", __jsc_host_impl_, 1, Default::default()); - JSValue::call_next_tick_1(fnvalue, global, ex)?; - return Ok(JSValue::FALSE); - } - - Ok(if good == SerializeAndSendResult::Success { - JSValue::TRUE - } else { - JSValue::FALSE - }) -} - #[bun_jsc::host_fn] pub(crate) fn on_internal_message_child( global: &JSGlobalObject, @@ -170,14 +75,14 @@ pub(crate) fn send_helper_primary(global: &JSGlobalObject, frame: &CallFrame) -> // is `undefined` unless `cluster.fork()` created the process; Node's // `sendHelper` returns false for a worker with no IPC channel. let Some(subprocess) = arguments[0].as_class_ref::>() else { - return Ok(JSValue::FALSE); + return Ok(JSValue::NULL); }; let message = arguments[1]; let handle = arguments[2]; let callback = arguments[3]; let Some(ipc_data) = subprocess.ipc() else { - return Ok(JSValue::FALSE); + return Ok(JSValue::NULL); }; if message.is_undefined() { @@ -186,61 +91,70 @@ pub(crate) fn send_helper_primary(global: &JSGlobalObject, frame: &CallFrame) -> if !message.is_object() { return Err(global.throw_invalid_argument_type_value("message", "object", message)); } - // Only NODE_HANDLE envelopes (built by cluster/primary.ts's send()) carry - // a descriptor: the non-reading UDP wrap of a cluster-shared dgram socket. - // Any other handle argument (e.g. round-robin newconn) keeps the internal, - // handle-less form the worker's decoder expects. Converted before the ack - // callback is registered so a failure here cannot strand a never-acked - // entry in the callback table. - let carries_descriptor = if handle.is_undefined_or_null() { - false - } else if let Some(cmd) = message.get(global, "cmd")? { - cmd.is_string() - && bun_core::OwnedString::new(cmd.to_bun_string(global)?).eql_comptime(b"NODE_HANDLE") - } else { - false - }; - let (zig_handle, is_internal): (Option, IsInternal) = if !carries_descriptor { - (None, IsInternal::Internal) - } else { + let mut native_handle: Option = None; + if !handle.is_null() && !handle.is_undefined() { + let Some(fd_value) = handle.get(global, "fd")? else { + return Err(global.throw(format_args!("cluster handle is missing 'fd'"))); + }; + if !fd_value.is_number() { + return Err(global.throw_invalid_argument_type_value("handle.fd", "number", fd_value)); + } + #[cfg(not(windows))] + let native_fd = { + let raw_fd = fd_value.to_int32(); + if raw_fd < 0 { + return Ok(JSValue::NULL); + } + bun_sys::Fd::from_uv(raw_fd) + }; + #[cfg(windows)] + let native_fd = { + let raw = fd_value.to_number(global)?; + if !(raw.is_finite() && raw >= 0.0) { + return Ok(JSValue::NULL); + } + bun_sys::Fd::from_system(raw as u64 as usize as *mut core::ffi::c_void) + }; + message.put(global, b"$hasHandle", JSValue::TRUE); #[cfg(windows)] { - // Sending descriptors over IPC is not implemented on Windows; - // Node reports the same for cluster-shared dgram handles. - return Err(global.throw(format_args!( - "passing a dgram handle over IPC is not supported on Windows" - ))); + let peer_pid = subprocess.pid() as u32; + let Some(hex) = crate::ipc_host::attach_windows_socket_payload( + global, message, native_fd, peer_pid, + ) else { + return Ok(JSValue::NULL); + }; + let mut h = crate::ipc::Handle::init(native_fd, handle); + h.win_export_hex = Some(hex); + h.peer_pid = peer_pid; + native_handle = Some(h); } #[cfg(not(windows))] { - let fd = match handle.get(global, "fd")? { - Some(value) => value.coerce_to_i32(global)?, - None => -1, + native_handle = match crate::ipc::Handle::init_dup(native_fd, handle, false) { + Ok(h) => Some(h), + Err(_) => return Ok(JSValue::NULL), }; - if fd < 0 { - return Err(global - .throw_invalid_arguments(format_args!("Expected handle to have a valid fd"))); - } - ( - Some(Handle::init(bun_sys::Fd::from_native(fd), handle)), - IsInternal::External, - ) } - }; - - let seq = ipc_data.internal_msg_queue.with_mut(|q| { + } + let this_seq = ipc_data.internal_msg_queue.with_mut(|q| { + let seq = q.seq; if callback.is_function() { let _ = q .callbacks - .put(q.seq, StrongOptional::create(callback, global)); + .put(seq, StrongOptional::create(callback, global)); } - let seq = q.seq; - q.seq = q.seq.wrapping_add(1); + q.seq = seq.wrapping_add(1); seq }); + if callback.is_function() { + if let Some(h) = &mut native_handle { + h.cluster_seq = Some(this_seq); + } + } // sequence number for InternalMsgHolder - message.put(global, b"seq", JSValue::js_number(seq as f64)); + message.put(global, b"seq", JSValue::js_number(this_seq as f64)); // similar code as bun.jsc.Subprocess.doSend #[cfg(debug_assertions)] @@ -253,12 +167,17 @@ pub(crate) fn send_helper_primary(global: &JSGlobalObject, frame: &CallFrame) -> ); } - let success = - ipc_data.serialize_and_send(global, message, is_internal, JSValue::NULL, zig_handle); - Ok(if success == SerializeAndSendResult::Success { - JSValue::TRUE - } else { - JSValue::FALSE + let success = ipc_data.serialize_and_send( + global, + message, + IsInternal::Internal, + JSValue::NULL, + native_handle, + ); + Ok(match success { + SerializeAndSendResult::Success => JSValue::TRUE, + SerializeAndSendResult::Backoff => JSValue::FALSE, + SerializeAndSendResult::Failure => JSValue::NULL, }) } @@ -398,3 +317,494 @@ pub fn should_ignore_one_disconnect_event_listener(global: &JSGlobalObject) -> b let vm = global.bun_vm(); vm.channel_ref_should_ignore_one_disconnect_event_listener } + +#[bun_jsc::host_fn] +pub(crate) fn cluster_raw_bind(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + #[cfg(windows)] + { + let arguments = frame.arguments_as_array::<4>(); + let address_type = arguments[0]; + let address = arguments[1]; + let port = arguments[2].to_int32(); + let flags = arguments[3].to_int32(); + + if address_type.is_string() || address_type.to_int32() == -1 { + return Ok(JSValue::js_number_from_int32(-bun_sys::UV_E::NOTSUP)); + } + let atype = address_type.to_int32(); + + let host_owned: Vec = if address.is_string() { + let s = bun_jsc::JSString::opaque_ref(address.as_string()).to_slice(global); + let mut v = s.slice().to_vec(); + v.push(0); + v + } else { + b"::\0".to_vec() + }; + let fallback_host: Option<&[u8]> = if address.is_string() { + None + } else { + Some(b"0.0.0.0\0") + }; + let _ = atype; + + let options: core::ffi::c_int = if flags & 1 != 0 { + bun_uws::LIBUS_SOCKET_IPV6_ONLY + } else { + 0 + }; + + let mut out_port: core::ffi::c_int = 0; + let mut err: core::ffi::c_int = 0; + // SAFETY: `host_owned` is NUL-terminated; out params are live locals. + let mut fd = unsafe { + bun_uws::socket_transfer::bsd_create_bound_socket( + host_owned.as_ptr().cast(), + if port >= 0 { port } else { 0 }, + options, + &mut out_port, + &mut err, + ) + }; + const WSAEADDRINUSE: core::ffi::c_int = 10048; + if fd == bun_uws::LIBUS_SOCKET_DESCRIPTOR::MAX && err != WSAEADDRINUSE { + if let Some(v4) = fallback_host { + let mut err2: core::ffi::c_int = 0; + // SAFETY: as above. + let retry = unsafe { + bun_uws::socket_transfer::bsd_create_bound_socket( + v4.as_ptr().cast(), + if port >= 0 { port } else { 0 }, + options, + &mut out_port, + &mut err2, + ) + }; + if retry != bun_uws::LIBUS_SOCKET_DESCRIPTOR::MAX { + err = 0; + fd = retry; + } + } + } + if fd == bun_uws::LIBUS_SOCKET_DESCRIPTOR::MAX { + // SAFETY: pure translation function. + let uv_err = unsafe { bun_libuv_sys::uv_translate_sys_error(err) }; + return Ok(JSValue::js_number_from_int32(if uv_err != 0 { + uv_err + } else { + -4094 + })); + } + + let obj = JSValue::create_empty_object(global, 2); + obj.put( + global, + b"fd", + if (fd as u64) <= i32::MAX as u64 { + JSValue::js_number_from_int32(fd as i32) + } else { + JSValue::js_number_from_uint64(fd as u64) + }, + ); + obj.put(global, b"port", JSValue::js_number_from_int32(out_port)); + return Ok(obj); + } + #[cfg(not(windows))] + { + use core::ffi::c_int; + + let arguments = frame.arguments_as_array::<4>(); + let address_type = arguments[0]; + let address = arguments[1]; + let port = arguments[2].to_int32(); + let flags = arguments[3].to_int32(); + + let mut is_udp = false; + let atype: i32; + if address_type.is_string() { + let s = bun_jsc::JSString::opaque_ref(address_type.as_string()).to_slice(global); + is_udp = true; + atype = if s.slice() == b"udp6" { 6 } else { 4 }; + } else { + atype = address_type.to_int32(); + } + + fn last_neg_errno() -> JSValue { + JSValue::js_number_from_int32(-bun_core::ffi::errno()) + } + + fn close_fd(fd: c_int) { + let _ = bun_sys::FdExt::close_allowing_standard_io(bun_sys::Fd::from_native(fd), None); + } + + fn set_cloexec_nonblock(fd: c_int) { + let fd = bun_sys::Fd::from_native(fd); + let _ = bun_sys::set_close_on_exec(fd); + let _ = bun_sys::set_nonblocking(fd); + } + + if atype == -1 { + if !address.is_string() { + return Err(global.throw_invalid_argument_type_value("address", "string", address)); + } + let path_slice = bun_jsc::JSString::opaque_ref(address.as_string()).to_slice(global); + let path_bytes = path_slice.slice(); + // SAFETY: sockaddr_un is plain C data; all-zero is a valid value. + let mut sun: libc::sockaddr_un = unsafe { bun_core::ffi::zeroed_unchecked() }; + sun.sun_family = libc::AF_UNIX as libc::sa_family_t; + if path_bytes.len() >= sun.sun_path.len() { + return Ok(JSValue::js_number_from_int32(-(libc::ENAMETOOLONG))); + } + for (i, b) in path_bytes.iter().enumerate() { + sun.sun_path[i] = *b as _; + } + // SAFETY: socket/bind FFI with a NUL-safe sockaddr built above; + unsafe { + let fd = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0); + if fd < 0 { + return Ok(last_neg_errno()); + } + set_cloexec_nonblock(fd); + // Abstract names (leading NUL) are length-delimited: a padded length binds a different name. + let len = if path_bytes.first() == Some(&0) { + core::mem::offset_of!(libc::sockaddr_un, sun_path) + path_bytes.len() + } else { + core::mem::size_of::() + } as libc::socklen_t; + if libc::bind(fd, (&raw const sun).cast(), len) != 0 { + let e = last_neg_errno(); + close_fd(fd); + return Ok(e); + } + let obj = JSValue::create_empty_object(global, 2); + obj.put(global, b"fd", JSValue::js_number_from_int32(fd)); + obj.put(global, b"port", JSValue::js_number_from_int32(-1)); + return Ok(obj); + } + } + + let family: c_int = if atype == 6 { + libc::AF_INET6 + } else { + libc::AF_INET + }; + let socktype: c_int = if is_udp { + libc::SOCK_DGRAM + } else { + libc::SOCK_STREAM + }; + + fn wildcard_sockaddr( + family: c_int, + port: i32, + ) -> (libc::sockaddr_storage, libc::socklen_t) { + // SAFETY: sockaddr_storage is plain C data; all-zero is a valid + unsafe { + let mut ss: libc::sockaddr_storage = bun_core::ffi::zeroed_unchecked(); + let ss_len: libc::socklen_t = if family == libc::AF_INET6 { + let sin6: &mut libc::sockaddr_in6 = + &mut *(&raw mut ss).cast::(); + sin6.sin6_family = libc::AF_INET6 as libc::sa_family_t; + sin6.sin6_port = (port as u16).to_be(); + core::mem::size_of::() as libc::socklen_t + } else { + let sin: &mut libc::sockaddr_in = + &mut *(&raw mut ss).cast::(); + sin.sin_family = libc::AF_INET as libc::sa_family_t; + sin.sin_port = (port as u16).to_be(); + sin.sin_addr.s_addr = libc::INADDR_ANY.to_be(); + core::mem::size_of::() as libc::socklen_t + }; + (ss, ss_len) + } + } + + fn create_and_bind( + family: c_int, + socktype: c_int, + is_udp: bool, + flags: i32, + ss: &libc::sockaddr_storage, + ss_len: libc::socklen_t, + ) -> Result { + // SAFETY: socket/setsockopt/bind FFI on a freshly created fd with + unsafe { + let fd = libc::socket(family, socktype, 0); + if fd < 0 { + return Err(-bun_core::ffi::errno()); + } + set_cloexec_nonblock(fd); + + let one: c_int = 1; + let one_ptr = (&raw const one).cast::(); + let one_len = core::mem::size_of::() as libc::socklen_t; + if !is_udp { + libc::setsockopt(fd, libc::SOL_SOCKET, libc::SO_REUSEADDR, one_ptr, one_len); + } else if flags & 0x4 != 0 { + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))] + { + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_REUSEPORT, + one_ptr, + one_len, + ); + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_REUSEADDR, + one_ptr, + one_len, + ); + } + #[cfg(not(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd" + )))] + { + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_REUSEADDR, + one_ptr, + one_len, + ); + } + } + if family == libc::AF_INET6 { + let v6only: libc::c_int = if flags & 0x1 != 0 { 1 } else { 0 }; + libc::setsockopt( + fd, + libc::IPPROTO_IPV6, + libc::IPV6_V6ONLY, + (&raw const v6only).cast(), + one_len, + ); + } + + if libc::bind(fd, core::ptr::from_ref(ss).cast(), ss_len) != 0 { + let e = -bun_core::ffi::errno(); + close_fd(fd); + return Err(e); + } + Ok(fd) + } + } + + // SAFETY: sockaddr_storage is plain C data; all-zero is a valid value. + let mut ss: libc::sockaddr_storage = unsafe { bun_core::ffi::zeroed_unchecked() }; + let ss_len: libc::socklen_t; + let fd: c_int; + let bound_family: c_int; + if address.is_string() { + let addr_slice = bun_jsc::JSString::opaque_ref(address.as_string()).to_slice(global); + let addr_bytes = addr_slice.slice(); + let mut addr_z: [u8; 256] = [0; 256]; + if addr_bytes.len() >= addr_z.len() { + return Ok(JSValue::js_number_from_int32(-(libc::EINVAL))); + } + addr_z[..addr_bytes.len()].copy_from_slice(addr_bytes); + + unsafe extern "C" { + fn ares_inet_pton( + af: c_int, + src: *const core::ffi::c_char, + dst: *mut core::ffi::c_void, + ) -> c_int; + } + // SAFETY: `ss` is a zeroed sockaddr_storage large enough for + let parsed = unsafe { + if family == libc::AF_INET6 { + let sin6: &mut libc::sockaddr_in6 = + &mut *(&raw mut ss).cast::(); + sin6.sin6_family = libc::AF_INET6 as libc::sa_family_t; + sin6.sin6_port = (port as u16).to_be(); + ares_inet_pton( + libc::AF_INET6, + addr_z.as_ptr().cast(), + (&raw mut sin6.sin6_addr).cast(), + ) == 1 + } else { + let sin: &mut libc::sockaddr_in = + &mut *(&raw mut ss).cast::(); + sin.sin_family = libc::AF_INET as libc::sa_family_t; + sin.sin_port = (port as u16).to_be(); + ares_inet_pton( + libc::AF_INET, + addr_z.as_ptr().cast(), + (&raw mut sin.sin_addr).cast(), + ) == 1 + } + }; + if !parsed { + // SAFETY: addrinfo is plain C data; all-zero is a valid hints value. + let mut hints: libc::addrinfo = unsafe { bun_core::ffi::zeroed_unchecked() }; + hints.ai_family = family; + hints.ai_socktype = socktype; + let mut res: *mut libc::addrinfo = core::ptr::null_mut(); + // SAFETY: `addr_z` is NUL-terminated; out-params are live locals. + let rc = unsafe { + libc::getaddrinfo( + addr_z.as_ptr().cast(), + core::ptr::null(), + &raw const hints, + &raw mut res, + ) + }; + if rc != 0 || res.is_null() { + return Ok(JSValue::js_number_from_int32(-(libc::EINVAL))); + } + // SAFETY: rc == 0 and res was null-checked; ai_addr/ai_addrlen + unsafe { + let ai = &*res; + core::ptr::copy_nonoverlapping( + ai.ai_addr.cast::(), + (&raw mut ss).cast::(), + ai.ai_addrlen as usize, + ); + libc::freeaddrinfo(res); + if family == libc::AF_INET6 { + (*(&raw mut ss).cast::()).sin6_port = + (port as u16).to_be(); + } else { + (*(&raw mut ss).cast::()).sin_port = + (port as u16).to_be(); + } + } + } + ss_len = if family == libc::AF_INET6 { + core::mem::size_of::() as libc::socklen_t + } else { + core::mem::size_of::() as libc::socklen_t + }; + match create_and_bind(family, socktype, is_udp, flags, &ss, ss_len) { + Ok(bound) => { + fd = bound; + bound_family = family; + } + Err(e) => return Ok(JSValue::js_number_from_int32(e)), + } + } else { + let (ss6, len6) = wildcard_sockaddr(libc::AF_INET6, port); + match create_and_bind(libc::AF_INET6, socktype, is_udp, flags, &ss6, len6) { + Ok(bound) => { + fd = bound; + bound_family = libc::AF_INET6; + } + Err(e) if e == -(libc::EADDRINUSE) => { + return Ok(JSValue::js_number_from_int32(e)); + } + Err(_) => { + let (ss4, len4) = wildcard_sockaddr(libc::AF_INET, port); + match create_and_bind(libc::AF_INET, socktype, is_udp, flags, &ss4, len4) { + Ok(bound) => { + fd = bound; + bound_family = libc::AF_INET; + } + Err(e) => return Ok(JSValue::js_number_from_int32(e)), + } + } + } + } + + // SAFETY: getsockname FFI on the bound fd with a properly sized + unsafe { + let mut bound_port = port; + let mut out: libc::sockaddr_storage = bun_core::ffi::zeroed_unchecked(); + let mut out_len = core::mem::size_of::() as libc::socklen_t; + if libc::getsockname(fd, (&raw mut out).cast(), &raw mut out_len) == 0 { + bound_port = if bound_family == libc::AF_INET6 { + u16::from_be((*(&raw const out).cast::()).sin6_port) as i32 + } else { + u16::from_be((*(&raw const out).cast::()).sin_port) as i32 + }; + } + + let obj = JSValue::create_empty_object(global, 2); + obj.put(global, b"fd", JSValue::js_number_from_int32(fd)); + obj.put(global, b"port", JSValue::js_number_from_int32(bound_port)); + Ok(obj) + } + } +} + +#[bun_jsc::host_fn] +pub(crate) fn cluster_validate_fd(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + let _ = global; + let value = frame.arguments_as_array::<1>()[0]; + if !value.is_number() { + return Ok(JSValue::js_number_from_int32(-bun_sys::UV_E::INVAL)); + } + #[cfg(not(windows))] + { + let fd = value.to_int32(); + if fd < 0 { + return Ok(JSValue::js_number_from_int32(-bun_sys::UV_E::BADF)); + } + let mut ty: libc::c_int = 0; + let mut len = core::mem::size_of::() as libc::socklen_t; + // SAFETY: plain getsockopt on a caller-supplied fd; out-params are + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_TYPE, + (&raw mut ty).cast(), + &raw mut len, + ) + }; + // node's createServerHandle: EINVAL for anything that cannot listen (e.g. a connected stdio socketpair), fd left untouched. + if rc != 0 || (ty != libc::SOCK_STREAM && ty != libc::SOCK_DGRAM) { + return Ok(JSValue::js_number_from_int32(-bun_sys::UV_E::INVAL)); + } + if ty == libc::SOCK_STREAM { + // SAFETY: sockaddr_storage is plain data; getpeername only writes within `peer_len`. + let connected = unsafe { + let mut peer: libc::sockaddr_storage = bun_core::ffi::zeroed_unchecked(); + let mut peer_len = + core::mem::size_of::() as libc::socklen_t; + libc::getpeername(fd, (&raw mut peer).cast(), &raw mut peer_len) == 0 + }; + if connected { + return Ok(JSValue::js_number_from_int32(-bun_sys::UV_E::INVAL)); + } + } + Ok(JSValue::js_number_from_int32(0)) + } + #[cfg(windows)] + { + let _ = value; + Ok(JSValue::js_number_from_int32(-bun_sys::UV_E::INVAL)) + } +} + +#[bun_jsc::host_fn] +pub(crate) fn cluster_close_handle( + global: &JSGlobalObject, + frame: &CallFrame, +) -> JsResult { + let _ = global; + let value = frame.arguments_as_array::<1>()[0]; + if value.is_number() { + #[cfg(windows)] + { + let raw = value.to_number(global)?; + if raw.is_finite() && raw >= 0.0 { + bun_uws::socket_transfer::bsd_close_socket( + raw as u64 as bun_uws::LIBUS_SOCKET_DESCRIPTOR, + ); + } + } + #[cfg(not(windows))] + { + let fd = value.to_int32(); + if fd >= 0 { + let _ = + bun_sys::FdExt::close_allowing_standard_io(bun_sys::Fd::from_native(fd), None); + } + } + } + Ok(JSValue::UNDEFINED) +} diff --git a/src/runtime/node/node_util_binding.rs b/src/runtime/node/node_util_binding.rs index 9dbd65253cba..335d1741b88c 100644 --- a/src/runtime/node/node_util_binding.rs +++ b/src/runtime/node/node_util_binding.rs @@ -56,6 +56,35 @@ pub(crate) fn enobufs_error_code( Ok(JSValue::js_number_from_int32(-UV_E::NOBUFS)) } +#[bun_jsc::host_fn] +pub(crate) fn uv_translate_sys_error( + _global: &JSGlobalObject, + frame: &CallFrame, +) -> JsResult { + let arg = frame.arguments_as_array::<1>()[0]; + if !arg.is_number() { + return Ok(JSValue::js_number_from_int32(-UV_E::INVAL)); + } + let n = arg.to_int32(); + if n <= 0 { + return Ok(JSValue::js_number_from_int32(n)); + } + #[cfg(windows)] + { + // SAFETY: pure translation function. + let uv_err = unsafe { bun_libuv_sys::uv_translate_sys_error(n) }; + return Ok(JSValue::js_number_from_int32(if uv_err != 0 { + uv_err + } else { + -UV_E::INVAL + })); + } + #[cfg(not(windows))] + { + Ok(JSValue::js_number_from_int32(-n)) + } +} + /// libuv's ECANCELED code (`uv_udp_send` requests cancelled by close). Not a /// JS-side literal (unlike EBADF/EINVAL, ECANCELED's number differs across the /// POSIX platforms: Linux 125, Darwin 89, FreeBSD 85; synthetic -4081 on diff --git a/src/runtime/socket/Handlers.rs b/src/runtime/socket/Handlers.rs index 4df0cbebcb2d..8950f8f791a0 100644 --- a/src/runtime/socket/Handlers.rs +++ b/src/runtime/socket/Handlers.rs @@ -487,7 +487,16 @@ impl SocketConfig { break 'blk SocketConfig { hostname_or_unix: ZigStringSlice::empty(), port: None, - fd: generated.fd.map(Fd::from_uv), + fd: generated.fd.map(|v| { + #[cfg(windows)] + { + Fd::from_system(v as u32 as usize as *mut core::ffi::c_void) + } + #[cfg(not(windows))] + { + Fd::from_uv(v) + } + }), ssl, handlers: Handlers::from_generated(global, &generated.handlers, mode)?, default_data: if generated.data.is_undefined() { @@ -504,6 +513,11 @@ impl SocketConfig { // On any `?` below, `result` drops and releases what it owns — no // manual error-path cleanup needed. + result.exclusive = generated.exclusive; + result.allow_half_open = generated.allow_half_open; + result.reuse_port = generated.reuse_port; + result.ipv6_only = generated.ipv6_only; + if result.fd.is_some() { // If a user passes a file descriptor then prefer it over hostname or unix } else if let Some(unix) = generated.unix_.get() { @@ -543,10 +557,6 @@ impl SocketConfig { } }, }); - result.exclusive = generated.exclusive; - result.allow_half_open = generated.allow_half_open; - result.reuse_port = generated.reuse_port; - result.ipv6_only = generated.ipv6_only; } else { return Err(global.throw_invalid_arguments(format_args!( "Expected either \"hostname\" or \"unix\"" diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index d1c3a18fe91a..ea78a7bb4f51 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -269,15 +269,14 @@ impl Listener { // Surface coded syscall failures the way node:net // does (EADDRINUSE vs EACCES need different caller // handling) rather than an invalid-arguments TypeError. - if let ListenPipeError::Sys(sys_err) = &e { + if let ListenPipeError::Sys(sys_err, uv_errno) = &e { // get_error_code_tag_name does not reject EUNKNOWN / // UV_EAI_* (>=3000); neither is a node-style code, so // route those through the generic error below. if let Some((name, se)) = sys_err.get_error_code_tag_name() { if se != bun_sys::SystemErrno::EUNKNOWN && (se as u16) < 3000 { let err = jsc::SystemError { - // Negated errno per fill_system_error_common. - errno: -(se as c_int), + errno: *uv_errno, code: bun_core::String::static_(name).into(), message: bun_core::String::clone_utf8( format!( @@ -300,7 +299,7 @@ impl Listener { let detail = match &e { ListenPipeError::Other(err) => err.name(), // Sys whose errno has no node-style code (EUNKNOWN / UV_EAI_*). - ListenPipeError::Sys(_) => "UNKNOWN", + ListenPipeError::Sys(..) => "UNKNOWN", }; return Err(global.throw_invalid_arguments(format_args!( "Failed to listen at {}: {}", @@ -473,18 +472,18 @@ impl Listener { ) }), UnixOrHost::Fd(fd) => { - let err = jsc::SystemError { - errno: bun_sys::SystemErrno::EINVAL as c_int, - code: bun_core::String::static_("EINVAL").into(), - message: bun_core::String::static_( - "Bun does not support listening on a file descriptor.", + let fd_native = fd.native() as uws_sys::LIBUS_SOCKET_DESCRIPTOR; + this_ref.group.with_mut(|g| { + g.listen_fd( + kind, + secure_ctx_ptr, + fd_native, + 511, + socket_flags, + size_of::<*mut c_void>() as c_int, + &mut errno, ) - .into(), - syscall: bun_core::String::static_("listen").into(), - fd: fd.uv(), - ..Default::default() - }; - return Err(global.throw_value(err.to_error_instance(global))); + }) } }; if listen_socket.is_null() { @@ -499,9 +498,15 @@ impl Listener { bstr::BStr::new(hostname_bytes) )); log!("Failed to listen {}", errno); - // libuv reports UV_EINVAL for a pipe path it cannot express in a - // sockaddr_un, which is what Node surfaces for an over-long path. - let errno = if errno == bun_sys::SystemErrno::ENAMETOOLONG as c_int { + let mapped = bun_sys::SystemErrno::init(errno as i64); + let errno = if mapped == Some(bun_sys::SystemErrno::ENAMETOOLONG) + || (matches!(connection, UnixOrHost::Fd(_)) + && matches!( + mapped, + Some(bun_sys::SystemErrno::ENOTSOCK) + | Some(bun_sys::SystemErrno::EBADF) + | Some(bun_sys::SystemErrno::EOPNOTSUPP) + )) { bun_sys::SystemErrno::EINVAL as c_int } else { errno @@ -1083,6 +1088,16 @@ impl Listener { let connection: UnixOrHost = 'blk: { if let Some(fd_) = opts.get_truthy(global, "fd")? { if fd_.is_number() { + #[cfg(windows)] + let fd = if opts + .get_truthy(global, "fdIsRawSocket")? + .is_some_and(|v| v.to_boolean()) + { + Fd::from_system(fd_.to_int32() as u32 as usize as *mut c_void) + } else { + Fd::from_uv(fd_.to_int32()) + }; + #[cfg(not(windows))] let fd = Fd::from_uv(fd_.to_int32()); break 'blk UnixOrHost::Fd(fd); } @@ -1171,6 +1186,7 @@ impl Listener { } None => false, }, + UnixOrHost::Fd(fd) if fd.kind() == bun_core::FdKind::System => false, UnixOrHost::Fd(fd) => { let uvfd = fd.uv(); let fd_type = uv::uv_guess_handle(uvfd); @@ -1739,12 +1755,10 @@ pub struct WindowsNamedPipeListeningContext { _priv: (), } -/// `Sys` keeps the structured uv error so the JS error carries its real -/// code/errno; `Other` covers the non-syscall setup failures, whose payload -/// names the failure in the caller's generic invalid-arguments message. +/// `c_int`: raw libuv return code so JS `err.errno` is the platform-correct UV value. #[cfg(windows)] enum ListenPipeError { - Sys(bun_sys::Error), + Sys(bun_sys::Error, c_int), Other(crate::Error), } @@ -1909,8 +1923,9 @@ impl WindowsNamedPipeListeningContext { // EACCES (pipe namespace denied) need different caller // handling, and a generic bind failure hides that. use bun_sys::ReturnCodeExt as _; + let raw = listen_rc.int(); return Err(match listen_rc.to_error(bun_sys::Tag::listen) { - Some(err) => ListenPipeError::Sys(err), + Some(err) => ListenPipeError::Sys(err, raw), // Unreachable in practice: the uv→errno mapping is total. None => ListenPipeError::Other(crate::Error::FailedToBindPipe), }); diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 1465b20cce40..c10ea059cdd1 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -637,6 +637,7 @@ impl NewSocket { ssl_ctx, core::mem::size_of::<*mut c_void>() as c_int, f.native() as uws::LIBUS_SOCKET_DESCRIPTOR, + flags, false, ); if s.is_null() { diff --git a/src/runtime/socket/sockets.classes.ts b/src/runtime/socket/sockets.classes.ts index 2a2695e2359c..2f55ac721ac1 100644 --- a/src/runtime/socket/sockets.classes.ts +++ b/src/runtime/socket/sockets.classes.ts @@ -394,6 +394,9 @@ export default [ closed: { getter: "getClosed", }, + fd: { + getter: "getFd", + }, setBroadcast: { fn: "setBroadcast", length: 1, diff --git a/src/runtime/socket/udp_socket.rs b/src/runtime/socket/udp_socket.rs index 8c8b8367f18e..a5fc9c1df8f0 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -327,6 +327,7 @@ pub struct UDPSocketConfig { /// `socket.bind({ fd })` and cluster-shared sockets. pub(crate) fd: Option, pub(crate) binary_type: BinaryType, + pub(crate) shared_fd: bool, } impl Default for UDPSocketConfig { @@ -338,6 +339,7 @@ impl Default for UDPSocketConfig { flags: 0, fd: None, binary_type: BinaryType::Buffer, + shared_fd: false, } } } @@ -401,11 +403,16 @@ impl UDPSocketConfig { } }; + let shared_fd = options + .fast_get(global_this, bun_jsc::BuiltinName::sharedFd)? + .is_some_and(|v| v.to_boolean()); + let mut config = Self { hostname, port, flags, fd, + shared_fd, ..Default::default() }; @@ -665,6 +672,7 @@ impl UDPSocket { on_close, on_recv_error, fd, + config.shared_fd, Some(&mut err), this_ptr.cast::(), ) @@ -683,6 +691,9 @@ impl UDPSocket { ) }; drop(hostname_z); + if created.is_null() && err == 0 && config.fd.is_some() { + err = libc::EINVAL; + } this.socket.set(if created.is_null() { None } else { @@ -1730,6 +1741,17 @@ impl UDPSocket { JSValue::from(this.closed.get()) } + #[bun_jsc::host_fn(getter)] + pub(crate) fn get_fd(this: &Self, _: &JSGlobalObject) -> JSValue { + if this.closed.get() { + return JSValue::js_number(-1.0); + } + let Some(socket) = this.socket.get() else { + return JSValue::js_number(-1.0); + }; + JSValue::js_number(uws::udp::Socket::opaque_mut(socket).fd() as f64) + } + #[bun_jsc::host_fn(getter)] pub(crate) fn get_hostname(this: &Self, _: &JSGlobalObject) -> JsResult { this.config.get().hostname.to_js(this.global_this.get()) @@ -1977,6 +1999,29 @@ impl UDPSocket { Ok(JSValue::js_number(f64::from(value))) } + /// Underlying socket descriptor, or `None` once closed. IPC send dups/exports this for a `dgram.Socket` handle. + pub(crate) fn native_fd(&self) -> Option { + if self.closed.get() { + return None; + } + let socket = self.socket.get()?; + // `Socket` is an `opaque_ffi!` ZST — `opaque_mut` is the safe deref. + let raw = uws::udp::Socket::opaque_mut(socket).fd(); + if raw < 0 { + return None; + } + #[cfg(windows)] + { + Some(bun_sys::Fd::from_system( + raw as usize as *mut core::ffi::c_void, + )) + } + #[cfg(not(windows))] + { + Some(bun_sys::Fd::from_native(raw)) + } + } + /// Underlying socket descriptor as a number, or -1 once closed. Backs /// node:dgram's handle.fd. // See `js_connect` — codegen `JsClass` derive owns the link name. diff --git a/src/runtime/webview/ChromeBackend.cpp b/src/runtime/webview/ChromeBackend.cpp index cac7f405155d..2bce2e3c961e 100644 --- a/src/runtime/webview/ChromeBackend.cpp +++ b/src/runtime/webview/ChromeBackend.cpp @@ -329,7 +329,7 @@ bool Transport::ensureSpawned(Zig::GlobalObject* zig, const WTF::String& userDat // care about READABLE — writable events on a read-end pipe fire // constantly, but onWritable is a no-op when m_txQueue is empty so // they're harmless. kind=1 (.dynamic) → dispatch via s_cdpVTable. - m_readSock = us_socket_from_fd(&s_cdpGroup, BUN_SOCKET_KIND_DYNAMIC, nullptr, sizeof(void*), fd, 0); + m_readSock = us_socket_from_fd(&s_cdpGroup, BUN_SOCKET_KIND_DYNAMIC, nullptr, sizeof(void*), fd, 0, 0); if (!m_readSock) { closefd(fd); m_dead = true; diff --git a/src/runtime/webview/WebKitBackend.cpp b/src/runtime/webview/WebKitBackend.cpp index 4f9c85bddc8b..37cd5ea345e9 100644 --- a/src/runtime/webview/WebKitBackend.cpp +++ b/src/runtime/webview/WebKitBackend.cpp @@ -160,7 +160,7 @@ bool HostClient::ensureSpawned(Zig::GlobalObject* zig, bool stdoutInherit, bool // READABLE|WRITABLE. ipc=0 — we're not doing SCM_RIGHTS fd passing. // us_poll_start_rc doesn't touch loop.active; updateKeepAlive is the // sole ref manager. kind=1 (.dynamic) → dispatch via s_hostVTable. - sock = us_socket_from_fd(&s_hostGroup, BUN_SOCKET_KIND_DYNAMIC, nullptr, sizeof(void*), fd, 0); + sock = us_socket_from_fd(&s_hostGroup, BUN_SOCKET_KIND_DYNAMIC, nullptr, sizeof(void*), fd, 0, 0); if (!sock) { // us_socket_from_fd calls us_poll_free on failure but doesn't close // the fd (ownership was ours). Leak it and the child stays alive diff --git a/src/spawn_sys/spawn_process.rs b/src/spawn_sys/spawn_process.rs index bbdf41f6bfd3..37073b63a04b 100644 --- a/src/spawn_sys/spawn_process.rs +++ b/src/spawn_sys/spawn_process.rs @@ -778,7 +778,12 @@ pub unsafe fn spawn_process_posix( } } PosixStdio::Inherit => { - actions.inherit(fileno)?; + // A closed slot would inherit whatever fd is created later at that number (e.g. the ipc socketpair); libuv gives it /dev/null. + if bun_sys::get_fcntl_flags(fileno).is_err() { + actions.open_z(fileno, c"/dev/null", flag | bun_sys::O::CREAT as u32, 0o664)?; + } else { + actions.inherit(fileno)?; + } } PosixStdio::Ipc | PosixStdio::Ignore => { actions.open_z(fileno, c"/dev/null", flag | bun_sys::O::CREAT as u32, 0o664)?; diff --git a/src/uws/lib.rs b/src/uws/lib.rs index bd0c3a6afec9..8dc3b9ce794f 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -31,7 +31,7 @@ pub use bun_uws_sys::{ /// hook, so no `catch_unwind` wrapper is emitted. pub use bun_jsc_macros::uws_callback; pub use bun_uws_sys::response::State; -pub use bun_uws_sys::{h3 as H3, quic, udp, vtable}; +pub use bun_uws_sys::{h3 as H3, quic, socket_transfer, udp, vtable}; pub type Socket = us_socket_t; /// Bare BoringSSL `SSL_CTX`. `SSL_CTX_up_ref`/`SSL_CTX_free` is the refcount; diff --git a/src/uws_sys/SocketGroup.rs b/src/uws_sys/SocketGroup.rs index 2dfe607e99a1..a820e3889ff9 100644 --- a/src/uws_sys/SocketGroup.rs +++ b/src/uws_sys/SocketGroup.rs @@ -202,6 +202,31 @@ impl SocketGroup { } } + pub fn listen_fd( + &mut self, + kind: SocketKind, + ssl_ctx: Option<*mut SslCtx>, + fd: LIBUS_SOCKET_DESCRIPTOR, + backlog: c_int, + options: c_int, + socket_ext_size: c_int, + err: &mut c_int, + ) -> *mut ListenSocket { + // SAFETY: forwarding to C; all pointers are valid or null as documented. + unsafe { + us_socket_group_listen_fd( + self, + kind as u8, + ssl_ctx.unwrap_or(ptr::null_mut()), + fd, + backlog, + options, + socket_ext_size, + err, + ) + } + } + pub fn connect( &mut self, kind: SocketKind, @@ -270,6 +295,7 @@ impl SocketGroup { ssl_ctx: Option<*mut SslCtx>, socket_ext_size: c_int, fd: LIBUS_SOCKET_DESCRIPTOR, + options: c_int, ipc: bool, ) -> *mut us_socket_t { // SAFETY: forwarding to C. @@ -280,6 +306,7 @@ impl SocketGroup { ssl_ctx.unwrap_or(ptr::null_mut()), socket_ext_size, fd, + options, ipc as c_int, ) } @@ -329,6 +356,16 @@ unsafe extern "C" { socket_ext_size: c_int, err: *mut c_int, ) -> *mut ListenSocket; + fn us_socket_group_listen_fd( + group: *mut SocketGroup, + kind: u8, + ssl_ctx: *mut SslCtx, + fd: LIBUS_SOCKET_DESCRIPTOR, + backlog: c_int, + options: c_int, + socket_ext_size: c_int, + err: *mut c_int, + ) -> *mut ListenSocket; /// Returns `us_socket_t*` (fast path) OR `us_connecting_socket_t*` (slow /// path), discriminated by `*is_connecting`. The public `connect()` method /// turns this into the typed `ConnectResult` enum — call that, not this. @@ -359,6 +396,7 @@ unsafe extern "C" { ssl_ctx: *mut SslCtx, socket_ext_size: c_int, fd: LIBUS_SOCKET_DESCRIPTOR, + options: c_int, ipc: c_int, ) -> *mut us_socket_t; fn us_socket_pair( diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index 02414316fc9c..c67acf925bce 100644 --- a/src/uws_sys/lib.rs +++ b/src/uws_sys/lib.rs @@ -170,6 +170,28 @@ bun_core::opaque_extern!( pub UpgradedDuplex, pub WindowsNamedPipe, ); +pub mod socket_transfer { + use super::LIBUS_SOCKET_DESCRIPTOR; + use core::ffi::{c_char, c_int, c_uint, c_void}; + unsafe extern "C" { + pub safe fn bsd_socket_export_size() -> c_int; + pub fn bsd_socket_export( + fd: LIBUS_SOCKET_DESCRIPTOR, + target_pid: c_uint, + info_out: *mut c_void, + ) -> c_int; + pub fn bsd_socket_import(info: *mut c_void, err: *mut c_int) -> LIBUS_SOCKET_DESCRIPTOR; + pub safe fn bsd_close_socket(fd: LIBUS_SOCKET_DESCRIPTOR); + pub fn bsd_create_bound_socket( + host: *const c_char, + port: c_int, + options: c_int, + out_port: *mut c_int, + error: *mut c_int, + ) -> LIBUS_SOCKET_DESCRIPTOR; + } +} + // ── UpgradedDuplex (cycle-break shim) ──────────────────────────────────────── // The full `UpgradedDuplex` lives in `bun_runtime::socket` (T6); `socket.rs` // here dispatches to it from the low-tier `InternalSocket` enum. To avoid an diff --git a/src/uws_sys/socket.rs b/src/uws_sys/socket.rs index 6908661da0d4..efa284040d6a 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -734,6 +734,7 @@ impl NewSocketHandler { None, ext_size, handle.native() as LIBUS_SOCKET_DESCRIPTOR, + 0, is_ipc, ); if raw.is_null() { diff --git a/src/uws_sys/udp.rs b/src/uws_sys/udp.rs index 44addc603bd2..dfe2b06247c5 100644 --- a/src/uws_sys/udp.rs +++ b/src/uws_sys/udp.rs @@ -56,11 +56,10 @@ impl Socket { close_cb: extern "C" fn(*mut Socket), recv_error_cb: extern "C" fn(*mut Socket, c_int, c_int), fd: c_int, + shared: bool, err: Option<&mut c_int>, user_data: *mut c_void, ) -> *mut Socket { - // LIBUS_SOCKET_DESCRIPTOR is `int` on POSIX and `SOCKET` (usize) on - // Windows; the JS-facing contract is a non-negative int. #[cfg(not(windows))] let fd_native: LIBUS_SOCKET_DESCRIPTOR = fd; #[cfg(windows)] @@ -76,10 +75,8 @@ impl Socket { close_cb, recv_error_cb, fd_native, - match err { - Some(e) => std::ptr::from_mut::(e), - None => core::ptr::null_mut(), - }, + shared as c_int, + err.map_or(core::ptr::null_mut(), core::ptr::from_mut), user_data, ) } @@ -240,6 +237,7 @@ unsafe extern "C" { close_cb: extern "C" fn(*mut Socket), recv_error_cb: extern "C" fn(*mut Socket, c_int, c_int), fd: LIBUS_SOCKET_DESCRIPTOR, + shared: c_int, err: *mut c_int, user_data: *mut c_void, ) -> *mut Socket; diff --git a/test/js/bun/net/named-pipe-listen-error.test.ts b/test/js/bun/net/named-pipe-listen-error.test.ts index 84e2ef948806..8cdbe83d8b40 100644 --- a/test/js/bun/net/named-pipe-listen-error.test.ts +++ b/test/js/bun/net/named-pipe-listen-error.test.ts @@ -35,8 +35,8 @@ describe.skipIf(!isWindows)("Bun.listen named-pipe error path", () => { console.error("expected code EADDRINUSE, got", e.code); process.exit(1); } - if (e.errno !== -98) { - console.error("expected errno -98, got", e.errno); + if (require("util").getSystemErrorName(e.errno) !== "EADDRINUSE") { + console.error("expected errno to resolve to EADDRINUSE, got", e.errno); process.exit(1); } if (e.syscall !== "listen") { diff --git a/test/js/bun/spawn/spawn.ipc.bun-node.test.ts b/test/js/bun/spawn/spawn.ipc.bun-node.test.ts index 44138368a4a4..6d0e12eab4a6 100644 --- a/test/js/bun/spawn/spawn.ipc.bun-node.test.ts +++ b/test/js/bun/spawn/spawn.ipc.bun-node.test.ts @@ -20,19 +20,11 @@ p I am your father }); test.skipIf(isWindows || !nodeExe())( - "releases the descriptor of a received handle whose type it does not accept", + "receives a net.Socket handle from a node child and releases its descriptor", async () => { const parentSource = [ `const net = require("node:net");`, - `let reported = false;`, - `const handleFailed = Promise.withResolvers();`, - `process.on("uncaughtException", () => {`, - ` if (!reported) {`, - ` reported = true;`, - ` console.log("handle-error");`, - ` handleFailed.resolve();`, - ` }`, - `});`, + `const gotHandle = Promise.withResolvers();`, `const socketClosed = Promise.withResolvers();`, `const server = net.createServer(socket => {`, ` socket.resume();`, @@ -44,12 +36,15 @@ test.skipIf(isWindows || !nodeExe())( ` cmd: [process.env.NODE_BIN, "-e", childSource],`, ` stdio: ["ignore", "inherit", "inherit"],`, ` serialization: "json",`, - ` ipc() {},`, + ` ipc(message, _subprocess, handle) { gotHandle.resolve({ message, handle }); },`, ` env: { ...process.env, HANDLE_PORT: String(server.address().port) },`, `});`, - `await handleFailed.promise;`, + `const { message, handle } = await gotHandle.promise;`, + `console.log("message:", message);`, + `console.log("handle is a net.Socket:", handle instanceof net.Socket);`, `child.kill();`, `await child.exited;`, + `handle.destroy();`, `await socketClosed.promise;`, `server.close();`, `console.log("socket-closed");`, @@ -65,7 +60,60 @@ test.skipIf(isWindows || !nodeExe())( const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout: normalizeBunSnapshot(stdout), exitCode }).toEqual({ - stdout: "handle-error\nsocket-closed", + stdout: "message: x\nhandle is a net.Socket: true\nsocket-closed", + exitCode: 0, + }); + }, +); + +test.skipIf(isWindows || !nodeExe())( + "receives a dgram.Socket handle from a node child and adopts its descriptor", + async () => { + const parentSource = [ + `const dgram = require("node:dgram");`, + `const gotHandle = Promise.withResolvers();`, + `const gotDatagram = Promise.withResolvers();`, + `const childSource = 'const dgram = require("dgram"); const s = dgram.createSocket("udp4"); s.bind(0, "127.0.0.1", () => { process.send({ port: s.address().port }, s); });';`, + `const child = Bun.spawn({`, + ` cmd: [process.env.NODE_BIN, "-e", childSource],`, + ` stdio: ["ignore", "inherit", "inherit"],`, + ` serialization: "json",`, + ` ipc(message, _subprocess, handle) { gotHandle.resolve({ message, handle }); },`, + ` env: { ...process.env },`, + `});`, + `const { message, handle } = await gotHandle.promise;`, + `console.log("message port:", typeof message.port === "number" && message.port > 0);`, + `console.log("handle is a dgram.Socket:", handle instanceof dgram.Socket);`, + `console.log("adopted port matches:", handle.address().port === message.port);`, + `child.kill();`, + `await child.exited;`, + `handle.on("message", buf => gotDatagram.resolve(buf.toString()));`, + `const sender = dgram.createSocket("udp4");`, + `sender.send("ping", message.port, "127.0.0.1");`, + `console.log("datagram:", await gotDatagram.promise);`, + `sender.close();`, + `handle.close();`, + `console.log("done");`, + ].join("\n"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", parentSource], + env: { ...bunEnv, NODE_BIN: nodeExe()! }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout: normalizeBunSnapshot(stdout), stderr, exitCode }).toEqual({ + stdout: [ + "message port: true", + "handle is a dgram.Socket: true", + "adopted port matches: true", + "datagram: ping", + "done", + ].join("\n"), + stderr: "", exitCode: 0, }); }, diff --git a/test/js/node/child_process/child_process.test.ts b/test/js/node/child_process/child_process.test.ts index f1dfc3ea2350..a7bf59ba32ac 100644 --- a/test/js/node/child_process/child_process.test.ts +++ b/test/js/node/child_process/child_process.test.ts @@ -127,6 +127,75 @@ describe("ChildProcess.spawn()", () => { }); }); +describe("fork() IPC", () => { + it("routes a NODE_-prefixed cmd from parent to the child's internalMessage", async () => { + const dir = tmpdirSync(); + const child_path = path.join(dir, "internal-message-fixture.js"); + await write( + child_path, + `process.on("message", m => console.log("message:" + JSON.stringify(m))); + process.on("internalMessage", m => console.log("internalMessage:" + JSON.stringify(m))); + process.on("disconnect", () => process.exit(0));`, + ); + + const child = fork(child_path, { stdio: ["ignore", "pipe", "inherit", "ipc"] }); + try { + child.send({ cmd: "NODE_foo" }); + // The prefix only counts at position 0, and must be longer than "NODE_". + child.send({ cmd: "fooNODE_" }); + child.send({ cmd: "NODE_" }); + + let out = ""; + for await (const chunk of child.stdout!) { + out += chunk; + if (out.split("\n").length > 3) break; + } + + expect(out.split("\n").filter(Boolean)).toEqual([ + 'internalMessage:{"cmd":"NODE_foo"}', + 'message:{"cmd":"fooNODE_"}', + 'message:{"cmd":"NODE_"}', + ]); + } finally { + if (child.connected) child.disconnect(); + child.kill(); + } + }); + + // libuv maps an inherited stdio slot the parent has closed to /dev/null. Registering a plain + // inherit instead captured whichever fd was created next: the ipc socketpair's parent end, which the + // child then held open as its stdin, so the parent's disconnect() never reached it. + it.skipIf(isWindows)("inherits a closed stdin as /dev/null, so disconnect() still reaches the child", async () => { + const dir = tmpdirSync(); + await write( + path.join(dir, "parent.js"), + `require("fs").closeSync(0); + const child = require("child_process").fork(require("path").join(__dirname, "child.js")); + child.on("message", m => { console.log(JSON.stringify(m)); child.disconnect(); }); + child.on("exit", code => console.log("child exit " + code));`, + ); + await write( + path.join(dir, "child.js"), + `const s = require("fs").fstatSync(0); + process.on("disconnect", () => process.exit(0)); + process.send({ stdin: s.isCharacterDevice() ? "chardev" : s.isSocket() ? "socket" : "other" });`, + ); + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js"], + cwd: dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.split("\n").filter(Boolean), stderr }).toEqual({ + stdout: ['{"stdin":"chardev"}', "child exit 0"], + stderr: "", + }); + expect(exitCode).toBe(0); + }); +}); + describe("spawn()", () => { it("should spawn a process", () => { const child = spawn("bun", ["-v"]); @@ -423,6 +492,62 @@ describe("spawn()", () => { expect(child.stdout).not.toBeNull(); expect(child.stderr).not.toBeNull(); }); + + it.skipIf(isWindows)("accepts another subprocess's stdin as a stdio target", async () => { + // (cat [p1] ; cat [p2]) | cat [p3] + let p1, p2; + const p3 = spawn("cat", { stdio: ["pipe", "pipe", "inherit"] }); + try { + p1 = spawn("cat", { stdio: ["pipe", p3.stdin, "inherit"] }); + p2 = spawn("cat", { stdio: ["pipe", p3.stdin, "inherit"] }); + + p3.stdout.setEncoding("utf8"); + + const firstChunk = once(p3.stdout, "data"); + p1.stdin.end("hello\n"); + expect((await firstChunk)[0]).toBe("hello\n"); + + const secondChunk = once(p3.stdout, "data"); + p2.stdin.end("world\n"); + expect((await secondChunk)[0]).toBe("world\n"); + + const thirdChunk = once(p3.stdout, "data"); + p3.stdin.end("foobar\n"); + expect((await thirdChunk)[0]).toBe("foobar\n"); + } finally { + for (const p of [p1, p2, p3]) p?.kill(); + } + }); + + // The test above passes only because 6-byte writes never fill the buffer. + const itTodoPosix = isWindows ? it.skip : it.todo; + itTodoPosix("a child inheriting another subprocess's stdin survives a large write", async () => { + const size = 4 * 1024 * 1024; + const dir = tmpdirSync(); + const bigPath = path.join(dir, "big.txt"); + await write(bigPath, Buffer.alloc(size, "x")); + + let writer; + const p3 = spawn("cat", { stdio: ["pipe", "pipe", "inherit"] }); + try { + let received = 0; + p3.stdout.on("data", chunk => (received += chunk.length)); + + writer = spawn("cat", [bigPath], { stdio: ["ignore", p3.stdin, "pipe"] }); + let stderr = ""; + writer.stderr.setEncoding("utf8"); + writer.stderr.on("data", chunk => (stderr += chunk)); + + const [code] = await once(writer, "close"); + p3.stdin.end(); + await once(p3.stdout, "end"); + + expect({ code, stderr, received }).toEqual({ code: 0, stderr: "", received: size }); + } finally { + for (const p of [writer, p3]) p?.kill(); + } + }); + it("overlapped string shorthand behaves like pipe", async () => { const child = spawn(bunExe(), ["-e", "process.stdin.on('data', d => process.stdout.write('out:' + d))"], { env: bunEnv, diff --git a/test/js/node/child_process/child_process_ipc_handle.test.ts b/test/js/node/child_process/child_process_ipc_handle.test.ts new file mode 100644 index 000000000000..29fc6c105bd3 --- /dev/null +++ b/test/js/node/child_process/child_process_ipc_handle.test.ts @@ -0,0 +1,658 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, nodeExe, tempDir, tls } from "harness"; + +const node = nodeExe(); + +describe.skipIf(isWindows)("process.send(message, handle)", () => { + test.concurrent("bun parent -> bun child: net.Server handle and message both arrive", async () => { + using dir = tempDir("ipc-handle-bun-bun", { + "parent.js": ` +const { fork } = require('node:child_process'); +const { createServer, connect } = require('node:net'); + +const child = fork('child.js'); +const server = createServer(); + +function finish(ok, detail) { + console.log(ok ? 'RESPONSE:' + detail : 'FAILED:' + detail); + try { child.kill(); } catch {} + try { server.close(); } catch {} + process.exit(ok ? 0 : 1); +} + +server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + child.send({ greeting: 'hi' }, server); + child.on('message', m => { + if (typeof m === 'object' && m.error) return finish(false, m.error); + if (m !== 'ready') return; + server.close(); + const client = connect(port, '127.0.0.1'); + client.setEncoding('utf8'); + let data = ''; + client.on('data', c => (data += c)); + client.on('end', () => finish(true, data)); + client.on('error', err => finish(false, 'client:' + err.message)); + }); +}); +`, + "child.js": ` +const net = require('node:net'); +process.on('message', (m, server) => { + if (!(server instanceof net.Server)) return process.send({ error: 'handle was ' + typeof server }); + if (!m || m.greeting !== 'hi') return process.send({ error: 'message was ' + JSON.stringify(m) }); + server.on('connection', s => s.end('hello from bun child')); + process.send('ready'); +}); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ exitCode, stderr, response: stdout.includes("RESPONSE:hello from bun child") }).toEqual({ + exitCode: 0, + stderr: expect.any(String), + response: true, + }); + }); + + test + .skipIf(!node) + .concurrent("bun parent -> node child: the user message survives the NODE_HANDLE envelope", async () => { + using dir = tempDir("ipc-handle-bun-node", { + "parent.js": ` +const { fork } = require('node:child_process'); +const { createServer, connect } = require('node:net'); + +const child = fork('child.js', [], { execPath: ${JSON.stringify(node)} }); +const server = createServer(); + +function finish(ok, detail) { + console.log(ok ? 'RESPONSE:' + detail : 'FAILED:' + detail); + try { child.kill(); } catch {} + try { server.close(); } catch {} + process.exit(ok ? 0 : 1); +} + +server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + child.send({ greeting: 'hi-from-bun' }, server); + child.on('message', m => { + if (typeof m === 'object' && m.error) return finish(false, m.error); + if (m !== 'ready') return; + server.close(); + const client = connect(port, '127.0.0.1'); + client.setEncoding('utf8'); + let data = ''; + client.on('data', c => (data += c)); + client.on('end', () => finish(true, data)); + client.on('error', err => finish(false, 'client:' + err.message)); + }); +}); +`, + "child.js": ` +const net = require('node:net'); +process.on('message', (m, server) => { + if (!(server instanceof net.Server)) return process.send({ error: 'handle was ' + typeof server }); + if (!m || m.greeting !== 'hi-from-bun') return process.send({ error: 'message was ' + JSON.stringify(m) }); + server.on('connection', s => s.end('hello from node child')); + process.send('ready'); +}); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ exitCode, stderr, response: stdout.includes("RESPONSE:hello from node child") }).toEqual({ + exitCode: 0, + stderr: expect.any(String), + response: true, + }); + }); + + test.concurrent("destroying a socket right after send() does not lose the queued handle", async () => { + using dir = tempDir("ipc-handle-destroy-race", { + "parent.js": ` +const { fork } = require('node:child_process'); +const net = require('node:net'); + +const child = fork('child.js'); +const replies = []; +const datas = []; +let clientsDone = 0; + +function finish(ok, detail) { + console.log(ok ? 'RESULT:' + detail : 'FAILED:' + detail); + try { child.kill(); } catch {} + process.exit(ok ? 0 : 1); +} + +child.on('message', m => { + replies.push(m); + if (m.error) finish(false, m.error); +}); + +const server = net.createServer(); +const accepted = []; +server.on('connection', c => { + accepted.push(c); + if (accepted.length === 2) { + child.send({ i: 1 }, accepted[0]); + child.send({ i: 2 }, accepted[1]); + accepted[1].destroy(); + } +}); +server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + for (let i = 0; i < 2; i++) { + const client = net.connect(port, '127.0.0.1'); + client.setEncoding('utf8'); + let buf = ''; + client.on('data', c => (buf += c)); + client.on('end', () => { + datas.push(buf); + if (++clientsDone === 2) { + server.close(); + finish(true, JSON.stringify(datas.sort())); + } + }); + client.on('error', e => finish(false, 'client:' + e.message)); + } +}); +`, + "child.js": ` +process.on('message', (m, sock) => { + if (!sock) return process.send({ i: m.i, error: 'no handle for message ' + m.i }); + sock.end('hi-' + m.i); + process.send({ i: m.i }); +}); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ exitCode, stderr, result: stdout.includes('RESULT:["hi-1","hi-2"]') }).toEqual({ + exitCode: 0, + stderr: expect.any(String), + result: true, + }); + }); + + test + .skipIf(!node) + .concurrent("node parent -> bun child: the user message survives the NODE_HANDLE envelope", async () => { + using dir = tempDir("ipc-handle-node-bun", { + "parent.js": ` +const { fork } = require('node:child_process'); +const { createServer, connect } = require('node:net'); + +const child = fork('child.js', [], { execPath: ${JSON.stringify(bunExe())} }); +const server = createServer(); + +function finish(ok, detail) { + console.log(ok ? 'RESPONSE:' + detail : 'FAILED:' + detail); + try { child.kill(); } catch {} + try { server.close(); } catch {} + process.exit(ok ? 0 : 1); +} + +server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + child.send({ greeting: 'hi-from-node' }, server); + child.on('message', m => { + if (typeof m === 'object' && m.error) return finish(false, m.error); + if (m !== 'ready') return; + server.close(); + const client = connect(port, '127.0.0.1'); + client.setEncoding('utf8'); + let data = ''; + client.on('data', c => (data += c)); + client.on('end', () => finish(true, data)); + client.on('error', err => finish(false, 'client:' + err.message)); + }); +}); +`, + "child.js": ` +const net = require('node:net'); +process.on('message', (m, server) => { + if (!(server instanceof net.Server)) return process.send({ error: 'handle was ' + typeof server }); + if (!m || m.greeting !== 'hi-from-node') return process.send({ error: 'message was ' + JSON.stringify(m) }); + server.on('connection', s => s.end('hello from bun child')); + process.send('ready'); +}); +`, + }); + + await using proc = Bun.spawn({ + cmd: [node!, "parent.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ exitCode, stderr, response: stdout.includes("RESPONSE:hello from bun child") }).toEqual({ + exitCode: 0, + stderr: expect.any(String), + response: true, + }); + }); + + test.concurrent("net.Socket handle sent with {keepOpen: true} stays open in the sender", async () => { + using dir = tempDir("ipc-handle-keepopen", { + "parent.js": ` +const { fork } = require('node:child_process'); +const net = require('node:net'); + +const child = fork('child.js'); +let closed = false; +const server = net.createServer(socket => { + socket.on('close', () => { closed = true; }); + child.send('socket', socket, { keepOpen: true }, err => { + if (err) return finish(false, 'send:' + err.message); + socket.write('parent', () => {}); + }); + child.on('message', m => { + if (m !== 'child-wrote') return; + setTimeout(() => { + if (closed) return finish(false, 'parent socket closed by keepOpen send'); + socket.end(); + }, 50); + }); +}).listen(0, '127.0.0.1', () => { + const client = net.connect(server.address().port, '127.0.0.1'); + client.setEncoding('utf8'); + let data = ''; + client.on('data', c => (data += c)); + client.on('end', () => finish(data.includes('parent') && data.includes('child'), data)); + client.on('error', e => finish(false, 'client:' + e.message)); +}); + +function finish(ok, detail) { + console.log(ok ? 'RESPONSE:' + detail : 'FAILED:' + detail); + try { child.kill(); } catch {} + try { server.close(); } catch {} + process.exit(ok ? 0 : 1); +} +`, + "child.js": ` +const net = require('node:net'); +process.on('message', (m, socket) => { + if (!(socket instanceof net.Socket)) return process.send({ error: 'handle was ' + typeof socket }); + socket.write('child', () => process.send('child-wrote')); +}); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ exitCode, stderr, hasParent: stdout.includes("parent"), hasChild: stdout.includes("child") }).toEqual({ + exitCode: 0, + stderr: expect.any(String), + hasParent: true, + hasChild: true, + }); + }); + + test.concurrent("dgram.Socket handle arrives as a bound dgram.Socket the child can send/receive on", async () => { + using dir = tempDir("ipc-handle-dgram", { + "parent.js": ` +const { fork } = require('node:child_process'); +const dgram = require('node:dgram'); + +const child = fork('child.js'); +const server = dgram.createSocket('udp4'); +const client = dgram.createSocket('udp4'); + +function finish(ok, detail) { + console.log(ok ? 'RESPONSE:' + detail : 'FAILED:' + detail); + try { child.kill(); } catch {} + try { client.close(); } catch {} + process.exit(ok ? 0 : 1); +} + +server.bind(0, '127.0.0.1', () => { + const port = server.address().port; + let ready = false, closed = false; + const maybePing = () => { + if (!ready || !closed) return; + client.once('message', buf => finish(true, buf.toString())); + client.send('ping', port, '127.0.0.1', err => { + if (err) finish(false, 'client:' + err.message); + }); + }; + child.send({ greeting: 'hi' }, server, err => { + if (err) return finish(false, 'send:' + err.message); + server.close(() => { closed = true; maybePing(); }); + }); + child.on('message', m => { + if (m && m.error) return finish(false, m.error); + if (m !== 'ready') return; + ready = true; maybePing(); + }); +}); +`, + "child.js": ` +const dgram = require('node:dgram'); +process.on('message', (m, socket) => { + if (!(socket instanceof dgram.Socket)) return process.send({ error: 'handle was ' + typeof socket }); + if (!m || m.greeting !== 'hi') return process.send({ error: 'message was ' + JSON.stringify(m) }); + socket.on('message', (buf, rinfo) => { + socket.send('pong:' + buf, rinfo.port, rinfo.address); + }); + process.send('ready'); +}); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ exitCode, stderr, response: stdout.includes("RESPONSE:pong:ping") }).toEqual({ + exitCode: 0, + stderr: expect.any(String), + response: true, + }); + }); + + test.concurrent("received net.Socket has connecting=false and remoteAddress synchronously", async () => { + using dir = tempDir("ipc-handle-connecting", { + "parent.js": ` +const { fork } = require('node:child_process'); +const net = require('node:net'); +const child = fork('child.js'); +const server = net.createServer(sock => child.send('sock', sock)); +server.listen(0, '127.0.0.1', () => { + const c = net.connect(server.address().port, '127.0.0.1'); + c.on('error', () => {}); +}); +child.on('message', m => { console.log(JSON.stringify(m)); child.kill(); server.close(); process.exit(0); }); +`, + "child.js": ` +process.on('message', (m, sock) => { + process.send({ connecting: sock.connecting, readyState: sock.readyState, hasRemote: typeof sock.remoteAddress === 'string' }); +}); +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout.trim()), stderr }).toEqual({ + out: { connecting: false, readyState: "open", hasRemote: true }, + stderr: expect.any(String), + }); + expect(exitCode).toBe(0); + }); + + test.concurrent( + "channel close: written handle callback fires null; unsent queued handle callback never fires", + async () => { + using dir = tempDir("ipc-handle-abort-unsent", { + "parent.js": ` +const { fork } = require('node:child_process'); +const net = require('node:net'); +const child = fork('child.js'); +const server = net.createServer(); +server.listen(0, '127.0.0.1', () => { + let a = "never called", bCalled = false; + net.connect(server.address().port, '127.0.0.1', function () { + const sockA = this; + net.connect(server.address().port, '127.0.0.1', function () { + const sockB = this; + child.send('A', sockA, err => { a = err; }); + child.send('B', sockB, () => { bCalled = true; }); + child.kill('SIGKILL'); + child.on('close', () => setImmediate(() => { + console.log(JSON.stringify({ aWasNull: a === null, bCalled })); + server.close(); + process.exit(0); + })); + }).on('error', () => {}); + }).on('error', () => {}); +}); +`, + "child.js": `const end = Date.now() + 30_000; while (Date.now() < end) {}`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout.trim()), stderr }).toEqual({ + out: { aWasNull: true, bCalled: false }, + stderr: expect.any(String), + }); + expect(exitCode).toBe(0); + }, + ); + + test.concurrent( + "sending a tls.TLSSocket throws ERR_INVALID_HANDLE_TYPE instead of silently dropping the handle", + async () => { + using dir = tempDir("ipc-handle-tls-socket", { + "cert.pem": tls.cert, + "key.pem": tls.key, + "parent.js": ` +const { fork } = require('node:child_process'); +const tlsMod = require('node:tls'); +const fs = require('node:fs'); +const child = fork('child.js'); +const sockets = []; +const finish = out => { console.log(JSON.stringify(out)); for (const s of sockets) s.destroy(); server.close(); child.disconnect(); }; +child.on('message', m => finish({ childReceived: m })); +const server = tlsMod.createServer({ key: fs.readFileSync('key.pem'), cert: fs.readFileSync('cert.pem') }, serverSide => { + sockets.push(serverSide); + try { child.send('tls', serverSide); } catch (err) { report('serverCode', err.code); } +}); +const codes = {}; +function report(side, code) { codes[side] = code; if ('serverCode' in codes && 'clientCode' in codes) finish({ ...codes, childReceived: null }); } +server.listen(0, '127.0.0.1', () => { + const clientSide = tlsMod.connect({ port: server.address().port, host: '127.0.0.1', rejectUnauthorized: false }, () => { + sockets.push(clientSide); + try { child.send('tls', clientSide); } catch (err) { report('clientCode', err.code); } + }); +}); +`, + "child.js": `process.on('message', m => process.send('unexpected:' + m));`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout.trim()), stderr }).toEqual({ + out: { serverCode: "ERR_INVALID_HANDLE_TYPE", clientCode: "ERR_INVALID_HANDLE_TYPE", childReceived: null }, + stderr: expect.any(String), + }); + expect(exitCode).toBe(0); + }, + ); + + test.concurrent("a received handle that lands on fd 0 is adopted", async () => { + using dir = tempDir("ipc-handle-fd0", { + "parent.js": ` +const { fork } = require('node:child_process'); +const net = require('node:net'); +const child = fork('child.js'); +const server = net.createServer(sock => { + child.send('sock', sock); + child.once('message', m => { console.log(JSON.stringify(m)); sock.destroy(); server.close(); child.disconnect(); }); +}); +server.listen(0, '127.0.0.1', () => { + const client = net.connect(server.address().port, '127.0.0.1'); + client.on('data', d => { client.end(); }); + client.on('error', () => {}); +}); +`, + "child.js": ` +require('node:fs').closeSync(0); // the next descriptor this process receives is fd 0 +process.on('message', (m, sock) => { + const fd = sock && sock._handle && sock._handle.fd; + sock.end('hi', () => process.send({ message: m, receivedFd: fd, writable: true })); +}); +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout.trim()), stderr }).toEqual({ + out: { message: "sock", receivedFd: 0, writable: true }, + stderr: "", + }); + expect(exitCode).toBe(0); + }); + + // node: a sent socket is detached from the sender's net.Socket (no 'end'/'close' there when the + // receiver finishes with it), and disconnect() waits for the messages queued behind an un-acked + // handle to be delivered. https://github.com/nodejs/node/blob/v26.3.0/lib/internal/child_process.js + test.concurrent( + "handoff detaches the sender's socket; disconnect() reports disconnected at once but flushes messages queued behind the handle", + async () => { + using dir = tempDir("ipc-handle-detach-flush", { + "parent.js": ` +const { fork } = require('node:child_process'); +const net = require('node:net'); +const child = fork('child.js'); +const senderEvents = []; +let client, connectedAfterDisconnect, secondDisconnect = 'no error'; +child.on('error', e => { secondDisconnect = e.code; }); +const server = net.createServer(sock => { + sock.on('end', () => senderEvents.push('end')); + sock.on('close', () => senderEvents.push('close')); + child.send('sock', sock); + child.send({ type: 'after-handle' }); + child.disconnect(); + // While the queue behind the handle drains, the channel already reports disconnected. + connectedAfterDisconnect = child.connected; + child.disconnect(); +}); +child.on('exit', code => { + client.destroy(); + server.close(() => console.log(JSON.stringify({ childSawQueuedMessage: code === 0, senderEvents, connectedAfterDisconnect, secondDisconnect }))); +}); +server.listen(0, '127.0.0.1', () => { + client = net.connect(server.address().port, '127.0.0.1'); + client.on('error', () => {}); +}); +`, + "child.js": ` +let sawQueued = false; +process.on('message', (m, sock) => { if (sock) sock.destroy(); else sawQueued = m.type === 'after-handle'; }); +process.on('disconnect', () => process.exit(sawQueued ? 0 : 3)); +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout.trim()), stderr }).toEqual({ + out: { + childSawQueuedMessage: true, + senderEvents: [], + connectedAfterDisconnect: false, + secondDisconnect: "ERR_IPC_DISCONNECTED", + }, + stderr: "", + }); + expect(exitCode).toBe(0); + }, + ); + + // The child sends a server and disconnects at once. node: process.connected drops immediately, a + // second disconnect() errors, and the parent still receives the server (its adoption completes a + // loop turn later, which must not lose it) as well as the message queued behind it. Order is not + // pinned: bun currently emits the late-adopted handle after 'disconnect', node before it. + test.concurrent("a handle sent right before the child's disconnect() is still delivered", async () => { + using dir = tempDir("ipc-handle-then-disconnect", { + "parent.js": ` +const { fork } = require('node:child_process'); +const child = fork('child.js', { stdio: ['ignore', 'inherit', 'pipe', 'ipc'] }); +const got = []; +let childReport = ''; +child.stderr.on('data', d => { childReport += d; }); +child.on('message', (m, h) => { got.push(h ? 'handle:' + m : m); if (h) h.close(); }); +child.on('disconnect', () => got.push('disconnect')); +child.on('exit', code => console.log(JSON.stringify({ got: got.sort(), code, child: JSON.parse(childReport) }))); +`, + "child.js": ` +const net = require('node:net'); +const server = net.createServer().listen(0, '127.0.0.1', () => { + process.send('srv', server); + process.send('after-handle'); + process.disconnect(); + const connectedAfterDisconnect = process.connected; + let secondDisconnect = 'no error'; + process.once('error', e => { secondDisconnect = e.code; }); + process.disconnect(); + process.on('disconnect', () => { process.stderr.write(JSON.stringify({ connectedAfterDisconnect, secondDisconnect })); server.close(); }); +}); +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout.trim()), stderr }).toEqual({ + out: { + got: ["after-handle", "disconnect", "handle:srv"], + code: 0, + child: { connectedAfterDisconnect: false, secondDisconnect: "ERR_IPC_DISCONNECTED" }, + }, + stderr: "", + }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/node/cluster.test.ts b/test/js/node/cluster.test.ts index 6cc38a1d0129..64d101f15e8f 100644 --- a/test/js/node/cluster.test.ts +++ b/test/js/node/cluster.test.ts @@ -1,5 +1,17 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, bunRun, joinP, tempDirWithFiles } from "harness"; +import { + bunEnv, + bunExe, + bunRun, + isIPv6, + isLinux, + isWindows, + joinP, + tempDir, + tempDirWithFiles, + tls as tlsCerts, +} from "harness"; +import net from "node:net"; test.concurrent("cloneable and transferable equals", async () => { const dir = tempDirWithFiles("bun-test", { @@ -162,6 +174,389 @@ process.send("regular message"); expect(exitCode).toBe(0); }); +test("TLS worker listening on a key already owned by a round-robin handle fails with EINVAL", async () => { + const dir = tempDirWithFiles("bun-test", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +const tls = require("node:tls"); + +if (cluster.isPrimary) { + const netWorker = cluster.fork({ ROLE: "net" }); + cluster.once("listening", () => { + const tlsWorker = cluster.fork({ ROLE: "tls" }); + tlsWorker.on("message", msg => { + console.log("tls listen error code:", msg.code, msg.msg); + netWorker.kill(); + tlsWorker.kill(); + process.exit(0); + }); + }); +} else if (process.env.ROLE === "net") { + net.createServer(() => {}).listen(0); +} else { + const server = tls.createServer({}); + server.on("error", err => process.send({ code: err.code, msg: err.message })); + server.listen(0); +} +`, + }); + const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); + expect(stdout).toContain("tls listen error code: EINVAL"); + expect(stdout).toContain("TLS and non-TLS cluster workers cannot share"); +}); + +test("cluster pipe listen error carries no port suffix", async () => { + const dir = tempDirWithFiles("bun-test", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +const path = require("node:path"); + +if (cluster.isPrimary) { + const PIPE = + process.platform === "win32" + ? String.raw\`\\\\.\\pipe\\bun-cluster-pipe-err-\${process.pid}\` + : path.join(__dirname, "test.sock"); + const blocker = net.createServer(() => {}); + blocker.listen(PIPE, () => { + const worker = cluster.fork({ BUN_CLUSTER_PIPE: PIPE }); + worker.on("message", msg => { + console.log("code:", msg.code); + console.log("message:", msg.message); + console.log("port:", msg.port); + worker.kill(); + blocker.close(); + process.exit(0); + }); + }); +} else { + const server = net.createServer(() => {}); + server.on("error", err => process.send({ code: err.code, message: err.message, port: err.port })); + server.listen(process.env.BUN_CLUSTER_PIPE); +} +`, + }); + const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); + expect(stdout).toContain("code: EADDRINUSE"); + expect(stdout).not.toContain(":-1"); + expect(stdout).toContain("port: -1"); +}); + +test.skipIf(isWindows)("SCHED_NONE pipe listen unlinks the socket file when the last worker leaves", async () => { + const dir = tempDirWithFiles("bun-test", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +const fs = require("node:fs"); +const path = require("node:path"); + +cluster.schedulingPolicy = cluster.SCHED_NONE; +const SOCK = path.join(__dirname, "test.sock"); + +if (cluster.isPrimary) { + const worker = cluster.fork({ BUN_CLUSTER_SOCK: SOCK }); + cluster.on("listening", () => { + console.log("exists while listening:", fs.existsSync(SOCK)); + worker.disconnect(); + }); + cluster.on("exit", () => { + console.log("exists after exit:", fs.existsSync(SOCK)); + process.exit(0); + }); +} else { + net.createServer(() => {}).listen(process.env.BUN_CLUSTER_SOCK); +} +`, + }); + const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); + expect(stdout).toContain("exists while listening: true"); + expect(stdout).toContain("exists after exit: false"); +}); + +test.skipIf(isWindows)("round-robin pipe listen applies readableAll/writableAll to the socket file", async () => { + const dir = tempDirWithFiles("bun-test", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +const fs = require("node:fs"); +const path = require("node:path"); + +const SOCK = path.join(__dirname, "rr-perm.sock"); + +if (cluster.isPrimary) { + const worker = cluster.fork({ BUN_CLUSTER_SOCK: SOCK }); + cluster.on("listening", () => { + const mode = fs.statSync(SOCK).mode; + console.log("perm bits:", (mode & 0o066).toString(8)); + worker.disconnect(); + }); + worker.on("exit", (code, signal) => { + console.log("worker exit:", code, signal); + process.exit(0); + }); +} else { + net.createServer(() => {}).listen({ path: process.env.BUN_CLUSTER_SOCK, readableAll: true, writableAll: true }); +} +`, + }); + const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); + expect(stdout).toContain("perm bits: 66"); + expect(stdout).toContain("worker exit: 0"); +}); + +test.skipIf(isWindows)("round-robin accepted sockets honor allowHalfOpen after the client's FIN", async () => { + const dir = tempDirWithFiles("bun-test", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); + +if (cluster.isPrimary) { + const worker = cluster.fork(); + cluster.on("listening", (w, address) => { + const c = net.connect({ host: "127.0.0.1", port: address.port, allowHalfOpen: true }); + let buf = ""; + c.on("data", d => (buf += d)); + c.on("connect", () => { + c.write("ping"); + c.end(); + }); + c.on("end", () => { + console.log("client got:", buf); + worker.kill(); + process.exit(0); + }); + c.on("error", e => { + console.log("client error:", e.code); + process.exit(1); + }); + }); +} else { + net + .createServer({ allowHalfOpen: true }, socket => { + let buf = ""; + socket.on("data", d => (buf += d)); + socket.on("end", () => { + setTimeout(() => socket.end("pong:" + buf), 50); + }); + }) + .listen(0, "127.0.0.1"); +} +`, + }); + const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); + expect(stdout).toContain("client got: pong:ping"); +}); + +test("round-robin accepted sockets honor the server's highWaterMark", async () => { + const dir = tempDirWithFiles("bun-test", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", m => { + console.log("accepted hwm:", m.hwm); + worker.kill(); + process.exit(0); + }); + cluster.on("listening", (w, address) => { + const c = net.connect({ host: "127.0.0.1", port: address.port }); + c.on("error", () => {}); + }); +} else { + net + .createServer({ highWaterMark: 1234 }, socket => { + process.send({ hwm: socket.readableHighWaterMark }); + socket.end(); + }) + .listen(0, "127.0.0.1"); +} +`, + }); + const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); + expect(stdout).toContain("accepted hwm: 1234"); +}); + +test.skipIf(!isIPv6())("SCHED_NONE listen with no host binds the IPv6 wildcard (dual-stack)", async () => { + const dir = tempDirWithFiles("bun-test", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); + +cluster.schedulingPolicy = cluster.SCHED_NONE; + +if (cluster.isPrimary) { + const worker = cluster.fork(); + cluster.on("listening", (w, address) => { + const c = net.connect({ host: "::1", port: address.port }); + c.on("connect", () => { + console.log("ipv6 connect ok"); + c.end(); + worker.kill(); + process.exit(0); + }); + c.on("error", err => { + console.log("ipv6 connect error:", err.code); + worker.kill(); + process.exit(1); + }); + }); +} else { + net.createServer(s => s.end()).listen(0); +} +`, + }); + const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); + expect(stdout).toContain("ipv6 connect ok"); +}); + +test("SCHED_NONE: a second worker listens on the same shared handle", async () => { + const dir = tempDirWithFiles("bun-test", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); + +cluster.schedulingPolicy = cluster.SCHED_NONE; + +if (cluster.isPrimary) { + const workers = [cluster.fork(), cluster.fork()]; + let listening = 0; + const ports = new Set(); + console.log("policy is SCHED_NONE:", cluster.schedulingPolicy === cluster.SCHED_NONE); + cluster.on("listening", (w, address) => { + ports.add(address.port); + if (++listening !== 2) return; + console.log("listening workers:", listening, "distinct ports:", ports.size); + for (const w of workers) w.kill(); + process.exit(0); + }); + for (const w of workers) { + w.on("message", msg => { + console.log("worker listen error:", msg.code, msg.msg); + for (const x of workers) x.kill(); + process.exit(1); + }); + } +} else { + const server = net.createServer(s => s.end()); + server.on("error", err => process.send({ code: err.code, msg: err.message })); + server.listen(0, "127.0.0.1"); +} +`, + }); + const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); + expect(stdout).toContain("policy is SCHED_NONE: true"); + expect(stdout).toContain("listening workers: 2 distinct ports: 1"); +}); + +test("SCHED_NONE: close() releases the shared handle so the worker can re-listen on the same port", async () => { + using dir = tempDir("cluster-shared-relisten", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +cluster.schedulingPolicy = cluster.SCHED_NONE; +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", m => { + if (m.port) { const c = net.connect(m.port, "127.0.0.1"); c.on("error", () => {}); return; } + console.log(JSON.stringify(m)); + worker.disconnect(); + }); +} else { + const first = net.createServer(sock => { + // Close while this connection is still open, then re-listen on the same port immediately. + const port = first.address().port; + first.close(); + const second = net.createServer(); + const report = result => { sock.destroy(); second.close(); process.send(result); }; + second.on("error", err => report({ relisten: err.code })); + second.listen(port, "127.0.0.1", () => report({ relisten: "ok", samePort: second.address().port === port })); + }); + first.listen(0, "127.0.0.1", () => process.send({ port: first.address().port })); +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout.trim()), stderr }).toEqual({ + out: { relisten: "ok", samePort: true }, + stderr: expect.any(String), + }); + expect(exitCode).toBe(0); +}); + +test.skipIf(isWindows)("SCHED_NONE: a worker listening on a unix path reports it from address()", async () => { + using dir = tempDir("cluster-shared-unix-address", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +const path = require("node:path"); +cluster.schedulingPolicy = cluster.SCHED_NONE; +const SOCK = path.join(__dirname, "srv.sock"); +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", m => { console.log(JSON.stringify(m)); worker.disconnect(); }); +} else { + const server = net.createServer(); + server.listen(SOCK, () => { const address = server.address(); server.close(() => process.send({ address, expected: SOCK })); }); +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const out = JSON.parse(stdout.trim()); + expect({ address: out.address, stderr }).toEqual({ address: out.expected, stderr: expect.any(String) }); + expect(exitCode).toBe(0); +}); + +test.skipIf(!isLinux)("SCHED_NONE: an abstract-namespace listen is reachable by clients", async () => { + using dir = tempDir("cluster-shared-abstract", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +cluster.schedulingPolicy = cluster.SCHED_NONE; +const NAME = "\\0bun-cluster-abstract-" + (process.env.ABSTRACT_ID || process.pid); +if (cluster.isPrimary) { + const worker = cluster.fork({ ABSTRACT_ID: String(process.pid) }); + worker.on("message", () => { + const finish = result => { console.log(JSON.stringify(result)); worker.send("close"); }; + const c = net.connect(NAME, () => { c.destroy(); finish({ connect: "ok" }); }); + c.on("error", err => finish({ connect: err.code })); + }); + worker.on("exit", code => process.exitCode = code); +} else { + const server = net.createServer(s => s.end()); + process.on("message", () => server.close(() => process.disconnect())); + server.listen(NAME, () => process.send("listening")); +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout.trim()), stderr }).toEqual({ out: { connect: "ok" }, stderr: expect.any(String) }); + expect(exitCode).toBe(0); +}); + test("disconnect() on a cluster.Worker built around a plain object does not abort", async () => { // `kHandle` is a private symbol that only `cluster.fork()` sets, so a // `cluster.Worker({ process })` built around a plain object (how Node's own @@ -186,3 +581,698 @@ test("disconnect() on a cluster.Worker built around a plain object does not abor const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "returned self: true", exitCode: 0 }); }); + +const listeningPayloadFixture = ` +const cluster = require("node:cluster"); + +const targets = JSON.parse(process.env.TARGETS); + +if (cluster.isPrimary) { + const payloads = []; + const { promise, resolve, reject } = Promise.withResolvers(); + const worker = cluster.fork(); + + cluster.on("listening", (listeningWorker, address) => { + if (listeningWorker !== worker) { + reject(new Error("'listening' came from an unexpected worker")); + return; + } + payloads.push({ address: address.address, addressType: address.addressType, port: address.port }); + if (payloads.length === targets.length) resolve(); + }); + worker.on("error", reject); + worker.on("exit", (code, signal) => { + reject(new Error("worker exited before it finished listening (" + code + ", " + signal + ")")); + }); + + promise.then( + () => { + console.log(JSON.stringify(payloads)); + worker.kill(); + process.exit(0); + }, + error => { + console.error(error); + process.exit(1); + }, + ); +} else { + const { createServer } = require("node:" + process.env.MODULE); + + (async () => { + for (const target of targets) { + const server = createServer(() => {}); + await new Promise((resolve, reject) => { + server.once("error", reject); + if (target.path) server.listen(target.path, resolve); + else if (target.host === null) server.listen(0, resolve); + else server.listen(0, target.host, resolve); + }); + } + })().catch(error => { + console.error(error); + process.exit(1); + }); +} +`; + +test.each(["net", "http"])("cluster 'listening' reports the address a %s server bound", async moduleName => { + const dir = tempDirWithFiles("cluster-listening", { "fixture.js": listeningPayloadFixture }); + const targets: ({ host: string | null } | { path: string })[] = [{ host: "127.0.0.1" }, { host: null }]; + if (isIPv6()) targets.push({ host: "::1" }); + if (!isWindows) targets.push({ path: joinP(dir, `${moduleName}.sock`) }); + + const { stdout } = await bunRun(joinP(dir, "fixture.js"), { MODULE: moduleName, TARGETS: JSON.stringify(targets) }); + const payloads = JSON.parse(stdout); + + expect(payloads).toEqual( + targets.map(target => + "path" in target + ? { address: target.path, addressType: -1, port: -1 } + : { + address: target.host, + addressType: target.host?.includes(":") ? 6 : 4, + port: expect.any(Number), + }, + ), + ); + for (const [i, target] of targets.entries()) { + if (!("path" in target)) expect(payloads[i].port).toBeWithin(1, 65536); + } +}); + +test("round-robin worker connection socket has connecting=false and remoteAddress synchronously", async () => { + const dir = tempDirWithFiles("bun-test", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", m => { + console.log(JSON.stringify(m)); + worker.kill(); + process.exit(0); + }); + cluster.on("listening", (w, address) => { + net.connect(address.port, "127.0.0.1").on("error", () => {}); + }); +} else { + net + .createServer(socket => { + process.send({ + connecting: socket.connecting, + readyState: socket.readyState, + remote: typeof socket.remoteAddress, + }); + socket.end(); + }) + .listen(0, "127.0.0.1"); +} +`, + }); + const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); + const m = JSON.parse(stdout.trim()); + expect(m.connecting).toBe(false); + expect(m.readyState).toBe("open"); + expect(m.remote).toBe("string"); +}); + +test("round-robin: primary never consumes accepted-socket bytes before handoff", async () => { + const dir = tempDirWithFiles("bun-test", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); + +const N = 20; +if (cluster.isPrimary) { + const worker = cluster.fork(); + let got = 0; + worker.on("message", m => { + console.log(m); + if (++got === N) { + worker.kill(); + process.exit(0); + } + }); + cluster.on("listening", (w, address) => { + for (let i = 0; i < N; i++) { + const c = net.connect(address.port, "127.0.0.1", () => { + c.write("MAGIC-" + i + "-" + "x".repeat(4096)); + c.end(); + }); + c.on("error", () => {}); + } + }); +} else { + net + .createServer(sock => { + let buf = ""; + sock.on("data", d => (buf += d)); + sock.on("end", () => process.send(buf.slice(0, 20) + " " + buf.length)); + }) + .listen(0, "127.0.0.1"); +} +`, + }); + const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); + const lines = stdout.trim().split("\n").sort(); + expect(lines.length).toBe(20); + for (const line of lines) { + expect(line).toMatch(/^MAGIC-\d+-x+ 41\d\d$/); + } +}); + +test("TLS cluster worker under SCHED_RR listens on a shared handle and completes handshakes", async () => { + const dir = tempDirWithFiles("bun-test", { + "cert.pem": tlsCerts.cert, + "key.pem": tlsCerts.key, + "main.ts": ` +const cluster = require("node:cluster"); +const tls = require("node:tls"); +const fs = require("node:fs"); +const path = require("node:path"); +const key = fs.readFileSync(path.join(__dirname, "key.pem")); +const cert = fs.readFileSync(path.join(__dirname, "cert.pem")); + +if (cluster.isPrimary) { + const w1 = cluster.fork(); + const w2 = cluster.fork(); + const ports = new Set(); + let listening = 0; + for (const w of [w1, w2]) { + w.on("message", msg => { + if (!msg || !msg.listenError) return; + const e = msg.listenError; + console.log("worker listen error:", e.code, e.errno, e.syscall, e.msg); + w1.kill(); + w2.kill(); + process.exit(1); + }); + } + cluster.on("listening", (w, address) => { + ports.add(address.port); + if (++listening !== 2) return; + console.log("distinct ports:", ports.size); + const port = address.port; + const c = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }, () => { + c.write("hi"); + }); + c.setEncoding("utf8"); + c.on("data", d => { + console.log("reply:", d); + c.end(); + w1.kill(); + w2.kill(); + process.exit(0); + }); + c.on("error", e => { + console.log("client error:", e.code); + process.exit(1); + }); + }); +} else { + const server = tls.createServer({ key, cert }, socket => { + socket.on("data", d => socket.end("echo:" + d)); + }); + server.on("error", e => + process.send({ listenError: { code: e.code, errno: e.errno, syscall: e.syscall, msg: e.message } }), + ); + server.listen(0); +} +`, + }); + const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); + expect(stdout).toContain("distinct ports: 1"); + expect(stdout).toContain("reply: echo:hi"); +}, 30_000); + +test("plain worker listening on a key already owned by a TLS shared-only handle fails with EINVAL", async () => { + const dir = tempDirWithFiles("bun-test", { + "cert.pem": tlsCerts.cert, + "key.pem": tlsCerts.key, + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +const tls = require("node:tls"); +const fs = require("node:fs"); +const path = require("node:path"); +const key = fs.readFileSync(path.join(__dirname, "key.pem")); +const cert = fs.readFileSync(path.join(__dirname, "cert.pem")); + +if (cluster.isPrimary) { + const tlsWorker = cluster.fork({ ROLE: "tls" }); + cluster.once("listening", () => { + const netWorker = cluster.fork({ ROLE: "net" }); + netWorker.on("message", msg => { + console.log("net listen error code:", msg.code, msg.msg); + tlsWorker.kill(); + netWorker.kill(); + process.exit(0); + }); + }); +} else if (process.env.ROLE === "tls") { + tls.createServer({ key, cert }, () => {}).listen(0); +} else { + const server = net.createServer(() => {}); + server.on("error", err => process.send({ code: err.code, msg: err.message })); + server.listen(0); +} +`, + }); + const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); + expect(stdout).toContain("net listen error code: EINVAL"); + expect(stdout).toContain("TLS and non-TLS cluster workers cannot share"); +}, 30_000); + +test.skipIf(isWindows)( + "SCHED_NONE listen({fd:2}) fails EINVAL like node and does not close the primary's stderr", + async () => { + const dir = tempDirWithFiles("bun-test", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +const fs = require("node:fs"); + +cluster.schedulingPolicy = cluster.SCHED_NONE; + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", m => { + console.log("worker error code:", m.code); + worker.disconnect(); + }); + cluster.on("exit", () => { + try { + fs.fstatSync(2); + console.log("stderr open: true"); + } catch (e) { + console.log("stderr open: false"); + } + process.exit(0); + }); +} else { + const server = net.createServer(() => {}); + server.on("error", err => { + process.send({ code: err.code }); + }); + server.listen({ fd: 2 }); +} +`, + }); + const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); + expect(stdout).toContain("worker error code: EINVAL"); + expect(stdout).toContain("stderr open: true"); + }, +); + +test.skipIf(isWindows)("dgram worker releases a shared fd it failed to adopt", async () => { + using dir = tempDir("cluster-dgram-adopt-fail", { + "main.ts": ` +const cluster = require("node:cluster"); +const dgram = require("node:dgram"); +const net = require("node:net"); + +if (cluster.isPrimary) { + // A stream socket passes the primary's fd check but cannot be adopted as a dgram socket in the worker. + const tcp = net.createServer().listen(0, "127.0.0.1", () => { + const { port } = tcp.address(); + const worker = cluster.fork(); + worker.on("message", m => { + console.log("worker error code:", m.code); + // Refused once both processes closed their copy; a leaked copy in either keeps the socket accepting. + const probe = net.connect(port, "127.0.0.1"); + probe.on("connect", () => { console.log("probe: connected"); probe.destroy(); finish(); }); + probe.on("error", err => { console.log("probe:", err.code); finish(); }); + }); + function finish() { + worker.kill(); + worker.on("exit", () => process.exit(0)); + } + worker.send({ fd: tcp._handle.fd }); + }); +} else { + process.on("message", ({ fd }) => { + const socket = dgram.createSocket("udp4"); + socket.on("listening", () => process.send({ code: "listening" })); + socket.on("error", err => process.send({ code: err.code })); + socket.bind({ fd }); + }); +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ + stdout: "worker error code: EINVAL\nprobe: ECONNREFUSED", + stderr: "", + }); + expect(exitCode).toBe(0); +}); + +test.skipIf(isWindows)( + "round-robin: RST-while-queued handle is dropped, not shipped stale", + async () => { + using dir = tempDir("cluster-rst-queued", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", msg => { console.log(msg); worker.kill(); process.exit(0); }); + cluster.on("listening", (_w, addr) => { + const N = 4; + let done = 0; + const clients = []; + for (let i = 0; i < N; i++) { + const c = net.connect(addr.port, "127.0.0.1"); + c.on("connect", () => { if (++done === N) setImmediate(rst); }); + c.on("error", () => {}); + clients.push(c); + } + function rst() { + let closed = 0; + for (const c of clients) { c.once("close", onClosed); c.resetAndDestroy(); } + function onClosed() { + if (++closed !== N) return; + const real = net.connect(addr.port, "127.0.0.1"); + real.on("connect", () => real.write("REAL")); + real.on("error", e => { console.log("real client error:", e.code); process.exit(1); }); + } + } + }); +} else { + const server = net.createServer(sock => { + sock.on("data", d => { process.send("worker got: " + d.toString()); server.close(); }); + sock.on("error", () => {}); + }); + server.listen(0, "127.0.0.1"); +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ stdout: "worker got: REAL", stderr: expect.any(String) }); + expect(exitCode).toBe(0); + }, + 30_000, +); + +test("round-robin worker closes a server.blockList peer silently, like node", async () => { + using dir = tempDir("cluster-blocklist", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", m => { console.log(JSON.stringify(m)); worker.disconnect(); }); + cluster.on("listening", (_w, addr) => { + const c = net.connect(addr.port, "127.0.0.1"); + c.on("error", () => {}); + // The blocked peer is closed by the worker; node emits neither 'connection' nor 'drop' for it. + c.on("close", () => worker.send("report")); + }); +} else { + const bl = new net.BlockList(); + bl.addAddress("127.0.0.1"); + const seen = { connection: false, drop: false }; + const server = net.createServer({ blockList: bl }, () => { seen.connection = true; }); + server.on("drop", () => { seen.drop = true; }); + process.on("message", () => server.close(() => process.send({ ...seen, clientClosed: true }))); + server.listen(0, "127.0.0.1"); +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ + stdout: JSON.stringify({ connection: false, drop: false, clientClosed: true }), + stderr: expect.any(String), + }); + expect(exitCode).toBe(0); +}, 30_000); + +test("round-robin worker honors server.pauseOnConnect and sets socket._server", async () => { + using dir = tempDir("cluster-pauseonconnect", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", m => { console.log(JSON.stringify(m)); worker.kill(); process.exit(0); }); + cluster.on("listening", (_w, addr) => { + const c = net.connect(addr.port, "127.0.0.1", () => c.write("early")); + c.on("error", () => {}); + }); +} else { + const server = net.createServer({ pauseOnConnect: true }, sock => { + let earlyData = false; + sock.once("data", () => { earlyData = true; }); + setImmediate(() => { + process.send({ paused: sock.isPaused(), earlyData, _server: sock._server === server }); + }); + }); + server.listen(0, "127.0.0.1"); +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout.trim()), stderr }).toEqual({ + out: { paused: true, earlyData: false, _server: true }, + stderr: expect.any(String), + }); + expect(exitCode).toBe(0); +}, 30_000); + +test("round-robin accepted socket buffers early bytes until a 'data' listener is attached", async () => { + using dir = tempDir("cluster-early-bytes", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +if (cluster.isPrimary) { + const worker = cluster.fork(); + let c; + worker.on("message", m => { + if (m === "connected") return c.end("early", () => worker.send("attach")); + console.log(JSON.stringify(m)); + c.destroy(); + worker.disconnect(); + }); + cluster.on("listening", (_w, addr) => { + c = net.connect(addr.port, "127.0.0.1"); + c.on("error", () => {}); + }); +} else { + const server = net.createServer(sock => { + process.once("message", () => { + const report = result => { sock.destroy(); server.close(); process.send(result); }; + if (sock.readableEnded) return report({ endedBeforeListener: true, data: "" }); + let data = ""; + sock.on("data", d => { data += d; }); + sock.on("end", () => report({ endedBeforeListener: false, data })); + }); + process.send("connected"); + }); + server.listen(0, "127.0.0.1"); +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout.trim()), stderr }).toEqual({ + out: { endedBeforeListener: false, data: "early" }, + stderr: expect.any(String), + }); + expect(exitCode).toBe(0); +}, 30_000); + +test("worker listen(0, 'localhost') resolves before querying the primary", async () => { + using dir = tempDir("cluster-dns", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +if (cluster.isPrimary) { + const worker = cluster.fork(); + cluster.on("listening", (_w, addr) => { + console.log(JSON.stringify({ address: addr.address, type: addr.addressType })); + worker.kill(); + process.exit(0); + }); +} else { + net.createServer(() => {}).listen(0, "localhost"); +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const out = JSON.parse(stdout.trim()); + expect(net.isIP(out.address)).toBeGreaterThan(0); + expect([4, 6]).toContain(out.type); + expect(stderr).toEqual(expect.any(String)); + expect(exitCode).toBe(0); +}, 30_000); + +test.skipIf(isWindows)( + "worker death mid-handoff redistributes the connection to another worker", + async () => { + using dir = tempDir("cluster-mid-handoff", { + "main.ts": `const cluster = require("node:cluster"); +const net = require("node:net"); +if (cluster.isPrimary) { + // One shared round-robin handle on a pre-picked port. "die" registers first, so the first connection + // is handed to it; it exits on that newconn and the primary must hand the unacked connection to "live". + const pick = net.createServer(); + pick.listen(0, "127.0.0.1", () => { + const port = pick.address().port; + pick.close(() => { + const die = cluster.fork({ ROLE: "die", PORT: port }); + die.once("listening", () => { + const live = cluster.fork({ ROLE: "live", PORT: port }); + let served = false; + live.on("message", m => { served = true; console.log(m); live.send("close"); }); + live.once("listening", () => { + const client = net.connect(port, "127.0.0.1", () => client.write("hi")); + client.on("error", () => {}); + client.on("close", () => { if (!served) { console.log("connection dropped"); live.send("close"); } }); + }); + }); + }); + }); +} else if (process.env.ROLE === "die") { + process.on("internalMessage", m => { if (m.act === "newconn") process.exit(0); }); + net.createServer(() => {}).listen(+process.env.PORT, "127.0.0.1"); +} else { + const server = net.createServer(sock => sock.on("data", d => { process.send("live got: " + d); sock.destroy(); })); + process.on("message", () => server.close(() => process.disconnect())); + server.listen(+process.env.PORT, "127.0.0.1"); +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ stdout: "live got: hi", stderr: expect.any(String) }); + expect(exitCode).toBe(0); + }, + 30_000, +); + +test("round-robin newconn reaches the worker's internalMessage listener via the handle slot", async () => { + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/cluster/utils.js#L33-L49 + using dir = tempDir("cluster-handle-slot", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", m => { console.log(JSON.stringify(m)); worker.kill(); process.exit(0); }); + cluster.on("listening", (_w, addr) => { + net.connect(addr.port, "127.0.0.1"); + }); +} else { + let reported = false; + process.on("internalMessage", (msg, handle) => { + if (msg && msg.act === "newconn" && !reported) { + reported = true; + process.send({ + hasDollarFd: "$fd" in msg, + handleIsObject: typeof handle === "object" && handle !== null, + handleHasFd: typeof handle?.fd === "number", + }); + } + }); + net.createServer(() => {}).listen(0, "127.0.0.1"); +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), joinP(String(dir), "main.ts")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout.trim())).toEqual({ hasDollarFd: false, handleIsObject: true, handleHasFd: true }); + expect(exitCode).toBe(0); +}); + +test("cluster child send() clones and stamps cmd:NODE_CLUSTER", async () => { + using dir = tempDir("cluster-send-shape", { + "main.ts": ` +const cluster = require("node:cluster"); +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", m => { console.log(JSON.stringify(m)); worker.kill(); process.exit(0); }); +} else { + const seen = []; + const orig = process.send; + process.send = function (msg, ...rest) { seen.push(msg); return orig.call(this, msg, ...rest); }; + const server = require("node:net").createServer(() => {}); + server.listen(0, "127.0.0.1"); + server.once("listening", () => setImmediate(() => { + const q = seen.find(m => m && m.act === "queryServer"); + const l = seen.find(m => m && m.act === "listening"); + process.send = orig; + process.send({ qCmd: q?.cmd, lCmd: l?.cmd, qActNow: q?.act }); + })); +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout.trim()), stderr }).toEqual({ + out: { qCmd: "NODE_CLUSTER", lCmd: "NODE_CLUSTER", qActNow: "queryServer" }, + stderr: expect.any(String), + }); + expect(exitCode).toBe(0); +}, 30_000); diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index 2a8994f1ffd7..0379fec9867e 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -2048,3 +2048,33 @@ describe.skipIf(!isWindows)("connect() error codes on Windows", () => { expect(missingErr.code).toBe("ENOENT"); }); }); + +describe("net.Server.listen({ fd })", () => { + // node's createServerHandle only accepts TCP / pipe descriptors and reports anything else as EINVAL; + // the raw listen(2) failure for a datagram socket is EOPNOTSUPP. + it.skipIf(isWindows)("reports a datagram descriptor as EINVAL, like node", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--no-deprecation", // Socket.prototype._handle (DEP0112) is the only way to get the descriptor + "-e", + ` + const dgram = require("dgram"), net = require("net"); + const u = dgram.createSocket("udp4"); + u.bind(0, "127.0.0.1", () => { + const s = net.createServer(); + s.on("error", e => { console.log(e.code); u.close(); }); + s.on("listening", () => { console.log("listening"); s.close(); u.close(); }); + s.listen({ fd: u._handle.fd }); + }); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ stdout: "EINVAL", stderr: "" }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/node/test/common/index.js b/test/js/node/test/common/index.js index 4420f27c8f01..a4ab5ba15f73 100644 --- a/test/js/node/test/common/index.js +++ b/test/js/node/test/common/index.js @@ -311,7 +311,7 @@ const isMacOS = process.platform === 'darwin'; const isASan = process.config.variables.asan === 1; const isRiscv64 = process.arch === 'riscv64'; const isDebug = process.features.debug; -const isPi = (() => { +function isPi() { try { // Normal Raspberry Pi detection is to find the `Raspberry Pi` string in // the contents of `/sys/firmware/devicetree/base/model` but that doesn't @@ -323,7 +323,7 @@ const isPi = (() => { } catch { return false; } -})(); +} const isDumbTerminal = process.env.TERM === 'dumb'; @@ -451,7 +451,7 @@ function platformTimeout(ms) { if (exports.isAIX || exports.isIBMi) return multipliers.two * ms; // Default localhost speed is slower on AIX - if (isPi) + if (isPi()) return multipliers.two * ms; // Raspberry Pi devices if (isRiscv64) { diff --git a/test/js/node/test/parallel/test-child-process-advanced-serialization-splitted-length-field.js b/test/js/node/test/parallel/test-child-process-advanced-serialization-splitted-length-field.js new file mode 100644 index 000000000000..5407a56f495c --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-advanced-serialization-splitted-length-field.js @@ -0,0 +1,24 @@ +'use strict'; +const common = require('../common'); +const child_process = require('child_process'); + +// Regression test for https://github.com/nodejs/node/issues/55834 +const msgLen = 65521; +let cnt = 10; + +if (process.argv[2] === 'child') { + const msg = Buffer.allocUnsafe(msgLen); + (function send() { + if (cnt--) { + process.send(msg, send); + } else { + process.disconnect(); + } + })(); +} else { + const child = child_process.spawn(process.execPath, [__filename, 'child'], { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + serialization: 'advanced' + }); + child.on('message', common.mustCall(cnt)); +} diff --git a/test/js/node/test/parallel/test-child-process-fork-advanced-header-serialization.js b/test/js/node/test/parallel/test-child-process-fork-advanced-header-serialization.js new file mode 100644 index 000000000000..85116a1b5d16 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-fork-advanced-header-serialization.js @@ -0,0 +1,40 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { fork } = require('child_process'); +const fs = require('fs'); + +if (process.argv[2] === 'child-buffer') { + const v = process.argv[3]; + const payload = Buffer.from([ + (v >> 24) & 0xFF, + (v >> 16) & 0xFF, + (v >> 8) & 0xFF, + v & 0xFF, + ]); + const fd = process.channel?.fd; + if (fd !== undefined) { + fs.writeSync(fd, payload); + } + return; +} + +const testCases = [ + 0x00000001, + 0x7fffffff, + 0x80000000, + 0x80000001, + 0xffffffff, +]; + +for (const size of testCases) { + const child = fork(__filename, ['child-buffer', size], { + serialization: 'advanced', + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + }); + + child.on('exit', common.mustCall((code, signal) => { + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + })); +} diff --git a/test/js/node/test/parallel/test-child-process-fork-net-server.js b/test/js/node/test/parallel/test-child-process-fork-net-server.js new file mode 100644 index 000000000000..e52b4dbe53e6 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-fork-net-server.js @@ -0,0 +1,159 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const fork = require('child_process').fork; +const net = require('net'); +const debug = require('util').debuglog('test'); + +const Countdown = require('../common/countdown'); + +if (process.argv[2] === 'child') { + + let serverScope; + + // TODO(@jasnell): The message event is not called consistently + // across platforms. Need to investigate if it can be made + // more consistent. + const onServer = (msg, server) => { + if (msg.what !== 'server') return; + process.removeListener('message', onServer); + + serverScope = server; + + // TODO(@jasnell): This is apparently not called consistently + // across platforms. Need to investigate if it can be made + // more consistent. + server.on('connection', (socket) => { + debug('CHILD: got connection'); + process.send({ what: 'connection' }); + socket.destroy(); + }); + + // Start making connection from parent. + debug('CHILD: server listening'); + process.send({ what: 'listening' }); + }; + + process.on('message', onServer); + + // TODO(@jasnell): The close event is not called consistently + // across platforms. Need to investigate if it can be made + // more consistent. + const onClose = common.mustCallAtLeast((msg) => { + if (msg.what !== 'close') return; + process.removeListener('message', onClose); + + serverScope.on('close', common.mustCall(() => { + process.send({ what: 'close' }); + })); + serverScope.close(); + }); + + process.on('message', onClose); + + process.send({ what: 'ready' }); +} else { + + const child = fork(process.argv[1], ['child']); + + child.on('exit', common.mustCall((code, signal) => { + const message = `CHILD: died with ${code}, ${signal}`; + assert.strictEqual(code, 0, message); + })); + + // Send net.Server to child and test by connecting. + function testServer(callback) { + + // Destroy server execute callback when done. + const countdown = new Countdown(2, common.mustCall(() => { + server.on('close', common.mustCall(() => { + debug('PARENT: server closed'); + child.send({ what: 'close' }); + })); + server.close(); + })); + + // We expect 4 connections and close events. + const connections = new Countdown(4, () => countdown.dec()); + const closed = new Countdown(4, () => countdown.dec()); + + // Create server and send it to child. + const server = net.createServer(); + + // TODO(@jasnell): The specific number of times the connection + // event is emitted appears to be variable across platforms. + // Need to investigate why and whether it can be made + // more consistent. + server.on('connection', (socket) => { + debug('PARENT: got connection'); + socket.destroy(); + connections.dec(); + }); + + server.on('listening', common.mustCall(() => { + debug('PARENT: server listening'); + child.send({ what: 'server' }, server); + })); + server.listen(0); + + // Handle client messages. + // TODO(@jasnell): The specific number of times the message + // event is emitted appears to be variable across platforms. + // Need to investigate why and whether it can be made + // more consistent. + const messageHandlers = common.mustCallAtLeast((msg) => { + if (msg.what === 'listening') { + // Make connections. + let socket; + for (let i = 0; i < 4; i++) { + socket = net.connect(server.address().port, common.mustCall(() => { + debug('CLIENT: connected'); + })); + socket.on('close', common.mustCall(() => { + closed.dec(); + debug('CLIENT: closed'); + })); + } + + } else if (msg.what === 'connection') { + // Child got connection + connections.dec(); + } else if (msg.what === 'close') { + child.removeListener('message', messageHandlers); + callback(); + } + }); + + child.on('message', messageHandlers); + } + + const onReady = common.mustCall((msg) => { + if (msg.what !== 'ready') return; + child.removeListener('message', onReady); + testServer(common.mustCall()); + }); + + // Create server and send it to child. + child.on('message', onReady); +} diff --git a/test/js/node/test/parallel/test-child-process-fork-net-socket.js b/test/js/node/test/parallel/test-child-process-fork-net-socket.js new file mode 100644 index 000000000000..28da94f4ef56 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-fork-net-socket.js @@ -0,0 +1,96 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const { + mustCall, + mustCallAtLeast, +} = require('../common'); +const assert = require('assert'); +const fork = require('child_process').fork; +const net = require('net'); +const debug = require('util').debuglog('test'); + +if (process.argv[2] === 'child') { + + const onSocket = mustCall((msg, socket) => { + if (msg.what !== 'socket') return; + process.removeListener('message', onSocket); + socket.end('echo'); + debug('CHILD: got socket'); + }); + + process.on('message', onSocket); + + process.send({ what: 'ready' }); +} else { + + const child = fork(process.argv[1], ['child']); + + child.on('exit', mustCall((code, signal) => { + const message = `CHILD: died with ${code}, ${signal}`; + assert.strictEqual(code, 0, message); + })); + + // Send net.Socket to child. + function testSocket() { + + // Create a new server and connect to it, + // but the socket will be handled by the child. + const server = net.createServer(); + server.on('connection', mustCall((socket) => { + // TODO(@jasnell): Close does not seem to actually be called. + // It is not clear if it is needed. + socket.on('close', () => { + debug('CLIENT: socket closed'); + }); + child.send({ what: 'socket' }, socket); + })); + server.on('close', mustCall(() => { + debug('PARENT: server closed'); + })); + + server.listen(0, mustCall(() => { + debug('testSocket, listening'); + const connect = net.connect(server.address().port); + let store = ''; + connect.on('data', mustCallAtLeast((chunk) => { + store += chunk; + debug('CLIENT: got data'); + })); + connect.on('close', mustCall(() => { + debug('CLIENT: closed'); + assert.strictEqual(store, 'echo'); + server.close(); + })); + })); + } + + const onReady = mustCall((msg) => { + if (msg.what !== 'ready') return; + child.removeListener('message', onReady); + + testSocket(); + }); + + // Create socket and send it to child. + child.on('message', onReady); +} diff --git a/test/js/node/test/parallel/test-child-process-internal.js b/test/js/node/test/parallel/test-child-process-internal.js new file mode 100644 index 000000000000..345d49517423 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-internal.js @@ -0,0 +1,49 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +// Messages +const PREFIX = 'NODE_'; +const normal = { cmd: `foo${PREFIX}` }; +const internal = { cmd: `${PREFIX}bar` }; + +if (process.argv[2] === 'child') { + // Send non-internal message containing PREFIX at a non prefix position + process.send(normal); + + // Send internal message + process.send(internal); +} else { + + const fork = require('child_process').fork; + const child = fork(process.argv[1], ['child']); + + child.once('message', common.mustCall(function(data) { + assert.deepStrictEqual(data, normal); + })); + + child.once('internalMessage', common.mustCall(function(data) { + assert.deepStrictEqual(data, internal); + })); +} diff --git a/test/js/node/test/parallel/test-child-process-prototype-tampering.mjs b/test/js/node/test/parallel/test-child-process-prototype-tampering.mjs new file mode 100644 index 000000000000..cc30c1b6c89d --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-prototype-tampering.mjs @@ -0,0 +1,91 @@ +import * as common from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { EOL } from 'node:os'; +import assert from 'node:assert'; +import cp from 'node:child_process'; + +// TODO(LiviaMedeiros): test on different platforms +if (!common.isLinux) + common.skip(); + +const expectedCWD = process.cwd(); +const expectedUID = process.getuid(); + +for (const tamperedCwd of ['', '/tmp', '/not/existing/malicious/path', 42n]) { + Object.prototype.cwd = tamperedCwd; + + cp.exec('pwd', common.mustSucceed((out) => { + assert.strictEqual(`${out}`, `${expectedCWD}${EOL}`); + })); + assert.strictEqual(`${cp.execSync('pwd')}`, `${expectedCWD}${EOL}`); + cp.execFile('pwd', common.mustSucceed((out) => { + assert.strictEqual(`${out}`, `${expectedCWD}${EOL}`); + })); + assert.strictEqual(`${cp.execFileSync('pwd')}`, `${expectedCWD}${EOL}`); + cp.spawn('pwd').stdout.on('data', common.mustCall((out) => { + assert.strictEqual(`${out}`, `${expectedCWD}${EOL}`); + })); + assert.strictEqual(`${cp.spawnSync('pwd').stdout}`, `${expectedCWD}${EOL}`); + + delete Object.prototype.cwd; +} + +for (const tamperedUID of [0, 1, 999, 1000, 0n, 'gwak']) { + Object.prototype.uid = tamperedUID; + + cp.exec('id -u', common.mustSucceed((out) => { + assert.strictEqual(`${out}`, `${expectedUID}${EOL}`); + })); + assert.strictEqual(`${cp.execSync('id -u')}`, `${expectedUID}${EOL}`); + cp.execFile('id', ['-u'], common.mustSucceed((out) => { + assert.strictEqual(`${out}`, `${expectedUID}${EOL}`); + })); + assert.strictEqual(`${cp.execFileSync('id', ['-u'])}`, `${expectedUID}${EOL}`); + cp.spawn('id', ['-u']).stdout.on('data', common.mustCall((out) => { + assert.strictEqual(`${out}`, `${expectedUID}${EOL}`); + })); + assert.strictEqual(`${cp.spawnSync('id', ['-u']).stdout}`, `${expectedUID}${EOL}`); + + delete Object.prototype.uid; +} + +{ + Object.prototype.execPath = '/not/existing/malicious/path'; + + // Does not throw ENOENT + cp.fork(fixtures.path('empty.js')); + + delete Object.prototype.execPath; +} + +for (const shellCommandArgument of ['-L && echo "tampered"']) { + Object.prototype.shell = true; + const cmd = 'pwd'; + let cmdExitCode = ''; + + const program = cp.spawn(cmd, [shellCommandArgument], { cwd: expectedCWD }); + program.stderr.on('data', common.mustCall()); + program.stdout.on('data', common.mustNotCall()); + + program.on('exit', common.mustCall((code) => { + assert.notStrictEqual(code, 0); + })); + + cp.execFile(cmd, [shellCommandArgument], { cwd: expectedCWD }, + common.mustCall((err) => { + assert.notStrictEqual(err.code, 0); + }) + ); + + assert.throws(() => { + cp.execFileSync(cmd, [shellCommandArgument], { cwd: expectedCWD }); + }, (e) => { + assert.notStrictEqual(e.status, 0); + return true; + }); + + cmdExitCode = cp.spawnSync(cmd, [shellCommandArgument], { cwd: expectedCWD }).status; + assert.notStrictEqual(cmdExitCode, 0); + + delete Object.prototype.shell; +} diff --git a/test/js/node/test/parallel/test-child-process-send-keep-open.js b/test/js/node/test/parallel/test-child-process-send-keep-open.js new file mode 100644 index 000000000000..62c862e1b438 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-send-keep-open.js @@ -0,0 +1,50 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cp = require('child_process'); +const net = require('net'); + +if (process.argv[2] !== 'child') { + // The parent process forks a child process, starts a TCP server, and connects + // to the server. The accepted connection is passed to the child process, + // where the socket is written. Then, the child signals the parent process to + // write to the same socket. + let result = ''; + + process.on('exit', () => { + assert.strictEqual(result, 'childparent'); + }); + + const child = cp.fork(__filename, ['child']); + + // Verify that the child exits successfully + child.on('exit', common.mustCall((exitCode, signalCode) => { + assert.strictEqual(exitCode, 0); + assert.strictEqual(signalCode, null); + })); + + const server = net.createServer(common.mustCall((socket) => { + child.on('message', common.mustCall((msg) => { + assert.strictEqual(msg, 'child_done'); + socket.end('parent', () => { + server.close(); + child.disconnect(); + }); + })); + + child.send('socket', socket, { keepOpen: true }, common.mustSucceed()); + })); + + server.listen(0, () => { + const socket = net.connect(server.address().port, common.localhostIPv4); + socket.setEncoding('utf8'); + socket.on('data', (data) => result += data); + }); +} else { + // The child process receives the socket from the parent, writes data to + // the socket, then signals the parent process to write + process.on('message', common.mustCall((msg, socket) => { + assert.strictEqual(msg, 'socket'); + socket.write('child', () => process.send('child_done')); + })); +} diff --git a/test/js/node/test/parallel/test-child-process-spawn-args.mjs b/test/js/node/test/parallel/test-child-process-spawn-args.mjs new file mode 100644 index 000000000000..6d0bc9056789 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-spawn-args.mjs @@ -0,0 +1,50 @@ +// This test confirms that `undefined`, `null`, and `[]` +// can be used as a placeholder for the second argument (`args`) of `spawn()`. +// Previously, there was a bug where using `undefined` for the second argument +// caused the third argument (`options`) to be ignored. +// See https://github.com/nodejs/node/issues/24912. + +import * as common from '../common/index.mjs'; +import tmpdir from '../common/tmpdir.js'; + +import assert from 'node:assert'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; + +tmpdir.refresh(); + +const command = common.isWindows ? 'cd' : 'pwd'; +const options = { cwd: tmpdir.path }; + +if (common.isWindows) { + // This test is not the case for Windows based systems + // unless the `shell` options equals to `true` + options.shell = true; +} + +const testCases = [ + undefined, + null, + [], +]; + +const expectedResult = new Set([tmpdir.path.trim().toLowerCase()]); + +const actualResults = new Set(); + +for (const testCase of testCases) { + const subprocess = spawn(command, testCase, options); + + let accumulatedData = ''; + + subprocess.stdout.setEncoding('utf8'); + subprocess.stdout.on('data', common.mustCall((data) => { + accumulatedData += data; + })); + + await once(subprocess.stdout, 'end'); + + actualResults.add(accumulatedData.trim().toLowerCase()); +} + +assert.deepStrictEqual(actualResults, expectedResult); diff --git a/test/js/node/test/parallel/test-child-process-spawn-windows-batch-file.js b/test/js/node/test/parallel/test-child-process-spawn-windows-batch-file.js new file mode 100644 index 000000000000..4d3057436063 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-spawn-windows-batch-file.js @@ -0,0 +1,97 @@ +'use strict'; + +// Node.js on Windows should not be able to spawn batch files directly, +// only when the 'shell' option is set. An undocumented feature of the +// Win32 CreateProcess API allows spawning .bat and .cmd files directly +// but it does not sanitize arguments. We cannot do that automatically +// because it's sometimes impossible to escape arguments unambiguously. +// +// Expectation: spawn() and spawnSync() raise EINVAL if and only if: +// +// 1. 'shell' option is unset +// 2. Platform is Windows +// 3. Filename ends in .bat or .cmd, case-insensitive +// +// exec() and execSync() are unchanged. + +const common = require('../common'); +const cp = require('child_process'); +const assert = require('assert'); +const { isWindows } = common; + +const expectedCode = isWindows ? 'EINVAL' : 'ENOENT'; +const expectedStatus = isWindows ? 1 : 127; + +const suffixes = + 'BAT|bAT|BaT|baT|BAt|bAt|Bat|bat|CMD|cMD|CmD|cmD|CMd|cMd|Cmd|cmd|cmd |cmd .|cmd ....' + .split('|'); + +function testExec(filename) { + return new Promise((resolve) => { + cp.exec(filename).once('exit', common.mustCall(function(status) { + assert.strictEqual(status, expectedStatus); + resolve(); + })); + }); +} + +function testExecSync(filename) { + let e; + try { + cp.execSync(filename); + } catch (_e) { + e = _e; + } + if (!e) throw new Error(`Exception expected for ${filename}`); + assert.strictEqual(e.status, expectedStatus); +} + +function testSpawn(filename, code) { + // Batch file case is a synchronous error, file-not-found is asynchronous. + if (code === 'EINVAL') { + let e; + try { + cp.spawn(filename); + } catch (_e) { + e = _e; + } + if (!e) throw new Error(`Exception expected for ${filename}`); + assert.strictEqual(e.code, code); + } else { + return new Promise((resolve) => { + cp.spawn(filename).once('error', common.mustCall(function(e) { + assert.strictEqual(e.code, code); + resolve(); + })); + }); + } +} + +function testSpawnSync(filename, code) { + { + const r = cp.spawnSync(filename); + assert.strictEqual(r.error.code, code); + } + { + const r = cp.spawnSync(filename, { shell: true }); + assert.strictEqual(r.status, expectedStatus); + } +} + +testExecSync('./nosuchdir/nosuchfile'); +testSpawnSync('./nosuchdir/nosuchfile', 'ENOENT'); +for (const suffix of suffixes) { + testExecSync(`./nosuchdir/nosuchfile.${suffix}`); + testSpawnSync(`./nosuchdir/nosuchfile.${suffix}`, expectedCode); +} + +go().catch((ex) => { throw ex; }); + +async function go() { + await testExec('./nosuchdir/nosuchfile'); + await testSpawn('./nosuchdir/nosuchfile', 'ENOENT'); + for (const suffix of suffixes) { + await testExec(`./nosuchdir/nosuchfile.${suffix}`); + await testSpawn(`./nosuchdir/nosuchfile.${suffix}`, expectedCode); + } +} diff --git a/test/js/node/test/parallel/test-child-process-spawnsync-non-string-args.js b/test/js/node/test/parallel/test-child-process-spawnsync-non-string-args.js new file mode 100644 index 000000000000..5ac14d821f7b --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-spawnsync-non-string-args.js @@ -0,0 +1,7 @@ +'use strict'; +const common = require('../common'); +const { spawnSync } = require('child_process'); +const stateful = { + toString: common.mustCall(() => ';'), +}; +spawnSync(process.execPath, ['-e', stateful], { stdio: 'ignore' }); diff --git a/test/js/node/test/parallel/test-child-process-uid-gid.js b/test/js/node/test/parallel/test-child-process-uid-gid.js new file mode 100644 index 000000000000..748214294ceb --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-uid-gid.js @@ -0,0 +1,20 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const spawn = require('child_process').spawn; +const expectedError = common.isWindows ? /\bENOTSUP\b/ : /\bEPERM\b/; + +if (common.isIBMi) + common.skip('IBMi has a different behavior'); + +if (common.isWindows || process.getuid() !== 0) { + assert.throws(() => { + spawn('echo', ['fhqwhgads'], { uid: 0 }); + }, expectedError); +} + +if (common.isWindows || !process.getgroups().some((gid) => gid === 0)) { + assert.throws(() => { + spawn('echo', ['fhqwhgads'], { gid: 0 }); + }, expectedError); +} diff --git a/test/js/node/test/parallel/test-cluster-accept-fail.js b/test/js/node/test/parallel/test-cluster-accept-fail.js new file mode 100644 index 000000000000..f35379afab4d --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-accept-fail.js @@ -0,0 +1,30 @@ +// Flags: --expose-internals +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); +const cluster = require('cluster'); +const rr = require('internal/cluster/round_robin_handle'); + +if (cluster.isPrimary) { + const originalDistribute = rr.prototype.distribute; + rr.prototype.distribute = common.mustCall(function distribute(err, handle) { + assert.strictEqual(err, 0); + handle.close(); + originalDistribute.call(this, -1, undefined); + }); + cluster.schedulingPolicy = cluster.SCHED_RR; + cluster.fork(); +} else { + const server = net.createServer(common.mustNotCall()); + server.listen(0, common.mustCall(() => { + + const socket = net.connect(server.address().port); + + socket.on('close', common.mustCall(() => { + server.close(common.mustCall(() => { + process.disconnect(); + })); + })); + })); +} diff --git a/test/js/node/test/parallel/test-cluster-basic.js b/test/js/node/test/parallel/test-cluster-basic.js new file mode 100644 index 000000000000..3644efc75bea --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-basic.js @@ -0,0 +1,195 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); + +const assert = require('node:assert'); +const cluster = require('node:cluster'); +const { spawnSync } = require('node:child_process'); + +assert.strictEqual('NODE_UNIQUE_ID' in process.env, false, + `NODE_UNIQUE_ID (${process.env.NODE_UNIQUE_ID}) ` + + 'should be removed on startup'); + +{ + const { status } = spawnSync(process.execPath, [ + '-e', + ` + const { strictEqual } = require('node:assert'); + Object.setPrototypeOf(process.env, { NODE_UNIQUE_ID: 0 }); + strictEqual(require('cluster').isPrimary, true); + `, + ]); + assert.strictEqual(status, 0); +} + +function forEach(obj, fn) { + Object.keys(obj).forEach((name, index) => { + fn(obj[name], name, index); + }); +} + + +if (cluster.isWorker) { + require('http').Server(common.mustNotCall()).listen(0, '127.0.0.1'); +} else if (cluster.isPrimary) { + + const checks = { + cluster: { + events: { + fork: false, + online: false, + listening: false, + exit: false + }, + equal: { + fork: false, + online: false, + listening: false, + exit: false + } + }, + + worker: { + events: { + online: false, + listening: false, + exit: false + }, + equal: { + online: false, + listening: false, + exit: false + }, + states: { + none: false, + online: false, + listening: false, + dead: false + } + } + }; + + const stateNames = Object.keys(checks.worker.states); + + // Check events, states, and emit arguments + forEach(checks.cluster.events, common.mustCallAtLeast((bool, name, index) => { + + // Listen on event + cluster.on(name, common.mustCall(function(/* worker */) { + + // Set event + checks.cluster.events[name] = true; + + // Check argument + checks.cluster.equal[name] = worker === arguments[0]; + + // Check state + const state = stateNames[index]; + checks.worker.states[state] = (state === worker.state); + })); + })); + + // Kill worker when listening + cluster.on('listening', common.mustCall(() => { + worker.kill(); + })); + + // Kill process when worker is killed + cluster.on('exit', common.mustCall()); + + // Create worker + const worker = cluster.fork(); + assert.strictEqual(worker.id, 1); + assert(worker instanceof cluster.Worker, + 'the worker is not a instance of the Worker constructor'); + + // Check event + forEach(checks.worker.events, common.mustCallAtLeast((bool, name, index) => { + worker.on(name, common.mustCall(function() { + // Set event + checks.worker.events[name] = true; + + // Check argument + checks.worker.equal[name] = (worker === this); + + switch (name) { + case 'exit': + assert.strictEqual(arguments[0], worker.process.exitCode); + assert.strictEqual(arguments[1], worker.process.signalCode); + assert.strictEqual(arguments.length, 2); + break; + + case 'listening': { + assert.strictEqual(arguments.length, 1); + assert.strictEqual(Object.keys(arguments[0]).length, 4); + assert.strictEqual(arguments[0].address, '127.0.0.1'); + assert.strictEqual(arguments[0].addressType, 4); + assert(Object.hasOwn(arguments[0], 'fd')); + assert.strictEqual(arguments[0].fd, undefined); + const port = arguments[0].port; + assert(Number.isInteger(port)); + assert(port >= 1); + assert(port <= 65535); + break; + } + default: + assert.strictEqual(arguments.length, 0); + break; + } + })); + })); + + // Check all values + process.on('exit', () => { + // Check cluster events + for (const [ name, check ] of Object.entries(checks.cluster.events)) { + assert(check, + `The cluster event "${name}" on the cluster object did not fire`); + } + + // Check cluster event arguments + for (const [ name, check ] of Object.entries(checks.cluster.equal)) { + assert(check, + `The cluster event "${name}" did not emit with correct argument`); + } + + // Check worker states + for (const [ name, check ] of Object.entries(checks.worker.states)) { + assert(check, + `The worker state "${name}" was not set to true`); + } + + // Check worker events + for (const [ name, check ] of Object.entries(checks.worker.events)) { + assert(check, + `The worker event "${name}" on the worker object did not fire`); + } + + // Check worker event arguments + for (const [ name, check ] of Object.entries(checks.worker.equal)) { + assert(check, + `The worker event "${name}" did not emit with correct argument`); + } + }); + +} diff --git a/test/js/node/test/parallel/test-cluster-bind-privileged-port.js b/test/js/node/test/parallel/test-cluster-bind-privileged-port.js index 43f6f201582c..3ac36543a27b 100644 --- a/test/js/node/test/parallel/test-cluster-bind-privileged-port.js +++ b/test/js/node/test/parallel/test-cluster-bind-privileged-port.js @@ -21,7 +21,6 @@ 'use strict'; const common = require('../common'); -if (common.isLinux) return; // TODO: BUN const assert = require('assert'); const cluster = require('cluster'); const net = require('net'); diff --git a/test/js/node/test/parallel/test-cluster-concurrent-disconnect.js b/test/js/node/test/parallel/test-cluster-concurrent-disconnect.js index b754fa221a0d..56707d3cf2fa 100644 --- a/test/js/node/test/parallel/test-cluster-concurrent-disconnect.js +++ b/test/js/node/test/parallel/test-cluster-concurrent-disconnect.js @@ -24,14 +24,14 @@ if (cluster.isPrimary) { // These errors can occur due to the nature of the test, we might be trying // to send messages when the worker is disconnecting. - worker.on('error', (err) => { + worker.on('error', common.mustCallAtLeast((err) => { assert.strictEqual(err.syscall, 'write'); if (common.isMacOS) { assert(['EPIPE', 'ENOTCONN'].includes(err.code), err); } else { assert(['EPIPE', 'ECONNRESET'].includes(err.code), err); } - }); + }, 0)); worker.once('disconnect', common.mustCall(() => { for (const worker of workers) diff --git a/test/js/node/test/parallel/test-cluster-dgram-1.js b/test/js/node/test/parallel/test-cluster-dgram-1.js new file mode 100644 index 000000000000..23c9df71b5d7 --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-dgram-1.js @@ -0,0 +1,117 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +if (common.isWindows) + common.skip('dgram clustering is currently not supported on Windows.'); +// Upstream skips this test on macOS: kernels since 15.7 no longer distribute +// datagrams evenly across processes sharing the fd, so the strict +// 10-packets-per-worker assertion stalls forever and the test times out. +// https://github.com/nodejs/node/issues/60050 +if (common.isMacOS) + common.skip('dgram packet distribution is uneven on macOS >= 15.7'); + +const NUM_WORKERS = 4; +const PACKETS_PER_WORKER = 10; + +const assert = require('assert'); +const cluster = require('cluster'); +const dgram = require('dgram'); + +if (cluster.isPrimary) + primary(); +else + worker(); + + +function primary() { + let listening = 0; + + // Fork 4 workers. + for (let i = 0; i < NUM_WORKERS; i++) + cluster.fork(); + + // Wait until all workers are listening. + cluster.on('listening', common.mustCall((worker, address) => { + if (++listening < NUM_WORKERS) + return; + + // Start sending messages. + const buf = Buffer.from('hello world'); + const socket = dgram.createSocket('udp4'); + let sent = 0; + doSend(); + + function doSend() { + socket.send(buf, 0, buf.length, address.port, address.address, afterSend); + } + + function afterSend() { + sent++; + if (sent < NUM_WORKERS * PACKETS_PER_WORKER) { + doSend(); + } else { + socket.close(); + } + } + }, NUM_WORKERS)); + + // Set up event handlers for every worker. Each worker sends a message when + // it has received the expected number of packets. After that it disconnects. + for (const key in cluster.workers) { + if (Object.hasOwn(cluster.workers, key)) + setupWorker(cluster.workers[key]); + } + + function setupWorker(worker) { + let received = 0; + + worker.on('message', common.mustCall((msg) => { + received = msg.received; + worker.disconnect(); + })); + + worker.on('exit', common.mustCall(() => { + assert.strictEqual(received, PACKETS_PER_WORKER); + })); + } +} + + +function worker() { + let received = 0; + + // Create udp socket and start listening. + const socket = dgram.createSocket('udp4'); + + socket.on('message', common.mustCall((data, info) => { + received++; + + // Every 10 messages, notify the primary. + if (received === PACKETS_PER_WORKER) { + process.send({ received }); + socket.close(); + } + }, PACKETS_PER_WORKER)); + + socket.bind(0); +} diff --git a/test/js/node/test/parallel/test-cluster-dgram-2.js b/test/js/node/test/parallel/test-cluster-dgram-2.js new file mode 100644 index 000000000000..924d572a867c --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-dgram-2.js @@ -0,0 +1,94 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +if (common.isWindows) + common.skip('dgram clustering is currently not supported on Windows.'); + +const NUM_WORKERS = 4; +const PACKETS_PER_WORKER = 10; + +const cluster = require('cluster'); +const dgram = require('dgram'); +const assert = require('assert'); + +if (cluster.isPrimary) + primary(); +else + worker(); + + +function primary() { + let received = 0; + + // Start listening on a socket. + const socket = dgram.createSocket('udp4'); + socket.bind({ port: 0 }, common.mustCall(() => { + + // Fork workers. + for (let i = 0; i < NUM_WORKERS; i++) { + const worker = cluster.fork(); + worker.send({ port: socket.address().port }); + } + })); + + // Disconnect workers when the expected number of messages have been + // received. + socket.on('message', common.mustCall((data, info) => { + received++; + + if (received === PACKETS_PER_WORKER * NUM_WORKERS) { + + // Close the socket. + socket.close(); + + // Disconnect all workers. + cluster.disconnect(); + } + }, NUM_WORKERS * PACKETS_PER_WORKER)); +} + + +function worker() { + // Create udp socket and send packets to primary. + const socket = dgram.createSocket('udp4'); + const buf = Buffer.from('hello world'); + + // This test is intended to exercise the cluster binding of udp sockets, but + // since sockets aren't clustered when implicitly bound by at first call of + // send(), explicitly bind them to an ephemeral port. + socket.bind(0); + + process.on('message', common.mustCall((msg) => { + assert(msg.port); + + // There is no guarantee that a sent dgram packet will be received so keep + // sending until disconnect. + const interval = setInterval(() => { + socket.send(buf, 0, buf.length, msg.port, '127.0.0.1'); + }, 1); + + cluster.worker.on('disconnect', () => { + clearInterval(interval); + }); + })); +} diff --git a/test/js/node/test/parallel/test-cluster-dgram-bind-fd.js b/test/js/node/test/parallel/test-cluster-dgram-bind-fd.js new file mode 100644 index 000000000000..f81a93a952b3 --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-dgram-bind-fd.js @@ -0,0 +1,116 @@ +// Flags: --expose-internals +'use strict'; +const common = require('../common'); +if (common.isWindows) + common.skip('dgram clustering is currently not supported on Windows.'); +// Same shared-fd packet distribution assertion as test-cluster-dgram-1, so +// the same macOS >= 15.7 kernel behavior makes it stall forever. +// https://github.com/nodejs/node/issues/60050 +if (common.isMacOS) + common.skip('dgram packet distribution is uneven on macOS >= 15.7'); + +const NUM_WORKERS = 4; +const PACKETS_PER_WORKER = 10; + +const assert = require('assert'); +const cluster = require('cluster'); +const dgram = require('dgram'); + +if (cluster.isPrimary) + primary(); +else + worker(); + + +function primary() { + const { internalBinding } = require('internal/test/binding'); + const { UDP } = internalBinding('udp_wrap'); + + // Create a handle and use its fd. + const rawHandle = new UDP(); + const err = rawHandle.bind(common.localhostIPv4, 0, 0); + assert(err >= 0, String(err)); + assert.notStrictEqual(rawHandle.fd, -1); + + const fd = rawHandle.fd; + + let listening = 0; + + // Fork 4 workers. + for (let i = 0; i < NUM_WORKERS; i++) + cluster.fork(); + + // Wait until all workers are listening. + cluster.on('listening', common.mustCall((worker, address) => { + if (++listening < NUM_WORKERS) + return; + + // Start sending messages. + const buf = Buffer.from('hello world'); + const socket = dgram.createSocket('udp4'); + let sent = 0; + doSend(); + + function doSend() { + socket.send(buf, 0, buf.length, address.port, address.address, afterSend); + } + + function afterSend() { + sent++; + if (sent < NUM_WORKERS * PACKETS_PER_WORKER) { + doSend(); + } else { + socket.close(); + } + } + }, NUM_WORKERS)); + + // Set up event handlers for every worker. Each worker sends a message when + // it has received the expected number of packets. After that it disconnects. + for (const key in cluster.workers) { + if (Object.hasOwn(cluster.workers, key)) + setupWorker(cluster.workers[key]); + } + + function setupWorker(worker) { + let received = 0; + + worker.send({ + fd, + }); + + worker.on('message', common.mustCall((msg) => { + received = msg.received; + worker.disconnect(); + })); + + worker.on('exit', common.mustCall(() => { + assert.strictEqual(received, PACKETS_PER_WORKER); + })); + } +} + + +function worker() { + let received = 0; + + process.on('message', common.mustCall((data) => { + const { fd } = data; + // Create udp socket and start listening. + const socket = dgram.createSocket('udp4'); + + socket.on('message', common.mustCall((data, info) => { + received++; + + // Every 10 messages, notify the primary. + if (received === PACKETS_PER_WORKER) { + process.send({ received }); + socket.close(); + } + }, PACKETS_PER_WORKER)); + + socket.bind({ + fd, + }); + })); +} diff --git a/test/js/node/test/parallel/test-cluster-dgram-reuse.js b/test/js/node/test/parallel/test-cluster-dgram-reuse.js index d2790b5d99c0..b8eae5826fd3 100644 --- a/test/js/node/test/parallel/test-cluster-dgram-reuse.js +++ b/test/js/node/test/parallel/test-cluster-dgram-reuse.js @@ -1,7 +1,10 @@ 'use strict'; const common = require('../common'); +const os = require('os'); if (common.isWindows) common.skip('dgram clustering is currently not supported on windows.'); +if (common.isAIX && os.release() === '7.3') + common.skip('dgram clutering with reuse does not work if built on AIX 7.3.'); const assert = require('assert'); const cluster = require('cluster'); diff --git a/test/js/node/test/parallel/test-cluster-disconnect-exitedAfterDisconnect-race.js b/test/js/node/test/parallel/test-cluster-disconnect-exitedAfterDisconnect-race.js index f1a8dea0a9a6..81bdc0459d0b 100644 --- a/test/js/node/test/parallel/test-cluster-disconnect-exitedAfterDisconnect-race.js +++ b/test/js/node/test/parallel/test-cluster-disconnect-exitedAfterDisconnect-race.js @@ -8,9 +8,9 @@ const assert = require('assert'); const cluster = require('cluster'); if (cluster.isPrimary) { - cluster.on('exit', (worker, code) => { + cluster.on('exit', common.mustCall((worker, code) => { assert.strictEqual(code, 0, `worker exited with code: ${code}, expected 0`); - }); + })); return cluster.fork(); } diff --git a/test/js/node/test/parallel/test-cluster-disconnect-race.js b/test/js/node/test/parallel/test-cluster-disconnect-race.js new file mode 100644 index 000000000000..ce9e3c6abf51 --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-disconnect-race.js @@ -0,0 +1,37 @@ +'use strict'; + +// This code triggers an AssertionError on Linux in Node.js 5.3.0 and earlier. +// Ref: https://github.com/nodejs/node/issues/4205 + +const common = require('../common'); +if (common.isWindows) + common.skip('This test does not apply to Windows.'); + +const assert = require('assert'); +const net = require('net'); +const cluster = require('cluster'); + +cluster.schedulingPolicy = cluster.SCHED_NONE; + +if (cluster.isPrimary) { + let worker2; + + const worker1 = cluster.fork(); + worker1.on('message', common.mustCall(function() { + worker2 = cluster.fork(); + worker1.disconnect(); + worker2.on('online', common.mustCall(worker2.disconnect)); + })); + + cluster.on('exit', common.mustCall(function(worker, code) { + assert.strictEqual(code, 0, `worker exited with error code ${code}`); + }, 2)); + + return; +} + +const server = net.createServer(); + +server.listen(0, function() { + process.send('listening'); +}); diff --git a/test/js/node/test/parallel/test-cluster-disconnect-unshared-tcp.js b/test/js/node/test/parallel/test-cluster-disconnect-unshared-tcp.js new file mode 100644 index 000000000000..72c163fec838 --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-disconnect-unshared-tcp.js @@ -0,0 +1,44 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +require('../common'); +process.env.NODE_CLUSTER_SCHED_POLICY = 'none'; + +const cluster = require('cluster'); +const net = require('net'); + +if (cluster.isPrimary) { + const unbound = cluster.fork().on('online', bind); + + function bind() { + cluster.fork({ BOUND: 'y' }).on('listening', disconnect); + } + + function disconnect() { + unbound.disconnect(); + unbound.on('disconnect', cluster.disconnect); + } +} else if (process.env.BOUND === 'y') { + const source = net.createServer(); + + source.listen(0); +} diff --git a/test/js/node/test/parallel/test-cluster-disconnect-unshared-udp.js b/test/js/node/test/parallel/test-cluster-disconnect-unshared-udp.js new file mode 100644 index 000000000000..52eb58026af6 --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-disconnect-unshared-udp.js @@ -0,0 +1,47 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; + +const common = require('../common'); + +if (common.isWindows) + common.skip('on windows, because clustered dgram is ENOTSUP'); + +const cluster = require('cluster'); +const dgram = require('dgram'); + +if (cluster.isPrimary) { + const unbound = cluster.fork().on('online', bind); + + function bind() { + cluster.fork({ BOUND: 'y' }).on('listening', disconnect); + } + + function disconnect() { + unbound.disconnect(); + unbound.on('disconnect', cluster.disconnect); + } +} else if (process.env.BOUND === 'y') { + const source = dgram.createSocket('udp4'); + + source.bind(0); +} diff --git a/test/js/node/test/parallel/test-cluster-disconnect.js b/test/js/node/test/parallel/test-cluster-disconnect.js new file mode 100644 index 000000000000..01a2167dbc2e --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-disconnect.js @@ -0,0 +1,105 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cluster = require('cluster'); +const net = require('net'); + +if (cluster.isWorker) { + net.createServer((socket) => { + socket.end('echo'); + }).listen(0, '127.0.0.1'); + + net.createServer((socket) => { + socket.end('echo'); + }).listen(0, '127.0.0.1'); +} else if (cluster.isPrimary) { + const servers = 2; + const serverPorts = new Set(); + + // Test a single TCP server + const testConnection = common.mustCallAtLeast((port, cb) => { + const socket = net.connect(port, '127.0.0.1', common.mustCall(() => { + // buffer result + let result = ''; + socket.on('data', (chunk) => { result += chunk; }); + + // check result + socket.on('end', common.mustCall(() => { + cb(result === 'echo'); + serverPorts.delete(port); + })); + })); + }); + + // Test both servers created in the cluster + const testCluster = common.mustCallAtLeast((cb) => { + let done = 0; + const portsArray = Array.from(serverPorts); + + for (let i = 0; i < servers; i++) { + testConnection(portsArray[i], common.mustCall((success) => { + assert.ok(success); + done += 1; + if (done === servers) { + cb(); + } + })); + } + }); + + // Start two workers and execute callback when both is listening + const startCluster = common.mustCallAtLeast((cb) => { + const workers = 8; + let online = 0; + + for (let i = 0, l = workers; i < l; i++) { + cluster.fork().on('listening', common.mustCall((address) => { + serverPorts.add(address.port); + + online += 1; + if (online === workers * servers) { + cb(); + } + }, servers)); + } + }); + + const test = common.mustCall((again) => { + // 1. start cluster + startCluster(common.mustCall(() => { + // 2. test cluster + testCluster(common.mustCall(() => { + // 3. disconnect cluster + cluster.disconnect(common.mustCall(() => { + // Run test again to confirm cleanup + if (again) { + test(); + } + })); + })); + })); + }, 2); + + test(true); +} diff --git a/test/js/node/test/parallel/test-cluster-eaccess.js b/test/js/node/test/parallel/test-cluster-eaccess.js new file mode 100644 index 000000000000..2f533acea1e5 --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-eaccess.js @@ -0,0 +1,83 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); + +// Test that errors propagated from cluster workers are properly +// received in their primary. Creates an EADDRINUSE condition by forking +// a process in child cluster and propagates the error to the primary. + +const assert = require('assert'); +const cluster = require('cluster'); +const fork = require('child_process').fork; +const net = require('net'); + +if (cluster.isPrimary && process.argv.length !== 3) { + // cluster.isPrimary + const tmpdir = require('../common/tmpdir'); + tmpdir.refresh(); + const PIPE_NAME = common.PIPE; + const worker = cluster.fork({ PIPE_NAME }); + + // Makes sure primary is able to fork the worker + cluster.on('fork', common.mustCall()); + + // Makes sure the worker is ready + worker.on('online', common.mustCall()); + + worker.on('message', common.mustCall(function(err) { + // Disconnect first, so that we will not leave zombies + worker.disconnect(); + assert.strictEqual(err.code, 'EADDRINUSE'); + })); +} else if (process.argv.length !== 3) { + // cluster.worker + const PIPE_NAME = process.env.PIPE_NAME; + const cp = fork(__filename, [PIPE_NAME], { stdio: 'inherit' }); + + // Message from the child indicates it's ready and listening + cp.on('message', common.mustCall(function() { + const server = net.createServer().listen(PIPE_NAME, function() { + // Message child process so that it can exit + cp.send('end'); + // Inform primary about the unexpected situation + process.send('PIPE should have been in use.'); + }); + + server.on('error', function(err) { + // Message to child process tells it to exit + cp.send('end'); + // Propagate error to primary + process.send(err); + }); + })); +} else if (process.argv.length === 3) { + // Child process (of cluster.worker) + const PIPE_NAME = process.argv[2]; + + const server = net.createServer().listen(PIPE_NAME, common.mustCall(() => { + process.send('listening'); + })); + process.once('message', common.mustCall(() => server.close())); +} else { + assert.fail('Impossible state'); +} diff --git a/test/js/node/test/parallel/test-cluster-eaddrinuse.js b/test/js/node/test/parallel/test-cluster-eaddrinuse.js index f74d4ab7ec2e..c2e9dd147825 100644 --- a/test/js/node/test/parallel/test-cluster-eaddrinuse.js +++ b/test/js/node/test/parallel/test-cluster-eaddrinuse.js @@ -49,14 +49,14 @@ if (id === 'undefined') { server.on('error', common.mustCall(function(e) { assert(e.code, 'EADDRINUSE'); process.send('stop-listening'); - process.once('message', function(msg) { + process.once('message', common.mustCall((msg) => { if (msg !== 'stopped-listening') return; server = net.createServer(common.mustNotCall()); server.listen(port, common.mustCall(function() { server.close(); })); - }); + })); })); } else { - assert(0); // Bad argument. + assert.fail('Bad argument'); } diff --git a/test/js/node/test/parallel/test-cluster-fork-windowsHide.js b/test/js/node/test/parallel/test-cluster-fork-windowsHide.js index 2b90713ceb0a..b74a7606e970 100644 --- a/test/js/node/test/parallel/test-cluster-fork-windowsHide.js +++ b/test/js/node/test/parallel/test-cluster-fork-windowsHide.js @@ -30,11 +30,11 @@ if (!process.argv[2]) { }) }; - primary.on('message', (msg) => { + primary.on('message', common.mustCallAtLeast((msg) => { const handler = messageHandlers[msg.type]; assert.ok(handler); handler(msg); - }); + })); primary.on('exit', common.mustCall((code, signal) => { assert.strictEqual(code, 0); diff --git a/test/js/node/test/parallel/test-cluster-listen-pipe-readable-writable.js b/test/js/node/test/parallel/test-cluster-listen-pipe-readable-writable.js new file mode 100644 index 000000000000..d4b758a374f6 --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-listen-pipe-readable-writable.js @@ -0,0 +1,29 @@ +'use strict'; +const common = require('../common'); + +if (common.isWindows) { + common.skip('skip on Windows'); + return; +} + +const assert = require('assert'); +const cluster = require('cluster'); +const net = require('net'); +const fs = require('fs'); + +if (cluster.isPrimary) { + cluster.fork(); +} else { + const tmpdir = require('../common/tmpdir'); + tmpdir.refresh(); + const server = net.createServer().listen({ + path: common.PIPE, + readableAll: true, + writableAll: true, + }, common.mustCall(() => { + const stat = fs.statSync(common.PIPE); + assert.strictEqual(stat.mode & 0o777, 0o777); + server.close(); + process.disconnect(); + })); +} diff --git a/test/js/node/test/parallel/test-cluster-message.js b/test/js/node/test/parallel/test-cluster-message.js index 35d6c975b28b..58d0a88c8921 100644 --- a/test/js/node/test/parallel/test-cluster-message.js +++ b/test/js/node/test/parallel/test-cluster-message.js @@ -103,13 +103,13 @@ if (cluster.isWorker) { worker.on('message', function(message) { check('primary', message === 'message from worker'); }); - cluster.on('message', function(worker_, message) { + cluster.on('message', common.mustCall((worker_, message) => { assert.strictEqual(worker_, worker); check('global', message === 'message from worker'); - }); + })); // When a TCP server is listening in the worker connect to it - worker.on('listening', function(address) { + worker.on('listening', common.mustCall((address) => { client = net.connect(address.port, function() { // Send message to worker. @@ -135,12 +135,12 @@ if (cluster.isWorker) { worker.on('exit', common.mustCall(function() { process.exit(0); })); - }); + })); process.once('exit', function() { - forEach(checks, function(check, type) { + for (const [type, check] of Object.entries(checks)) { assert.ok(check.receive, `The ${type} did not receive any message`); assert.ok(check.correct, `The ${type} did not get the correct message`); - }); + } }); } diff --git a/test/js/node/test/parallel/test-cluster-net-listen-backlog.js b/test/js/node/test/parallel/test-cluster-net-listen-backlog.js new file mode 100644 index 000000000000..090552fd1e1e --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-net-listen-backlog.js @@ -0,0 +1,45 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +// Monkey-patch `net.Server.listen` +const net = require('net'); +const cluster = require('cluster'); + +// Force round-robin scheduling policy +// as Windows defaults to SCHED_NONE +// https://nodejs.org/docs/latest/api/cluster.html#clusterschedulingpolicy +cluster.schedulingPolicy = cluster.SCHED_RR; + +// Ensures that the `backlog` is used to create a `net.Server`. +const kExpectedBacklog = 127; +if (cluster.isMaster) { + const listen = net.Server.prototype.listen; + + net.Server.prototype.listen = common.mustCall( + function(...args) { + const options = args[0]; + if (typeof options === 'object') { + assert(options.backlog, kExpectedBacklog); + } else { + assert(args[1], kExpectedBacklog); + } + return listen.call(this, ...args); + } + ); + + const worker = cluster.fork(); + worker.on('message', () => { + worker.disconnect(); + }); +} else { + const server = net.createServer(); + + server.listen({ + host: common.localhostIPv4, + port: 0, + backlog: kExpectedBacklog, + }, common.mustCall(() => { + process.send(true); + })); +} diff --git a/test/js/node/test/parallel/test-cluster-net-listen-ipv6only-false.js b/test/js/node/test/parallel/test-cluster-net-listen-ipv6only-false.js new file mode 100644 index 000000000000..52be91efae68 --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-net-listen-ipv6only-false.js @@ -0,0 +1,56 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasIPv6) + common.skip('no IPv6 support'); + +const assert = require('assert'); +const cluster = require('cluster'); +const net = require('net'); + +// This test ensures that dual-stack support still works for cluster module +// when `ipv6Only` is not `true`. +const host = '::'; +const WORKER_COUNT = 3; + +if (cluster.isPrimary) { + const workers = []; + let address; + + for (let i = 0; i < WORKER_COUNT; i += 1) { + const myWorker = new Promise((resolve) => { + const worker = cluster.fork().on('exit', common.mustCall((statusCode) => { + assert.strictEqual(statusCode, 0); + })).on('listening', common.mustCall((workerAddress) => { + if (!address) { + address = workerAddress; + } else { + assert.strictEqual(address.addressType, workerAddress.addressType); + assert.strictEqual(address.host, workerAddress.host); + assert.strictEqual(address.port, workerAddress.port); + } + resolve(worker); + })); + }); + + workers.push(myWorker); + } + + Promise.all(workers).then(common.mustCall((resolvedWorkers) => { + const socket = net.connect({ + port: address.port, + host: '0.0.0.0', + }, common.mustCall(() => { + socket.destroy(); + resolvedWorkers.forEach((resolvedWorker) => { + resolvedWorker.disconnect(); + }); + })); + socket.on('error', common.mustNotCall()); + })); +} else { + net.createServer().listen({ + host, + port: 0, + }, common.mustCall()); +} diff --git a/test/js/node/test/parallel/test-cluster-net-listen-relative-path.js b/test/js/node/test/parallel/test-cluster-net-listen-relative-path.js new file mode 100644 index 000000000000..16d2bf5c836b --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-net-listen-relative-path.js @@ -0,0 +1,52 @@ +'use strict'; +const common = require('../common'); + +if (common.isWindows) { + common.skip('On Windows named pipes live in their own ' + + 'filesystem and don\'t have a ~100 byte limit'); +} + +const { isMainThread } = require('worker_threads'); + +if (!isMainThread) { + common.skip('process.chdir is not available in Workers'); +} + +const assert = require('assert'); +const cluster = require('cluster'); +const fs = require('fs'); +const net = require('net'); +const path = require('path'); + +const tmpdir = require('../common/tmpdir'); + +// Choose a socket name such that the absolute path would exceed 100 bytes. +const socketDir = './unix-socket-dir'; +const socketName = 'A'.repeat(101 - socketDir.length); + +// Make sure we're not in a weird environment. +assert.ok(path.resolve(socketDir, socketName).length > 100, + 'absolute socket path should be longer than 100 bytes'); + +if (cluster.isPrimary) { + // Ensure that the worker exits peacefully. + tmpdir.refresh(); + process.chdir(tmpdir.path); + fs.mkdirSync(socketDir); + cluster.fork().on('exit', common.mustCall((statusCode) => { + assert.strictEqual(statusCode, 0); + + assert.ok(!fs.existsSync(path.join(socketDir, socketName)), + 'Socket should be removed when the worker exits'); + })); +} else { + process.chdir(socketDir); + + const server = net.createServer(common.mustNotCall()); + + server.listen(socketName, common.mustCall(() => { + assert.ok(fs.existsSync(socketName), 'Socket created in CWD'); + + process.disconnect(); + })); +} diff --git a/test/js/node/test/parallel/test-cluster-net-reuseport.js b/test/js/node/test/parallel/test-cluster-net-reuseport.js new file mode 100644 index 000000000000..b875490d61fa --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-net-reuseport.js @@ -0,0 +1,38 @@ +'use strict'; +const common = require('../common'); + +const { checkSupportReusePort, options } = require('../common/net'); +const assert = require('assert'); +const cluster = require('cluster'); +const net = require('net'); + +if (cluster.isPrimary) { + checkSupportReusePort().then(() => { + cluster.fork().on('exit', common.mustCall((code) => { + assert.strictEqual(code, 0); + })); + }, () => { + common.skip('The `reusePort` option is not supported'); + }); + return; +} + +let waiting = 2; +function close() { + if (--waiting === 0) + cluster.worker.disconnect(); +} + +const server1 = net.createServer(); +const server2 = net.createServer(); + +// Test if the worker requests the main process to create a socket +cluster._getServer = common.mustNotCall(); + +server1.listen(options, common.mustCall(() => { + const port = server1.address().port; + server2.listen({ ...options, port }, common.mustCall(() => { + server1.close(close); + server2.close(close); + })); +})); diff --git a/test/js/node/test/parallel/test-cluster-net-send.js b/test/js/node/test/parallel/test-cluster-net-send.js new file mode 100644 index 000000000000..72b88dd1f87f --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-net-send.js @@ -0,0 +1,77 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const fork = require('child_process').fork; +const net = require('net'); + +if (process.argv[2] !== 'child') { + console.error(`[${process.pid}] primary`); + + const worker = fork(__filename, ['child']); + let called = false; + + worker.once('message', common.mustCall(function(msg, handle) { + assert.strictEqual(msg, 'handle'); + assert.ok(handle); + worker.send('got'); + + handle.on('data', common.mustCall((data) => { + called = true; + assert.strictEqual(data.toString(), 'hello'); + })); + + handle.on('end', function() { + worker.kill(); + }); + })); + + process.once('exit', function() { + assert.ok(called); + }); +} else { + console.error(`[${process.pid}] worker`); + + let socket; + let cbcalls = 0; + function socketConnected() { + if (++cbcalls === 2) + process.send('handle', socket); + } + + const server = net.createServer(common.mustCall((c) => { + process.once('message', common.mustCall(function(msg) { + assert.strictEqual(msg, 'got'); + c.end('hello'); + })); + socketConnected(); + })); + + server.listen(0, function() { + socket = net.connect(server.address().port, '127.0.0.1', socketConnected); + }); + + process.on('disconnect', function() { + server.close(); + }); +} diff --git a/test/js/node/test/parallel/test-cluster-net-server-drop-connection.js b/test/js/node/test/parallel/test-cluster-net-server-drop-connection.js new file mode 100644 index 000000000000..75a009a9eafa --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-net-server-drop-connection.js @@ -0,0 +1,73 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); +const cluster = require('cluster'); +const tmpdir = require('../common/tmpdir'); + +// The core has bug in handling pipe handle by ipc when platform is win32, +// it can be triggered on win32. I will fix it in another pr. +if (common.isWindows) + common.skip('no setSimultaneousAccepts on pipe handle'); + +const totalConns = 10; +const totalWorkers = 3; +let worker0; +let worker1; +let worker2; +let connectionCount = 0; +let listenCount = 0; + +function request(path) { + for (let i = 0; i < totalConns; i++) { + net.connect(path); + } +} + +function handleMessage(message) { + assert.match(message.action, /listen|connection/); + if (message.action === 'listen') { + if (++listenCount === totalWorkers) { + request(common.PIPE); + } + } else if (message.action === 'connection') { + if (++connectionCount === totalConns) { + worker0.send({ action: 'disconnect' }); + worker1.send({ action: 'disconnect' }); + worker2.send({ action: 'disconnect' }); + } + } +} + +if (cluster.isPrimary) { + cluster.schedulingPolicy = cluster.SCHED_RR; + tmpdir.refresh(); + worker0 = cluster.fork({ maxConnections: 0, pipePath: common.PIPE }); + worker1 = cluster.fork({ maxConnections: 1, pipePath: common.PIPE }); + worker2 = cluster.fork({ maxConnections: 9, pipePath: common.PIPE }); + // expected = { action: 'listen' } + maxConnections * { action: 'connection' } + worker0.on('message', common.mustCall((message) => { + handleMessage(message); + }, 1)); + worker1.on('message', common.mustCall((message) => { + handleMessage(message); + }, 2)); + worker2.on('message', common.mustCall((message) => { + handleMessage(message); + }, 10)); +} else { + const server = net.createServer(common.mustCall((socket) => { + process.send({ action: 'connection' }); + }, +process.env.maxConnections)); + + server.listen(process.env.pipePath, common.mustCall(() => { + process.send({ action: 'listen' }); + })); + + server.maxConnections = +process.env.maxConnections; + + process.on('message', common.mustCall((message) => { + assert.strictEqual(message.action, 'disconnect'); + process.disconnect(); + })); +} diff --git a/test/js/node/test/parallel/test-cluster-rr-handle-close.js b/test/js/node/test/parallel/test-cluster-rr-handle-close.js new file mode 100644 index 000000000000..fb8e9740d665 --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-rr-handle-close.js @@ -0,0 +1,18 @@ +'use strict'; + +const common = require('../common'); +const cluster = require('cluster'); +const net = require('net'); + +cluster.schedulingPolicy = cluster.SCHED_RR; + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on('exit', common.mustCall()); +} else { + const server = net.createServer(common.mustNotCall()); + server.listen(0, common.mustCall(() => { + process.channel.unref(); + server.close(); + })); +} diff --git a/test/js/node/test/parallel/test-cluster-rr-handle-keep-loop-alive.js b/test/js/node/test/parallel/test-cluster-rr-handle-keep-loop-alive.js index 0b18408a192b..c31bbe5cbcb9 100644 --- a/test/js/node/test/parallel/test-cluster-rr-handle-keep-loop-alive.js +++ b/test/js/node/test/parallel/test-cluster-rr-handle-keep-loop-alive.js @@ -13,10 +13,10 @@ if (cluster.isPrimary) { worker.on('exit', () => { exited = true; }); - setTimeout(() => { + setTimeout(common.mustCall(() => { assert.ok(!exited); worker.kill(); - }, 3000); + }), 3000); } else { const server = net.createServer(common.mustNotCall()); server.listen(0, common.mustCall(() => process.channel.unref())); diff --git a/test/js/node/test/parallel/test-cluster-rr-handle-ref-unref.js b/test/js/node/test/parallel/test-cluster-rr-handle-ref-unref.js new file mode 100644 index 000000000000..403bbefd4dd6 --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-rr-handle-ref-unref.js @@ -0,0 +1,20 @@ +'use strict'; + +const common = require('../common'); +const cluster = require('cluster'); +const net = require('net'); + +cluster.schedulingPolicy = cluster.SCHED_RR; + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on('exit', common.mustCall()); +} else { + const server = net.createServer(common.mustNotCall()); + server.listen(0, common.mustCall(() => { + server.ref(); + server.unref(); + process.channel.unref(); + })); + server.unref(); +} diff --git a/test/js/node/test/parallel/test-cluster-send-deadlock.js b/test/js/node/test/parallel/test-cluster-send-deadlock.js index 8ddc40c25294..2ed876bc3df0 100644 --- a/test/js/node/test/parallel/test-cluster-send-deadlock.js +++ b/test/js/node/test/parallel/test-cluster-send-deadlock.js @@ -23,18 +23,18 @@ // Testing mutual send of handles: from primary to worker, and from worker to // primary. -require('../common'); +const common = require('../common'); const assert = require('assert'); const cluster = require('cluster'); const net = require('net'); if (cluster.isPrimary) { const worker = cluster.fork(); - worker.on('exit', (code, signal) => { + worker.on('exit', common.mustCall((code, signal) => { assert.strictEqual(code, 0, `Worker exited with an error code: ${code}`); assert(!signal, `Worker exited by a signal: ${signal}`); server.close(); - }); + })); const server = net.createServer((socket) => { worker.send('handle', socket); @@ -44,8 +44,8 @@ if (cluster.isPrimary) { worker.send({ message: 'listen', port: server.address().port }); }); } else { - process.on('message', (msg, handle) => { - if (msg.message && msg.message === 'listen') { + process.on('message', common.mustCallAtLeast((msg, handle) => { + if (msg.message === 'listen') { assert(msg.port); const client1 = net.connect({ host: 'localhost', @@ -69,5 +69,5 @@ if (cluster.isPrimary) { } else { process.send('reply', handle); } - }); + })); } diff --git a/test/js/node/test/parallel/test-cluster-send-socket-to-worker-http-server.js b/test/js/node/test/parallel/test-cluster-send-socket-to-worker-http-server.js new file mode 100644 index 000000000000..49993514dddc --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-send-socket-to-worker-http-server.js @@ -0,0 +1,39 @@ +'use strict'; + +// Regression test for https://github.com/nodejs/node/issues/13435 +// Tests that `socket.server` is correctly set when a socket is sent to a worker +// and the `'connection'` event is emitted manually on an HTTP server. + +const common = require('../common'); +const assert = require('assert'); +const cluster = require('cluster'); +const http = require('http'); +const net = require('net'); + +if (cluster.isPrimary) { + const worker = cluster.fork(); + const server = net.createServer(common.mustCall((socket) => { + worker.send('socket', socket); + })); + + worker.on('exit', common.mustCall((code) => { + assert.strictEqual(code, 0); + server.close(); + })); + + server.listen(0, common.mustCall(() => { + net.createConnection(server.address().port); + })); +} else { + const server = http.createServer(); + + server.on('connection', common.mustCall((socket) => { + assert.strictEqual(socket.server, server); + socket.destroy(); + cluster.worker.disconnect(); + })); + + process.on('message', common.mustCall((message, socket) => { + server.emit('connection', socket); + })); +} diff --git a/test/js/node/test/parallel/test-cluster-server-restart-none.js b/test/js/node/test/parallel/test-cluster-server-restart-none.js new file mode 100644 index 000000000000..b8fca694904e --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-server-restart-none.js @@ -0,0 +1,45 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cluster = require('cluster'); + +cluster.schedulingPolicy = cluster.SCHED_NONE; + +if (cluster.isPrimary) { + const worker1 = cluster.fork(); + worker1.on('listening', common.mustCall(() => { + const worker2 = cluster.fork(); + worker2.on('exit', common.mustCall((code, signal) => { + assert.strictEqual(code, 0, + 'worker2 did not exit normally. ' + + `exited with code ${code}`); + assert.strictEqual(signal, null, + 'worker2 did not exit normally. ' + + `exited with signal ${signal}`); + worker1.disconnect(); + })); + })); + + worker1.on('exit', common.mustCall((code, signal) => { + assert.strictEqual(code, 0, + 'worker1 did not exit normally. ' + + `exited with code ${code}`); + assert.strictEqual(signal, null, + 'worker1 did not exit normally. ' + + `exited with signal ${signal}`); + })); +} else { + const net = require('net'); + const server = net.createServer(); + server.listen(0, common.mustCall(() => { + if (cluster.worker.id === 2) { + server.close(common.mustCall(() => { + server.listen(0, common.mustCall(() => { + server.close(() => { + process.disconnect(); + }); + })); + })); + } + })); +} diff --git a/test/js/node/test/parallel/test-cluster-server-restart-rr.js b/test/js/node/test/parallel/test-cluster-server-restart-rr.js new file mode 100644 index 000000000000..9fbbf63f5b71 --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-server-restart-rr.js @@ -0,0 +1,53 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cluster = require('cluster'); + +cluster.schedulingPolicy = cluster.SCHED_RR; + +if (cluster.isPrimary) { + const worker1 = cluster.fork(); + worker1.on('listening', common.mustCall(() => { + const worker2 = cluster.fork(); + worker2.on('exit', common.mustCall((code, signal) => { + assert.strictEqual( + code, + 0, + `worker${worker2.id} did not exit normally. Exit with code: ${code}` + ); + assert.strictEqual( + signal, + null, + `worker${worker2.id} did not exit normally. Exit with signal: ${signal}` + ); + worker1.disconnect(); + })); + })); + + worker1.on('exit', common.mustCall((code, signal) => { + assert.strictEqual( + code, + 0, + `worker${worker1.id} did not exit normally. Exit with code: ${code}` + ); + assert.strictEqual( + signal, + null, + `worker${worker1.id} did not exit normally. Exit with code: ${signal}` + ); + })); +} else { + const net = require('net'); + const server = net.createServer(); + server.listen(0, common.mustCall(() => { + if (cluster.worker.id === 2) { + server.close(common.mustCall(() => { + server.listen(0, common.mustCall(() => { + server.close(() => { + process.disconnect(); + }); + })); + })); + } + })); +} diff --git a/test/js/node/test/parallel/test-cluster-shared-handle-bind-error.js b/test/js/node/test/parallel/test-cluster-shared-handle-bind-error.js new file mode 100644 index 000000000000..79f588d8de0d --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-shared-handle-bind-error.js @@ -0,0 +1,49 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cluster = require('cluster'); +const net = require('net'); + +if (cluster.isPrimary) { + // Primary opens and binds the socket and shares it with the worker. + cluster.schedulingPolicy = cluster.SCHED_NONE; + // Hog the TCP port so that when the worker tries to bind, it'll fail. + const server = net.createServer(common.mustNotCall()); + + server.listen(0, common.mustCall(() => { + const worker = cluster.fork({ PORT: server.address().port }); + worker.on('exit', common.mustCall((exitCode) => { + assert.strictEqual(exitCode, 0); + server.close(); + })); + })); +} else { + assert(process.env.PORT); + const s = net.createServer(common.mustNotCall()); + s.listen(process.env.PORT, common.mustNotCall('listen should have failed')); + s.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'EADDRINUSE'); + process.disconnect(); + })); +} diff --git a/test/js/node/test/parallel/test-cluster-shared-handle-bind-privileged-port.js b/test/js/node/test/parallel/test-cluster-shared-handle-bind-privileged-port.js index edc522fd2db7..5e04c8eea1a8 100644 --- a/test/js/node/test/parallel/test-cluster-shared-handle-bind-privileged-port.js +++ b/test/js/node/test/parallel/test-cluster-shared-handle-bind-privileged-port.js @@ -21,7 +21,6 @@ 'use strict'; const common = require('../common'); -if (common.isLinux) return; // TODO: BUN // Skip on macOS Mojave. https://github.com/nodejs/node/issues/21679 if (common.isMacOS) @@ -36,6 +35,22 @@ if (common.isWindows) if (process.getuid() === 0) common.skip('as this test should not be run as `root`'); +// Some systems won't have port 42 set as a privileged port, in that +// case, skip the test. +if (common.isLinux) { + const { readFileSync } = require('fs'); + + try { + const unprivilegedPortStart = parseInt(readFileSync('/proc/sys/net/ipv4/ip_unprivileged_port_start')); + if (unprivilegedPortStart <= 42) { + common.skip('Port 42 is unprivileged'); + } + } catch { + // Do nothing, feature doesn't exist, minimum is 1024 so 42 is usable. + // Continue... + } +} + const assert = require('assert'); const cluster = require('cluster'); const net = require('net'); diff --git a/test/js/node/test/parallel/test-cluster-shared-leak.js b/test/js/node/test/parallel/test-cluster-shared-leak.js new file mode 100644 index 000000000000..48c07c7cc04d --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-shared-leak.js @@ -0,0 +1,51 @@ +// In Node 4.2.1 on operating systems other than Linux, this test triggers an +// assertion in cluster.js. The assertion protects against memory leaks. +// https://github.com/nodejs/node/pull/3510 + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); +const cluster = require('cluster'); +cluster.schedulingPolicy = cluster.SCHED_NONE; + +if (cluster.isPrimary) { + let conn, worker2; + + const worker1 = cluster.fork(); + worker1.on('listening', common.mustCall(function(address) { + worker2 = cluster.fork(); + worker2.on('online', common.mustCall(() => { + conn = net.connect(address.port, common.mustCall(function() { + worker1.disconnect(); + worker2.disconnect(); + })); + conn.on('error', function(e) { + // ECONNRESET is OK + if (e.code !== 'ECONNRESET') + throw e; + }); + })); + })); + + cluster.on('exit', common.mustCall((worker, exitCode, signalCode) => { + assert(worker === worker1 || worker === worker2); + assert.strictEqual(exitCode, 0); + assert.strictEqual(signalCode, null); + if (Object.keys(cluster.workers).length === 0) + conn.destroy(); + }, 2)); + + return; +} + +const server = net.createServer(function(c) { + c.on('error', function(e) { + // ECONNRESET is OK, so we don't exit with code !== 0 + if (e.code !== 'ECONNRESET') + throw e; + }); + c.end('bye'); +}); + +server.listen(0); diff --git a/test/js/node/test/parallel/test-cluster-worker-events.js b/test/js/node/test/parallel/test-cluster-worker-events.js index 6c044ace8df0..f9dbd3a18475 100644 --- a/test/js/node/test/parallel/test-cluster-worker-events.js +++ b/test/js/node/test/parallel/test-cluster-worker-events.js @@ -20,7 +20,7 @@ // USE OR OTHER DEALINGS IN THE SOFTWARE. 'use strict'; -require('../common'); +const common = require('../common'); const assert = require('assert'); const cluster = require('cluster'); @@ -30,10 +30,10 @@ if (cluster.isPrimary) { const worker = cluster.fork(); - worker.on('exit', (code) => { + worker.on('exit', common.mustCall((code) => { assert.strictEqual(code, OK); process.exit(0); - }); + })); const result = worker.send('SOME MESSAGE'); assert.strictEqual(result, true); @@ -51,29 +51,29 @@ let sawWorker; const messages = []; -const check = (m) => { +const check = common.mustCallAtLeast((m) => { messages.push(m); if (messages.length < 2) return; assert.deepStrictEqual(messages[0], messages[1]); - cluster.worker.once('error', (e) => { + cluster.worker.once('error', common.mustCall((e) => { assert.strictEqual(e, 'HI'); process.exit(OK); - }); + })); process.emit('error', 'HI'); -}; +}); -process.on('message', (m) => { +process.on('message', common.mustCall((m) => { assert(!sawProcess); sawProcess = true; check(m); -}); +})); -cluster.worker.on('message', (m) => { +cluster.worker.on('message', common.mustCall((m) => { assert(!sawWorker); sawWorker = true; check(m); -}); +})); diff --git a/test/js/node/test/parallel/test-cluster-worker-handle-close.js b/test/js/node/test/parallel/test-cluster-worker-handle-close.js new file mode 100644 index 000000000000..47a80ef1cd1f --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-worker-handle-close.js @@ -0,0 +1,27 @@ +'use strict'; +const common = require('../common'); +const cluster = require('cluster'); +const net = require('net'); + +if (cluster.isPrimary) { + cluster.schedulingPolicy = cluster.SCHED_RR; + cluster.fork(); +} else { + const server = net.createServer(common.mustNotCall()); + server.listen(0, common.mustCall(() => { + net.connect(server.address().port); + })); + process.prependListener('internalMessage', common.mustCallAtLeast((message, handle) => { + if (message.act !== 'newconn') { + return; + } + // Make the worker drops the connection, see `rr` and `onconnection` in child.js + server.close(); + const close = handle.close; + handle.close = common.mustCall(() => { + close.call(handle, common.mustCall(() => { + process.exit(); + })); + }); + })); +} diff --git a/test/js/node/test/parallel/test-cluster-worker-isdead.js b/test/js/node/test/parallel/test-cluster-worker-isdead.js index 6f2aa3c52ecd..24395da42045 100644 --- a/test/js/node/test/parallel/test-cluster-worker-isdead.js +++ b/test/js/node/test/parallel/test-cluster-worker-isdead.js @@ -1,5 +1,5 @@ 'use strict'; -require('../common'); +const common = require('../common'); const cluster = require('cluster'); const assert = require('assert'); @@ -10,12 +10,12 @@ if (cluster.isPrimary) { `isDead() returned ${workerDead}. isDead() should return ` + 'false right after the worker has been created.'); - worker.on('exit', function() { + worker.on('exit', common.mustCall(() => { workerDead = worker.isDead(); assert.ok(workerDead, `isDead() returned ${workerDead}. After an event has been ` + 'emitted, isDead should return true'); - }); + })); worker.on('message', function(msg) { if (msg === 'readyToDie') { diff --git a/test/js/node/test/parallel/test-cluster-worker-kill-signal.js b/test/js/node/test/parallel/test-cluster-worker-kill-signal.js new file mode 100644 index 000000000000..53e3739eba16 --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-worker-kill-signal.js @@ -0,0 +1,49 @@ +'use strict'; +// test-cluster-worker-kill-signal.js +// verifies that when we're killing a worker using Worker.prototype.kill +// and the worker's process was killed with the given signal (SIGKILL) + + +const common = require('../common'); +const assert = require('assert'); +const cluster = require('cluster'); + +if (cluster.isWorker) { + // Make the worker run something + const http = require('http'); + const server = http.Server(() => { }); + + server.once('listening', common.mustCall()); + server.listen(0, '127.0.0.1'); + +} else if (cluster.isMaster) { + const KILL_SIGNAL = 'SIGKILL'; + + // Start worker + const worker = cluster.fork(); + + // When the worker is up and running, kill it + worker.once('listening', common.mustCall(() => { + worker.kill(KILL_SIGNAL); + })); + + // Check worker events and properties + worker.on('disconnect', common.mustCall(() => { + assert.strictEqual(worker.exitedAfterDisconnect, false); + assert.strictEqual(worker.state, 'disconnected'); + }, 1)); + + // Check that the worker died + worker.once('exit', common.mustCall((exitCode, signalCode) => { + const isWorkerProcessStillAlive = common.isAlive(worker.process.pid); + const numOfRunningWorkers = Object.keys(cluster.workers).length; + + assert.strictEqual(exitCode, null); + assert.strictEqual(signalCode, KILL_SIGNAL); + assert.strictEqual(isWorkerProcessStillAlive, false); + assert.strictEqual(numOfRunningWorkers, 0); + }, 1)); + + // Check if the cluster was killed as well + cluster.on('exit', common.mustCall(1)); +} diff --git a/test/js/node/test/parallel/test-cluster-worker-no-exit.js b/test/js/node/test/parallel/test-cluster-worker-no-exit.js index e4694a4a3a1c..3091015bfa22 100644 --- a/test/js/node/test/parallel/test-cluster-worker-no-exit.js +++ b/test/js/node/test/parallel/test-cluster-worker-no-exit.js @@ -20,7 +20,7 @@ // USE OR OTHER DEALINGS IN THE SOFTWARE. 'use strict'; -require('../common'); +const common = require('../common'); const assert = require('assert'); const cluster = require('cluster'); const net = require('net'); @@ -41,7 +41,7 @@ let server; // 4 destroy connection // 5 confirm it does exit if (cluster.isPrimary) { - server = net.createServer(function(conn) { + server = net.createServer(common.mustCall((conn) => { server.close(); worker.disconnect(); worker.once('disconnect', function() { @@ -49,13 +49,13 @@ if (cluster.isPrimary) { conn.destroy(); destroyed = true; }, 1000); - }).once('exit', function() { + }).once('exit', common.mustCall(() => { // Worker should not exit while it has a connection assert(destroyed, 'worker exited before connection destroyed'); success = true; - }); + })); - }).listen(0, function() { + })).listen(0, function() { const port = this.address().port; worker = cluster.fork() diff --git a/test/js/node/test/parallel/test-cluster-worker-wait-server-close.js b/test/js/node/test/parallel/test-cluster-worker-wait-server-close.js index 71a8cacb5260..f4f82615bf56 100644 --- a/test/js/node/test/parallel/test-cluster-worker-wait-server-close.js +++ b/test/js/node/test/parallel/test-cluster-worker-wait-server-close.js @@ -23,11 +23,11 @@ if (cluster.isWorker) { const keepOpen = setInterval(() => {}, 9999); // Check worker events and properties - process.once('disconnect', function() { + process.once('disconnect', common.mustCall(() => { // Disconnect should occur after socket close assert(serverClosed); clearInterval(keepOpen); - }); + })); } else if (cluster.isPrimary) { // start worker const worker = cluster.fork(); diff --git a/test/js/node/test/sequential/test-child-process-pass-fd.js b/test/js/node/test/sequential/test-child-process-pass-fd.js new file mode 100644 index 000000000000..9b62c3edb300 --- /dev/null +++ b/test/js/node/test/sequential/test-child-process-pass-fd.js @@ -0,0 +1,84 @@ +'use strict'; +const common = require('../common'); + +// On some OS X versions, when passing fd's between processes: +// When the handle associated to a specific file descriptor is closed by the +// sender process before it's received in the destination, the handle is indeed +// closed while it should remain opened. In order to fix this behavior, don't +// close the handle until the `NODE_HANDLE_ACK` is received by the sender. +// This test is basically `test-cluster-net-send` but creating lots of workers +// so the issue reproduces on OS X consistently. + +if (common.isPi()) { + common.skip('Too slow for Raspberry Pi devices'); +} + +const assert = require('assert'); +const { fork } = require('child_process'); +const net = require('net'); + +const N = 80; +let messageCallbackCount = 0; + +function forkWorker() { + const messageCallback = common.mustCall((msg, handle) => { + messageCallbackCount++; + assert.strictEqual(msg, 'handle'); + assert.ok(handle); + worker.send('got'); + + let recvData = ''; + handle.on('data', common.mustCall((data) => { + recvData += data; + })); + + handle.on('end', common.mustCall(() => { + assert.strictEqual(recvData, 'hello'); + worker.kill(); + })); + }); + + const worker = fork(__filename, ['child']); + worker.on('error', (err) => { + if (/\bEAGAIN\b/.test(err.message)) { + forkWorker(); + return; + } + throw err; + }); + worker.once('message', messageCallback); +} + +if (process.argv[2] !== 'child') { + for (let i = 0; i < N; ++i) { + forkWorker(); + } + process.on('exit', () => { assert.strictEqual(messageCallbackCount, N); }); +} else { + let socket; + let cbcalls = 0; + function socketConnected() { + if (++cbcalls === 2) + process.send('handle', socket); + } + + // As a side-effect, listening for the message event will ref the IPC channel, + // so the child process will stay alive as long as it has a parent process/IPC + // channel. Once this is done, we can unref our client and server sockets, and + // the only thing keeping this worker alive will be IPC. This is important, + // because it means a worker with no parent will have no referenced handles, + // thus no work to do, and will exit immediately, preventing process leaks. + process.on('message', common.mustCall()); + + const server = net.createServer(common.mustCall((c) => { + process.once('message', common.mustCall((msg) => { + assert.strictEqual(msg, 'got'); + c.end('hello'); + })); + socketConnected(); + })).unref(); + server.listen(0, common.localhostIPv4, () => { + const { port } = server.address(); + socket = net.connect(port, common.localhostIPv4, socketConnected).unref(); + }); +} diff --git a/test/js/node/test/sequential/test-cluster-inspect-brk.js b/test/js/node/test/sequential/test-cluster-inspect-brk.js new file mode 100644 index 000000000000..efeb91e59f02 --- /dev/null +++ b/test/js/node/test/sequential/test-cluster-inspect-brk.js @@ -0,0 +1,39 @@ +'use strict'; +const common = require('../common'); +if (common.isWindows) + common.skip('extra stdio pipes (stdio beyond the IPC channel) are not supported on Windows'); +common.skipIfInspectorDisabled(); + +// A test to ensure that cluster properly interoperates with the +// --inspect-brk option. + +const assert = require('assert'); +const cluster = require('cluster'); +const debuggerPort = common.PORT; + +if (cluster.isPrimary) { + function test(execArgv) { + + cluster.setupPrimary({ + execArgv: execArgv, + stdio: ['pipe', 'pipe', 'pipe', 'ipc', 'pipe'], + }); + + const worker = cluster.fork(); + + // Debugger listening on port [port]. + worker.process.stderr.once('data', common.mustCall(function() { + worker.process.kill('SIGTERM'); + })); + + worker.process.on('exit', common.mustCall(function(code, signal) { + assert.strictEqual(signal, 'SIGTERM'); + })); + } + + test(['--inspect-brk']); + test([`--inspect-brk=${debuggerPort}`]); +} else { + // Cluster worker is at a breakpoint, should not reach here. + assert.fail('Test failed: cluster worker should be at a breakpoint.'); +} diff --git a/test/js/node/test/sequential/test-cluster-net-listen-ipv6only-none.js b/test/js/node/test/sequential/test-cluster-net-listen-ipv6only-none.js new file mode 100644 index 000000000000..ebcdfca7d0bc --- /dev/null +++ b/test/js/node/test/sequential/test-cluster-net-listen-ipv6only-none.js @@ -0,0 +1,58 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasIPv6) + common.skip('no IPv6 support'); + +const assert = require('assert'); +const cluster = require('cluster'); +const net = require('net'); + +// This test ensures that the `ipv6Only` option in `net.Server.listen()` +// works as expected when we use cluster with `SCHED_NONE` schedulingPolicy. +cluster.schedulingPolicy = cluster.SCHED_NONE; +const host = '::'; +const WORKER_ACCOUNT = 3; + +if (cluster.isPrimary) { + const workers = []; + + for (let i = 0; i < WORKER_ACCOUNT; i += 1) { + const myWorker = new Promise((resolve) => { + const worker = cluster.fork().on('exit', common.mustCall((statusCode) => { + assert.strictEqual(statusCode, 0); + })).on('listening', common.mustCall((workerAddress) => { + assert.strictEqual(workerAddress.addressType, 6); + assert.strictEqual(workerAddress.address, host); + assert.strictEqual(workerAddress.port, common.PORT); + resolve(worker); + })); + }); + + workers.push(myWorker); + } + + Promise.all(workers).then(common.mustCall((resolvedWorkers) => { + // Make sure the `ipv6Only` option works. This is the part of the test that + // requires the whole test to use `common.PORT` rather than port `0`. If it + // used port `0` instead, then the operating system can supply a port that + // is available for the IPv6 interface but in use by the IPv4 interface. + // Refs: https://github.com/nodejs/node/issues/29679 + const server = net.createServer().listen({ + host: '0.0.0.0', + port: common.PORT, + }, common.mustCall(() => { + // Exit. + server.close(); + resolvedWorkers.forEach((resolvedWorker) => { + resolvedWorker.disconnect(); + }); + })); + })); +} else { + net.createServer().listen({ + host, + port: common.PORT, + ipv6Only: true, + }, common.mustCall()); +} diff --git a/test/js/node/test/sequential/test-cluster-net-listen-ipv6only-rr.js b/test/js/node/test/sequential/test-cluster-net-listen-ipv6only-rr.js new file mode 100644 index 000000000000..0948bdac25d6 --- /dev/null +++ b/test/js/node/test/sequential/test-cluster-net-listen-ipv6only-rr.js @@ -0,0 +1,63 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasIPv6) + common.skip('no IPv6 support'); + +const assert = require('assert'); +const cluster = require('cluster'); +const net = require('net'); + +// This test ensures that the `ipv6Only` option in `net.Server.listen()` +// works as expected when we use cluster with `SCHED_RR` schedulingPolicy. +cluster.schedulingPolicy = cluster.SCHED_RR; +const host = '::'; +const WORKER_ACCOUNT = 3; + +if (cluster.isPrimary) { + const workers = []; + let address; + + for (let i = 0; i < WORKER_ACCOUNT; i += 1) { + const myWorker = new Promise((resolve) => { + const worker = cluster.fork().on('exit', common.mustCall((statusCode) => { + assert.strictEqual(statusCode, 0); + })).on('listening', common.mustCall((workerAddress) => { + if (!address) { + address = workerAddress; + } else { + assert.deepStrictEqual(workerAddress, address); + } + resolve(worker); + })); + }); + + workers.push(myWorker); + } + + Promise.all(workers).then(common.mustCall((resolvedWorkers) => { + // Make sure the `ipv6Only` option works. Should be able to use the port on + // IPv4. + const server = net.createServer().listen({ + host: '0.0.0.0', + port: address.port, + }, common.mustCall(() => { + // Exit. + server.close(); + resolvedWorkers.forEach((resolvedWorker) => { + resolvedWorker.disconnect(); + }); + })); + })); +} else { + // As the cluster member has the potential to grab any port + // from the environment, this can cause collision when primary + // obtains the port from cluster member and tries to listen on. + // So move this to sequential, and provide a static port. + // Refs: https://github.com/nodejs/node/issues/25813 + net.createServer().listen({ + host: host, + port: common.PORT, + ipv6Only: true, + }, common.mustCall()); +} diff --git a/test/js/node/test/sequential/test-cluster-port-reuse-between-workers.js b/test/js/node/test/sequential/test-cluster-port-reuse-between-workers.js new file mode 100644 index 000000000000..77d6902964e7 --- /dev/null +++ b/test/js/node/test/sequential/test-cluster-port-reuse-between-workers.js @@ -0,0 +1,93 @@ +'use strict'; + +const common = require('../common'); +const cluster = require('cluster'); +const assert = require('assert'); + +const acts = { + WORKER1_SERVER1_CLOSED: { cmd: 'WORKER1_SERVER1_CLOSED' }, + WORKER2_SERVER1_STARTED: { cmd: 'WORKER2_SERVER1_STARTED' }, + WORKER1_SERVER2_CLOSED: { cmd: 'WORKER1_SERVER2_CLOSED' }, +}; + +if (cluster.isMaster) { + const currentHost = '::'; + const worker1 = cluster.fork({ + WORKER_ID: 'worker1', + HOST: currentHost, + }); + let worker2; + worker1.on('error', common.mustNotCall()); + worker1.on('message', onMessage); + + function createWorker2() { + worker2 = cluster.fork({ + WORKER_ID: 'worker2', + HOST: currentHost, + }); + worker2.on('error', common.mustNotCall()); + worker2.on('message', onMessage); + } + + function onMessage(msg) { + switch (msg.cmd) { + case acts.WORKER1_SERVER1_CLOSED.cmd: + createWorker2(); + break; + case acts.WORKER2_SERVER1_STARTED.cmd: + worker1.send(acts.WORKER2_SERVER1_STARTED); + break; + case acts.WORKER1_SERVER2_CLOSED.cmd: + worker1.kill(); + worker2.kill(); + break; + default: + assert.fail(`Unexpected message ${msg.cmd}`); + } + } +} else { + const WORKER_ID = process.env.WORKER_ID; + function createServer() { + return new Promise((resolve, reject) => { + const net = require('net'); + const PORT = 8000; + const server = net + .createServer((socket) => { + socket.end( + `Handled by worker ${process.env.WORKER_ID} (${process.pid})\n` + ); + }) + .on('error', (e) => { + reject(e); + }); + + server.listen( + { + port: PORT, + host: process.env.HOST, + }, + () => resolve(server) + ); + }); + } + (async () => { + const server1 = await createServer(); + if (WORKER_ID === 'worker2') { + process.send(acts.WORKER2_SERVER1_STARTED); + } else { + await createServer().catch(common.mustCall()); + await new Promise((r) => server1.close(r)); + process.send(acts.WORKER1_SERVER1_CLOSED); + + process.on('message', async (msg) => { + if (msg.cmd === acts.WORKER2_SERVER1_STARTED.cmd) { + const server2 = await createServer(); + await new Promise((r) => server2.close(r)); + process.send(acts.WORKER1_SERVER2_CLOSED); + } else { + assert.fail(`Unexpected message ${msg.cmd}`); + } + }); + } + })().then(common.mustCall()); +} diff --git a/test/js/node/test/sequential/test-cluster-send-handle-large-payload.js b/test/js/node/test/sequential/test-cluster-send-handle-large-payload.js new file mode 100644 index 000000000000..81e4f797817f --- /dev/null +++ b/test/js/node/test/sequential/test-cluster-send-handle-large-payload.js @@ -0,0 +1,53 @@ +'use strict'; +const common = require('../common'); + +const assert = require('assert'); +const cluster = require('cluster'); +const net = require('net'); + +const payload = 'a'.repeat(800004); + +if (cluster.isPrimary) { + const server = net.createServer(); + + server.on('connection', common.mustCall((socket) => { socket.unref(); })); + + const worker = cluster.fork(); + worker.on('message', common.mustCall(({ payload: received }, handle) => { + assert.strictEqual(payload, received); + assert(handle instanceof net.Socket); + server.close(); + handle.destroy(); + })); + + server.listen(0, common.mustCall(() => { + const port = server.address().port; + const socket = new net.Socket(); + socket.connect(port, common.mustSucceed(() => { + worker.send({ payload }, socket); + })); + })); +} else { + process.on('message', common.mustCall(({ payload: received }, handle) => { + assert.strictEqual(payload, received); + assert(handle instanceof net.Socket); + + // On macOS, the primary process might not receive a message if it is sent + // to soon, and then subsequent messages are also sometimes not received. + // + // (Is this a bug or expected operating system behavior like the way a file + // watcher is returned before it's actually watching the file system on + // macOS?) + // + // Send a second message after a delay on macOS. + // + // Refs: https://github.com/nodejs/node/issues/14747 + if (common.isMacOS) + setTimeout(() => { process.send({ payload }, handle); }, 1000); + else + process.send({ payload }, handle); + + // Prepare for a clean exit. + process.channel.unref(); + })); +}