|
1 | 1 | import { expect, test } from "bun:test"; |
2 | | -import { bunEnv, bunExe, bunRun, isIPv6, isWindows, joinP, tempDirWithFiles } from "harness"; |
| 2 | +import { bunEnv, bunExe, bunRun, isIPv6, isWindows, joinP, tempDirWithFiles, tls as tlsCerts } from "harness"; |
3 | 3 |
|
4 | 4 | test("cloneable and transferable equals", () => { |
5 | 5 | const dir = tempDirWithFiles("bun-test", { |
@@ -556,3 +556,223 @@ test.each(["net", "http"])("cluster 'listening' reports the address a %s server |
556 | 556 | if (!("path" in target)) expect(payloads[i].port).toBeWithin(1, 65536); |
557 | 557 | } |
558 | 558 | }); |
| 559 | + |
| 560 | +test("round-robin worker connection socket has connecting=false and remoteAddress synchronously", () => { |
| 561 | + const dir = tempDirWithFiles("bun-test", { |
| 562 | + "main.ts": ` |
| 563 | +const cluster = require("node:cluster"); |
| 564 | +const net = require("node:net"); |
| 565 | +
|
| 566 | +if (cluster.isPrimary) { |
| 567 | + const worker = cluster.fork(); |
| 568 | + worker.on("message", m => { |
| 569 | + console.log(JSON.stringify(m)); |
| 570 | + worker.kill(); |
| 571 | + process.exit(0); |
| 572 | + }); |
| 573 | + cluster.on("listening", (w, address) => { |
| 574 | + net.connect(address.port, "127.0.0.1").on("error", () => {}); |
| 575 | + }); |
| 576 | +} else { |
| 577 | + net |
| 578 | + .createServer(socket => { |
| 579 | + // Captured synchronously in the connection listener: node's onconnection |
| 580 | + // delivers accepted sockets already open, not connecting. |
| 581 | + process.send({ |
| 582 | + connecting: socket.connecting, |
| 583 | + readyState: socket.readyState, |
| 584 | + remote: typeof socket.remoteAddress, |
| 585 | + }); |
| 586 | + socket.end(); |
| 587 | + }) |
| 588 | + .listen(0, "127.0.0.1"); |
| 589 | +} |
| 590 | +`, |
| 591 | + }); |
| 592 | + const { stdout } = bunRun(joinP(dir, "main.ts"), bunEnv); |
| 593 | + const m = JSON.parse(stdout.trim()); |
| 594 | + expect(m.connecting).toBe(false); |
| 595 | + expect(m.readyState).toBe("open"); |
| 596 | + expect(m.remote).toBe("string"); |
| 597 | +}); |
| 598 | + |
| 599 | +test("round-robin: primary never consumes accepted-socket bytes before handoff", () => { |
| 600 | + const dir = tempDirWithFiles("bun-test", { |
| 601 | + "main.ts": ` |
| 602 | +const cluster = require("node:cluster"); |
| 603 | +const net = require("node:net"); |
| 604 | +
|
| 605 | +const N = 20; |
| 606 | +if (cluster.isPrimary) { |
| 607 | + const worker = cluster.fork(); |
| 608 | + let got = 0; |
| 609 | + worker.on("message", m => { |
| 610 | + console.log(m); |
| 611 | + if (++got === N) { |
| 612 | + worker.kill(); |
| 613 | + process.exit(0); |
| 614 | + } |
| 615 | + }); |
| 616 | + cluster.on("listening", (w, address) => { |
| 617 | + for (let i = 0; i < N; i++) { |
| 618 | + const c = net.connect(address.port, "127.0.0.1", () => { |
| 619 | + // Write immediately on connect: with pauseOnConnect at the primary |
| 620 | + // accept, byte 0 must reach the worker, not the primary's Duplex. |
| 621 | + c.write("MAGIC-" + i + "-" + "x".repeat(4096)); |
| 622 | + c.end(); |
| 623 | + }); |
| 624 | + c.on("error", () => {}); |
| 625 | + } |
| 626 | + }); |
| 627 | +} else { |
| 628 | + net |
| 629 | + .createServer(sock => { |
| 630 | + let buf = ""; |
| 631 | + sock.on("data", d => (buf += d)); |
| 632 | + sock.on("end", () => process.send(buf.slice(0, 20) + " " + buf.length)); |
| 633 | + }) |
| 634 | + .listen(0, "127.0.0.1"); |
| 635 | +} |
| 636 | +`, |
| 637 | + }); |
| 638 | + const { stdout } = bunRun(joinP(dir, "main.ts"), bunEnv); |
| 639 | + const lines = stdout.trim().split("\n").sort(); |
| 640 | + expect(lines.length).toBe(20); |
| 641 | + for (const line of lines) { |
| 642 | + expect(line).toMatch(/^MAGIC-\d+-x+ 41\d\d$/); |
| 643 | + } |
| 644 | +}); |
| 645 | + |
| 646 | +test("TLS cluster worker under SCHED_RR listens on a shared handle and completes handshakes", () => { |
| 647 | + // Two forked workers + a TLS handshake in a debug build is well over the |
| 648 | + // default 5s test budget. |
| 649 | + const dir = tempDirWithFiles("bun-test", { |
| 650 | + "cert.pem": tlsCerts.cert, |
| 651 | + "key.pem": tlsCerts.key, |
| 652 | + "main.ts": ` |
| 653 | +const cluster = require("node:cluster"); |
| 654 | +const tls = require("node:tls"); |
| 655 | +const fs = require("node:fs"); |
| 656 | +const path = require("node:path"); |
| 657 | +const key = fs.readFileSync(path.join(__dirname, "key.pem")); |
| 658 | +const cert = fs.readFileSync(path.join(__dirname, "cert.pem")); |
| 659 | +
|
| 660 | +if (cluster.isPrimary) { |
| 661 | + const w1 = cluster.fork(); |
| 662 | + const w2 = cluster.fork(); |
| 663 | + const ports = new Set(); |
| 664 | + let listening = 0; |
| 665 | + cluster.on("listening", (w, address) => { |
| 666 | + ports.add(address.port); |
| 667 | + if (++listening !== 2) return; |
| 668 | + // Both workers must share the primary-bound port under SCHED_RR TLS. |
| 669 | + console.log("distinct ports:", ports.size); |
| 670 | + const port = address.port; |
| 671 | + const c = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }, () => { |
| 672 | + c.write("hi"); |
| 673 | + }); |
| 674 | + c.setEncoding("utf8"); |
| 675 | + c.on("data", d => { |
| 676 | + console.log("reply:", d); |
| 677 | + c.end(); |
| 678 | + w1.kill(); |
| 679 | + w2.kill(); |
| 680 | + process.exit(0); |
| 681 | + }); |
| 682 | + c.on("error", e => { |
| 683 | + console.log("client error:", e.code); |
| 684 | + process.exit(1); |
| 685 | + }); |
| 686 | + }); |
| 687 | +} else { |
| 688 | + tls |
| 689 | + .createServer({ key, cert }, socket => { |
| 690 | + socket.on("data", d => socket.end("echo:" + d)); |
| 691 | + }) |
| 692 | + .listen(0); |
| 693 | +} |
| 694 | +`, |
| 695 | + }); |
| 696 | + const { stdout } = bunRun(joinP(dir, "main.ts"), bunEnv); |
| 697 | + expect(stdout).toContain("distinct ports: 1"); |
| 698 | + expect(stdout).toContain("reply: echo:hi"); |
| 699 | +}, 30_000); |
| 700 | + |
| 701 | +test("plain worker listening on a key already owned by a TLS shared-only handle fails with EINVAL", () => { |
| 702 | + const dir = tempDirWithFiles("bun-test", { |
| 703 | + "cert.pem": tlsCerts.cert, |
| 704 | + "key.pem": tlsCerts.key, |
| 705 | + "main.ts": ` |
| 706 | +const cluster = require("node:cluster"); |
| 707 | +const net = require("node:net"); |
| 708 | +const tls = require("node:tls"); |
| 709 | +const fs = require("node:fs"); |
| 710 | +const path = require("node:path"); |
| 711 | +const key = fs.readFileSync(path.join(__dirname, "key.pem")); |
| 712 | +const cert = fs.readFileSync(path.join(__dirname, "cert.pem")); |
| 713 | +
|
| 714 | +if (cluster.isPrimary) { |
| 715 | + // Reverse of the existing test: TLS worker claims first, plain worker second. |
| 716 | + const tlsWorker = cluster.fork({ ROLE: "tls" }); |
| 717 | + cluster.once("listening", () => { |
| 718 | + const netWorker = cluster.fork({ ROLE: "net" }); |
| 719 | + netWorker.on("message", msg => { |
| 720 | + console.log("net listen error code:", msg.code); |
| 721 | + tlsWorker.kill(); |
| 722 | + netWorker.kill(); |
| 723 | + process.exit(0); |
| 724 | + }); |
| 725 | + }); |
| 726 | +} else if (process.env.ROLE === "tls") { |
| 727 | + tls.createServer({ key, cert }, () => {}).listen(0); |
| 728 | +} else { |
| 729 | + const server = net.createServer(() => {}); |
| 730 | + server.on("error", err => process.send({ code: err.code })); |
| 731 | + server.listen(0); |
| 732 | +} |
| 733 | +`, |
| 734 | + }); |
| 735 | + const { stdout } = bunRun(joinP(dir, "main.ts"), bunEnv); |
| 736 | + expect(stdout).toContain("net listen error code: EINVAL"); |
| 737 | +}, 30_000); |
| 738 | + |
| 739 | +test.skipIf(isWindows)("SCHED_NONE listen({fd:2}) fails ENOTSOCK and does not close the primary's stderr", () => { |
| 740 | + const dir = tempDirWithFiles("bun-test", { |
| 741 | + "main.ts": ` |
| 742 | +const cluster = require("node:cluster"); |
| 743 | +const net = require("node:net"); |
| 744 | +const fs = require("node:fs"); |
| 745 | +
|
| 746 | +cluster.schedulingPolicy = cluster.SCHED_NONE; |
| 747 | +
|
| 748 | +if (cluster.isPrimary) { |
| 749 | + const worker = cluster.fork(); |
| 750 | + worker.on("message", m => { |
| 751 | + console.log("worker error code:", m.code); |
| 752 | + worker.disconnect(); |
| 753 | + }); |
| 754 | + cluster.on("exit", () => { |
| 755 | + // stderr fd must still be a valid open fd in the primary. |
| 756 | + try { |
| 757 | + fs.fstatSync(2); |
| 758 | + console.log("stderr open: true"); |
| 759 | + } catch (e) { |
| 760 | + console.log("stderr open: false"); |
| 761 | + } |
| 762 | + process.exit(0); |
| 763 | + }); |
| 764 | +} else { |
| 765 | + const server = net.createServer(() => {}); |
| 766 | + server.on("error", err => { |
| 767 | + process.send({ code: err.code }); |
| 768 | + }); |
| 769 | + server.listen({ fd: 2 }); |
| 770 | +} |
| 771 | +`, |
| 772 | + }); |
| 773 | + const { stdout } = bunRun(joinP(dir, "main.ts"), bunEnv); |
| 774 | + // ENOTSOCK when the primary's fd 2 is a pipe/tty; some paths surface EINVAL. |
| 775 | + // The load-bearing invariant is that the primary's stderr survives remove(). |
| 776 | + expect(stdout).toMatch(/worker error code: (ENOTSOCK|EINVAL|EBADF)/); |
| 777 | + expect(stdout).toContain("stderr open: true"); |
| 778 | +}); |
0 commit comments