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")