Compare commits

...

2 Commits

Author SHA1 Message Date
Andy Piper
27808460b1 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.
2026-10-28 15:11:06 -08:00
Harsh Bhanushali
70fc911358 Override basic-ftp to ^6.2.1 (#8086)
get-uri still depends on basic-ftp ^5.x, which has no patched release.
2026-10-28 14:25:45 -08:00
8 changed files with 109 additions and 7 deletions

View File

@@ -22,6 +22,12 @@ Code v99.99.999
## Unreleased ## 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 ## [4.143.1](https://github.com/coder/code-server/releases/tag/v4.143.1) - 2026-10-23
Code v1.143.1 Code v1.143.1

6
package-lock.json generated
View File

@@ -1726,9 +1726,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/basic-ftp": { "node_modules/basic-ftp": {
"version": "5.3.1", "version": "6.2.3",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.3.tgz",
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", "integrity": "sha512-T7GYtQ2eKJUN5YORG7A8u2WfB9JMN0a4/wl3MI64qq2qTRfa3FXzoNq0DDojZDkDyStcL+TkfKsz4JHEZx5kGQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"

View File

@@ -90,6 +90,9 @@
"resolutions": { "resolutions": {
"@types/node": "24.x" "@types/node": "24.x"
}, },
"overrides": {
"basic-ftp": "^6.2.1"
},
"bin": { "bin": {
"code-server": "out/node/entry.js" "code-server": "out/node/entry.js"
}, },

View File

@@ -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. * 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() 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) { if (!addr) {
throw new Error("Server has no address") throw new Error("Server has no address")
} }

View File

@@ -141,7 +141,7 @@ export const runCodeServer = async (
const app = await createApp(args) const app = await createApp(args)
const protocol = args.cert ? "https" : "http" 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) const { disposeRoutes, heart } = await register(app, args)
logger.info(`Using config file ${args.config}`) logger.info(`Using config file ${args.config}`)

View File

@@ -8,6 +8,9 @@ import { paths } from "./util"
const timeoutInterval = 10000 // 10s, matches VS Code's timeouts. 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, * Listen to a single message from a process. Reject if the process errors,
* exits, or times out. * exits, or times out.
@@ -334,13 +337,14 @@ export class ParentProcess extends Process {
} }
private spawn(): cp.ChildProcess { private spawn(): cp.ChildProcess {
const socketFd = this.args?.["socket-fd"]
return cp.fork(path.join(__dirname, "entry"), { return cp.fork(path.join(__dirname, "entry"), {
env: { env: {
...process.env, ...process.env,
CODE_SERVER_PARENT_PID: process.pid.toString(), CODE_SERVER_PARENT_PID: process.pid.toString(),
NODE_EXEC_PATH: process.execPath, 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,
) )
this.logger.debug("got message", field("message", message)) 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 })
} }
/** /**

View File

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

View File

@@ -152,6 +152,9 @@ describe("ensureAddress", () => {
it("should throw and error if no address", () => { it("should throw and error if no address", () => {
expect(() => ensureAddress(mockServer, "http")).toThrow("Server has 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 () => { it("should return the address if it's a string", async () => {
mockServer.address = () => "/path/to/unix.sock" mockServer.address = () => "/path/to/unix.sock"
const address = ensureAddress(mockServer, "http") const address = ensureAddress(mockServer, "http")