Skip to content

Commit 2780846

Browse files
authored
Fix --socket-fd when code-server is socket-activated (#8083)
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:<n> 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.
1 parent 70fc911 commit 2780846

6 files changed

Lines changed: 103 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ Code v99.99.999
2222

2323
## Unreleased
2424

25+
### Fixed
26+
27+
- `--socket-fd` now works: the inherited socket is passed on to the process
28+
that listens, and the address is logged as `fd:<n>`. Previously code-server
29+
failed with `listen EEXIST` or `Server has no address`.
30+
2531
## [4.143.1](https://github.com/coder/code-server/releases/tag/v4.143.1) - 2026-10-23
2632

2733
Code v1.143.1

‎src/node/app.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,14 @@ export const createApp = async (args: DefaultedArgs): Promise<App> => {
111111
*
112112
* The address might be a URL or it might be a pipe or socket path.
113113
*/
114-
export const ensureAddress = (server: http.Server, protocol: string): URL | string => {
114+
export const ensureAddress = (server: http.Server, protocol: string, socketFd?: number): URL | string => {
115115
const addr = server.address()
116116

117+
// Node reports no address for a Unix socket adopted by fd.
118+
if (!addr && typeof socketFd === "number") {
119+
return `fd:${socketFd}`
120+
}
121+
117122
if (!addr) {
118123
throw new Error("Server has no address")
119124
}

‎src/node/main.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export const runCodeServer = async (
141141

142142
const app = await createApp(args)
143143
const protocol = args.cert ? "https" : "http"
144-
const serverAddress = ensureAddress(app.server, protocol)
144+
const serverAddress = ensureAddress(app.server, protocol, args["socket-fd"])
145145
const { disposeRoutes, heart } = await register(app, args)
146146

147147
logger.info(`Using config file ${args.config}`)

‎src/node/wrapper.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import { paths } from "./util"
88

99
const timeoutInterval = 10000 // 10s, matches VS Code's timeouts.
1010

11+
// Fd 3 in the child is the IPC channel, so the inherited socket goes to fd 4.
12+
const CHILD_SOCKET_FD = 4
13+
1114
/**
1215
* Listen to a single message from a process. Reject if the process errors,
1316
* exits, or times out.
@@ -334,13 +337,14 @@ export class ParentProcess extends Process {
334337
}
335338

336339
private spawn(): cp.ChildProcess {
340+
const socketFd = this.args?.["socket-fd"]
337341
return cp.fork(path.join(__dirname, "entry"), {
338342
env: {
339343
...process.env,
340344
CODE_SERVER_PARENT_PID: process.pid.toString(),
341345
NODE_EXEC_PATH: process.execPath,
342346
},
343-
stdio: ["pipe", "pipe", "pipe", "ipc"],
347+
stdio: typeof socketFd === "number" ? ["pipe", "pipe", "pipe", "ipc", socketFd] : ["pipe", "pipe", "pipe", "ipc"],
344348
})
345349
}
346350

@@ -359,7 +363,8 @@ export class ParentProcess extends Process {
359363
this.logger,
360364
)
361365
this.logger.debug("got message", field("message", message))
362-
this.send(child, { type: "handshake", args: this.args })
366+
const args = typeof this.args["socket-fd"] === "number" ? { ...this.args, "socket-fd": CHILD_SOCKET_FD } : this.args
367+
this.send(child, { type: "handshake", args })
363368
}
364369

365370
/**

‎test/integration/socketFd.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { ChildProcess, spawn } from "child_process"
2+
import { promises as fs } from "fs"
3+
import * as http from "http"
4+
import * as net from "net"
5+
import * as path from "path"
6+
import { clean, tmpdir } from "../utils/helpers"
7+
8+
describe("--socket-fd", () => {
9+
const testName = "socketFd"
10+
const codeServerPath = process.env.CODE_SERVER_PATH || path.resolve("../../release-standalone/bin/code-server")
11+
let tempDir: string
12+
let socketDir: string | undefined
13+
let child: ChildProcess | undefined
14+
15+
beforeEach(async () => {
16+
await clean(testName)
17+
tempDir = await tmpdir(testName)
18+
})
19+
20+
afterEach(async () => {
21+
if (child?.pid && child.exitCode === null) {
22+
// Kill the process group so that the wrapper's inner process also stops.
23+
process.kill(-child.pid, "SIGKILL")
24+
}
25+
if (socketDir) {
26+
await fs.rm(socketDir, { force: true, recursive: true })
27+
socketDir = undefined
28+
}
29+
})
30+
31+
// Give the listener to code-server as fd 3, as systemd socket activation does.
32+
const getHealthzThroughFd = async (server: net.Server, target: http.RequestOptions): Promise<number> => {
33+
const fd: number = (server as any)._handle.fd
34+
let output = ""
35+
child = spawn(
36+
codeServerPath,
37+
[
38+
"--socket-fd=3",
39+
"--auth=none",
40+
`--user-data-dir=${path.join(tempDir, "data")}`,
41+
`--config=${path.join(tempDir, "config.yaml")}`,
42+
],
43+
{ detached: true, stdio: ["ignore", "pipe", "pipe", fd] },
44+
)
45+
child.stdout?.on("data", (chunk) => (output += chunk))
46+
child.stderr?.on("data", (chunk) => (output += chunk))
47+
await new Promise((resolve) => server.close(resolve))
48+
49+
const exited = new Promise<never>((_, reject) => {
50+
child?.once("exit", (code) => reject(new Error(`code-server exited with code ${code}:\n${output}`)))
51+
})
52+
const status = new Promise<number>((resolve, reject) => {
53+
http
54+
.get({ ...target, path: "/healthz" }, (res) => {
55+
res.resume()
56+
resolve(res.statusCode ?? 0)
57+
})
58+
.once("error", (error) => reject(new Error(`${error.message}:\n${output}`)))
59+
})
60+
return Promise.race([status, exited])
61+
}
62+
63+
it("should serve requests on a Unix socket passed as a file descriptor", async () => {
64+
// macOS limits socket paths to 104 bytes, and os.tmpdir() can be too long.
65+
socketDir = await fs.mkdtemp("/tmp/cs-fd-")
66+
const server = net.createServer()
67+
await new Promise<void>((resolve) => server.listen(path.join(socketDir!, "listen.sock"), resolve))
68+
// server.close() unlinks the socket path, so connect through a hard link.
69+
const socketPath = path.join(socketDir, "connect.sock")
70+
await fs.link(path.join(socketDir, "listen.sock"), socketPath)
71+
expect(await getHealthzThroughFd(server, { socketPath })).toBe(200)
72+
}, 60000)
73+
74+
it("should serve requests on a TCP socket passed as a file descriptor", async () => {
75+
const server = net.createServer()
76+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
77+
const { port } = server.address() as net.AddressInfo
78+
expect(await getHealthzThroughFd(server, { host: "127.0.0.1", port })).toBe(200)
79+
}, 60000)
80+
})

‎test/unit/node/app.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ describe("ensureAddress", () => {
152152
it("should throw and error if no address", () => {
153153
expect(() => ensureAddress(mockServer, "http")).toThrow("Server has no address")
154154
})
155+
it("should describe a --socket-fd listener by its fd when node reports no address", () => {
156+
expect(ensureAddress(mockServer, "http", 4)).toBe("fd:4")
157+
})
155158
it("should return the address if it's a string", async () => {
156159
mockServer.address = () => "/path/to/unix.sock"
157160
const address = ensureAddress(mockServer, "http")

0 commit comments

Comments
 (0)