Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<n>`. 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
Expand Down
7 changes: 6 additions & 1 deletion src/node/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,14 @@ export const createApp = async (args: DefaultedArgs): Promise<App> => {
*
* 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")
}
Expand Down
2 changes: 1 addition & 1 deletion src/node/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down
9 changes: 7 additions & 2 deletions src/node/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"],
})
}

Expand All @@ -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 })
}

/**
Expand Down
80 changes: 80 additions & 0 deletions test/integration/socketFd.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> => {
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<never>((_, reject) => {
child?.once("exit", (code) => reject(new Error(`code-server exited with code ${code}:\n${output}`)))
})
const status = new Promise<number>((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<void>((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<void>((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)
})
3 changes: 3 additions & 0 deletions test/unit/node/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading