From 3b8e41b719591f03c36e6f7dffb5275831cc2e8e Mon Sep 17 00:00:00 2001 From: Andy Piper Date: Tue, 27 Oct 2026 20:02:48 -0400 Subject: [PATCH] Fix --socket-fd when code-server is socket-activated The --socket-fd flag never worked. The wrapper forks the process that listens, but only the IPC channel was inherited as fd 3, so the socket passed by the caller was not available to the child. Node then tried to listen on the IPC pipe and failed with "listen EEXIST". Pass the socket to the child as stdio entry 4 and tell the child to use that fd in the handshake. Node also reports no address for a Unix socket adopted by fd, which made startup fail with "Server has no address". Log the listener as fd: in that case. Add an integration test that starts the code-server binary with a Unix or TCP socket as fd 3 and checks that /healthz answers over it. --- CHANGELOG.md | 6 +++ src/node/app.ts | 7 ++- src/node/main.ts | 2 +- src/node/wrapper.ts | 9 +++- test/integration/socketFd.test.ts | 80 +++++++++++++++++++++++++++++++ test/unit/node/app.test.ts | 3 ++ 6 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 test/integration/socketFd.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ba693eb6d5b..90dd8ae840d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ Code v99.99.999 ## Unreleased +### Fixed + +- `--socket-fd` now works: the inherited socket is passed on to the process + that listens, and the address is logged as `fd:`. Previously code-server + failed with `listen EEXIST` or `Server has no address`. + ## [4.143.1](https://github.com/coder/code-server/releases/tag/v4.143.1) - 2026-10-23 Code v1.143.1 diff --git a/src/node/app.ts b/src/node/app.ts index 82b1d2733f13..e995c7c21c0f 100644 --- a/src/node/app.ts +++ b/src/node/app.ts @@ -111,9 +111,14 @@ export const createApp = async (args: DefaultedArgs): Promise => { * * The address might be a URL or it might be a pipe or socket path. */ -export const ensureAddress = (server: http.Server, protocol: string): URL | string => { +export const ensureAddress = (server: http.Server, protocol: string, socketFd?: number): URL | string => { const addr = server.address() + // Node reports no address for a Unix socket adopted by fd. + if (!addr && typeof socketFd === "number") { + return `fd:${socketFd}` + } + if (!addr) { throw new Error("Server has no address") } diff --git a/src/node/main.ts b/src/node/main.ts index c2d3bd57852b..7fcc18b1aad5 100644 --- a/src/node/main.ts +++ b/src/node/main.ts @@ -141,7 +141,7 @@ export const runCodeServer = async ( const app = await createApp(args) const protocol = args.cert ? "https" : "http" - const serverAddress = ensureAddress(app.server, protocol) + const serverAddress = ensureAddress(app.server, protocol, args["socket-fd"]) const { disposeRoutes, heart } = await register(app, args) logger.info(`Using config file ${args.config}`) diff --git a/src/node/wrapper.ts b/src/node/wrapper.ts index 607512e2c9fb..7b0ccb3b9f24 100644 --- a/src/node/wrapper.ts +++ b/src/node/wrapper.ts @@ -8,6 +8,9 @@ import { paths } from "./util" const timeoutInterval = 10000 // 10s, matches VS Code's timeouts. +// Fd 3 in the child is the IPC channel, so the inherited socket goes to fd 4. +const CHILD_SOCKET_FD = 4 + /** * Listen to a single message from a process. Reject if the process errors, * exits, or times out. @@ -334,13 +337,14 @@ export class ParentProcess extends Process { } private spawn(): cp.ChildProcess { + const socketFd = this.args?.["socket-fd"] return cp.fork(path.join(__dirname, "entry"), { env: { ...process.env, CODE_SERVER_PARENT_PID: process.pid.toString(), NODE_EXEC_PATH: process.execPath, }, - stdio: ["pipe", "pipe", "pipe", "ipc"], + stdio: typeof socketFd === "number" ? ["pipe", "pipe", "pipe", "ipc", socketFd] : ["pipe", "pipe", "pipe", "ipc"], }) } @@ -359,7 +363,8 @@ export class ParentProcess extends Process { this.logger, ) this.logger.debug("got message", field("message", message)) - this.send(child, { type: "handshake", args: this.args }) + const args = typeof this.args["socket-fd"] === "number" ? { ...this.args, "socket-fd": CHILD_SOCKET_FD } : this.args + this.send(child, { type: "handshake", args }) } /** diff --git a/test/integration/socketFd.test.ts b/test/integration/socketFd.test.ts new file mode 100644 index 000000000000..49edfc691485 --- /dev/null +++ b/test/integration/socketFd.test.ts @@ -0,0 +1,80 @@ +import { ChildProcess, spawn } from "child_process" +import { promises as fs } from "fs" +import * as http from "http" +import * as net from "net" +import * as path from "path" +import { clean, tmpdir } from "../utils/helpers" + +describe("--socket-fd", () => { + const testName = "socketFd" + const codeServerPath = process.env.CODE_SERVER_PATH || path.resolve("../../release-standalone/bin/code-server") + let tempDir: string + let socketDir: string | undefined + let child: ChildProcess | undefined + + beforeEach(async () => { + await clean(testName) + tempDir = await tmpdir(testName) + }) + + afterEach(async () => { + if (child?.pid && child.exitCode === null) { + // Kill the process group so that the wrapper's inner process also stops. + process.kill(-child.pid, "SIGKILL") + } + if (socketDir) { + await fs.rm(socketDir, { force: true, recursive: true }) + socketDir = undefined + } + }) + + // Give the listener to code-server as fd 3, as systemd socket activation does. + const getHealthzThroughFd = async (server: net.Server, target: http.RequestOptions): Promise => { + const fd: number = (server as any)._handle.fd + let output = "" + child = spawn( + codeServerPath, + [ + "--socket-fd=3", + "--auth=none", + `--user-data-dir=${path.join(tempDir, "data")}`, + `--config=${path.join(tempDir, "config.yaml")}`, + ], + { detached: true, stdio: ["ignore", "pipe", "pipe", fd] }, + ) + child.stdout?.on("data", (chunk) => (output += chunk)) + child.stderr?.on("data", (chunk) => (output += chunk)) + await new Promise((resolve) => server.close(resolve)) + + const exited = new Promise((_, reject) => { + child?.once("exit", (code) => reject(new Error(`code-server exited with code ${code}:\n${output}`))) + }) + const status = new Promise((resolve, reject) => { + http + .get({ ...target, path: "/healthz" }, (res) => { + res.resume() + resolve(res.statusCode ?? 0) + }) + .once("error", (error) => reject(new Error(`${error.message}:\n${output}`))) + }) + return Promise.race([status, exited]) + } + + it("should serve requests on a Unix socket passed as a file descriptor", async () => { + // macOS limits socket paths to 104 bytes, and os.tmpdir() can be too long. + socketDir = await fs.mkdtemp("/tmp/cs-fd-") + const server = net.createServer() + await new Promise((resolve) => server.listen(path.join(socketDir!, "listen.sock"), resolve)) + // server.close() unlinks the socket path, so connect through a hard link. + const socketPath = path.join(socketDir, "connect.sock") + await fs.link(path.join(socketDir, "listen.sock"), socketPath) + expect(await getHealthzThroughFd(server, { socketPath })).toBe(200) + }, 60000) + + it("should serve requests on a TCP socket passed as a file descriptor", async () => { + const server = net.createServer() + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const { port } = server.address() as net.AddressInfo + expect(await getHealthzThroughFd(server, { host: "127.0.0.1", port })).toBe(200) + }, 60000) +}) diff --git a/test/unit/node/app.test.ts b/test/unit/node/app.test.ts index 54e245aec86c..618771842a4a 100644 --- a/test/unit/node/app.test.ts +++ b/test/unit/node/app.test.ts @@ -152,6 +152,9 @@ describe("ensureAddress", () => { it("should throw and error if no address", () => { expect(() => ensureAddress(mockServer, "http")).toThrow("Server has no address") }) + it("should describe a --socket-fd listener by its fd when node reports no address", () => { + expect(ensureAddress(mockServer, "http", 4)).toBe("fd:4") + }) it("should return the address if it's a string", async () => { mockServer.address = () => "/path/to/unix.sock" const address = ensureAddress(mockServer, "http")