Backend Development 9 min read

Unlocking Node.js Child Process Communication with NODE_CHANNEL_FD

This article explains how Node.js uses the NODE_CHANNEL_FD environment variable to establish a socketpair for IPC between a parent process and its child, detailing the spawn implementation, underlying libuv handling, and how a Go subprocess can read and write through the shared file descriptor.

Taobao Frontend Technology
Taobao Frontend Technology
Taobao Frontend Technology
Unlocking Node.js Child Process Communication with NODE_CHANNEL_FD

Node.js Communication with Child Processes

In the official Node.js documentation a description mentions that a child process can obtain a file descriptor for communication with its parent via the

NODE_CHANNEL_FD

environment variable. This article explores where

NODE_CHANNEL_FD

comes from and how to use it.

First, the

child_process.spawn

method is used to start a subprocess. The following example launches a Go program:

<code>const { spawn } = require('child_process');
const { join } = require('path');
const childProcess = spawn('go', ['run', 'main.go'], {
    stdio: [0, 1, 2, 'ipc']
});
</code>

In the

stdio

array the string

ipc

signals that an inter‑process communication channel should be created. Node.js processes this option internally, converting it into a pipe and assigning a file descriptor index (

ipcFd

) that later becomes

NODE_CHANNEL_FD

:

<code>// https://github.com/nodejs/node/blob/7b1e15353062feaa3f29f4fe53e11a1bc644e63c/lib/internal/child_process.js#L1025-L1043
stdio = ArrayPrototypeReduce(stdio, (acc, stdio, i) => {
    if (stdio === 'ignore') {
        // ignore
    } else if (stdio === 'ipc') {
        ipc = new Pipe(PipeConstants.IPC);
        ipcFd = i;
        ArrayPrototypePush(acc, {
            type: 'pipe',
            handle: ipc,
            ipc: true
        });
    } else if (stdio === 'inherit') {
        // ignore
    }
    return acc;
}, []);
</code>

The index of the

ipc

entry (in this case

3

) becomes the first file descriptor passed to the child process. The low‑level libuv implementation creates a socketpair, marks the descriptor with

FD_CLOEXEC

where appropriate, and then forks the child:

<code>// https://github.com/nodejs/node/blob/f313b39d8f282f16d36fe99372e919c37864721c/deps/uv/src/unix/process.c#L789
static int uv__spawn_and_init_child_fork(const uv_process_options_t* options,
                                         int stdio_count,
                                         int (*pipes)[2],
                                         int error_fd,
                                         pid_t* pid) {
    *pid = fork();
    if (*pid == 0) {
        uv__process_child_init(options, stdio_count, pipes, error_fd);
        abort();
    }
    if (pthread_sigmask(SIG_SETMASK, &sigoldset, NULL) != 0)
        abort();
    if (*pid == -1)
        return UV__ERR(errno);
    return 0;
}
</code>

During the child‑process initialization the file descriptor corresponding to the socketpair is duplicated onto descriptor

3

(the value of

NODE_CHANNEL_FD

) so that the child can read and write through it:

<code>// stdio_count is 4, corresponding to [0,1,2,'ipc']
for (fd = 0; fd < stdio_count; fd++) {
    close_fd = -1;
    // when fd == 3, use_fd is the socketpair descriptor, e.g., 24
    use_fd = pipes[fd][2];
    if (fd == use_fd) {
        // already correct
    } else {
        fd = dup2(use_fd, fd);
    }
    // ...
}
</code>

Because the socket descriptor is not marked

FD_CLOEXEC

, it remains open after

execvp

runs the target program, allowing the child (in this case a Go process) to communicate with the Node.js parent.

Golang Process Communicating with a Node.js Parent

In Go the environment variable

NODE_CHANNEL_FD

provides the numeric file descriptor of the socket. The following code obtains a

*os.File

for that descriptor:

<code>nodeChannelFD := os.Getenv(NODE_CHANNEL_FD)
nodeChannelFDInt, _ := strconv.Atoi(nodeChannelFD)
fd := os.NewFile(uintptr(int(nodeChannelFDInt)), "lbipc"+nodeChannelFD)
</code>

Using the

syscall

package, Go can send and receive messages over the socket with

Sendmsg

and

Recvmsg

:

<code>// Sending data
type Message struct {
    Id      string `json:"id"`
    MsgType string `json:"type"`
    Data    string `json:"data"`
}

fdHandler := int(fd.Fd())
responseMsg := Message{Id: "id:1", Data: "hello world", MsgType: "test"}
jsonData, _ := json.Marshal(responseMsg)
syscall.Sendmsg(fdHandler, append(jsonData, '\n'), nil, nil, 0)
</code>
<code>// Receiving data
fdHandler := int(fd.Fd())
syscall.Recvmsg(fdHandler, dataBuf, attachedDataBuf, 0)
</code>

The complete implementation can be found in the midwayjs/lb repository.

backend developmentGoNode.jsIPCchild_process
Taobao Frontend Technology
Written by

Taobao Frontend Technology

The frontend landscape is constantly evolving, with rapid innovations across familiar languages. Like us, your understanding of the frontend is continually refreshed. Join us on Taobao, a vibrant, all‑encompassing platform, to uncover limitless potential.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.