From 81072fc3ef688e4cb4c35822a6f8b42b37dff49b Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Mon, 10 Aug 2026 13:53:31 -0400 Subject: [PATCH] feat: add --socket-fd CLI option --- docs/FAQ.md | 39 ++++++++++++++++++++++++++++++++ src/node/app.ts | 15 +++++++++---- src/node/cli.ts | 5 +++++ test/unit/node/app.test.ts | 46 +++++++++++++++++++++++++++++++++++++- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/docs/FAQ.md b/docs/FAQ.md index 656b45978fe3..c492c73b2c59 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -41,6 +41,7 @@ - [How do I disable the proxy?](#how-do-i-disable-the-proxy) - [How do I disable file download?](#how-do-i-disable-file-download) - [Why do web views not work?](#why-do-web-views-not-work) +- [Can I run code-server with systemd socket activation?](#can-i-run-code-server-with-systemd-socket-activation) @@ -560,3 +561,41 @@ To fix this, you must either: create and trust a certificate manually). - Disable security if your browser allows it. For example, in Chromium see `chrome://flags/#unsafely-treat-insecure-origin-as-secure` + +## Can I run code-server with systemd socket activation? + +Yes. Pass the inherited socket to code-server with `--socket-fd`. systemd +passes the first listening socket as file descriptor `3`. + +Create a socket unit, `~/.config/systemd/user/code-server.socket`: + +```ini +[Socket] +ListenStream=8080 + +[Install] +WantedBy=sockets.target +``` + +And a matching service unit, `~/.config/systemd/user/code-server.service`: + +```ini +[Service] +ExecStart=/usr/bin/code-server --socket-fd 3 +``` + +Then enable and start the socket: + +```bash +systemctl --user enable --now code-server.socket +``` + +code-server will start on the first connection and listen on the socket +systemd created. `--socket-fd` takes precedence over `--socket` and +`--bind-addr`/`--port`/`--host`, and `--socket-mode` is ignored because +systemd owns the socket's permissions. + +Socket activation only changes how code-server binds; your usual +authentication still applies (it keeps prompting for the configured password +unless you set `--auth none`), so keep authentication enabled when exposing the +server. diff --git a/src/node/app.ts b/src/node/app.ts index 2043e3fd4bc0..82b1d2733f13 100644 --- a/src/node/app.ts +++ b/src/node/app.ts @@ -13,7 +13,8 @@ import { EditorSessionManager, makeEditorSessionManagerServer } from "./vscodeSo import { handleUpgrade } from "./wsRouter" type SocketOptions = { socket: string; "socket-mode"?: string } -type ListenOptions = DefaultedArgs | SocketOptions +type FdOptions = { "socket-fd": number } +type ListenOptions = DefaultedArgs | SocketOptions | FdOptions export interface App extends Disposable { /** Handles regular HTTP requests. */ @@ -30,8 +31,12 @@ const isSocketOpts = (opts: ListenOptions): opts is SocketOptions => { return !!(opts as SocketOptions).socket || !(opts as DefaultedArgs).host } +export const isFdOpts = (opts: ListenOptions): opts is FdOptions => { + return typeof (opts as FdOptions)["socket-fd"] === "number" +} + export const listen = async (server: http.Server, opts: ListenOptions) => { - if (isSocketOpts(opts)) { + if (!isFdOpts(opts) && isSocketOpts(opts)) { try { await fs.unlink(opts.socket) } catch (error: any) { @@ -46,7 +51,9 @@ export const listen = async (server: http.Server, opts: ListenOptions) => { server.on("error", (err) => util.logError(logger, "http server error", err)) resolve() } - if (isSocketOpts(opts)) { + if (isFdOpts(opts)) { + server.listen({ fd: opts["socket-fd"] }, onListen) + } else if (isSocketOpts(opts)) { server.listen(opts.socket, onListen) } else { // [] is the correct format when using :: but Node errors with them. @@ -56,7 +63,7 @@ export const listen = async (server: http.Server, opts: ListenOptions) => { // NOTE@jsjoeio: we need to chmod after the server is finished // listening. Otherwise, the socket may not have been created yet. - if (isSocketOpts(opts)) { + if (!isFdOpts(opts) && isSocketOpts(opts)) { if (opts["socket-mode"]) { await fs.chmod(opts.socket, opts["socket-mode"]) } diff --git a/src/node/cli.ts b/src/node/cli.ts index 0946c8e04344..3b27e29ba57b 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -83,6 +83,7 @@ export interface UserProvidedArgs extends UserProvidedCodeArgs { open?: boolean "bind-addr"?: string socket?: string + "socket-fd"?: number "socket-mode"?: string "trusted-origins"?: string[] version?: boolean @@ -235,6 +236,10 @@ export const options: Options> = { port: { type: "number", description: "" }, socket: { type: "string", path: true, description: "Path to a socket (bind-addr will be ignored)." }, + "socket-fd": { + type: "number", + description: "File descriptor of a pre-bound, listening socket to use (for systemd socket activation).", + }, "socket-mode": { type: "string", description: "File mode of the socket." }, "trusted-origins": { type: "string[]", diff --git a/test/unit/node/app.test.ts b/test/unit/node/app.test.ts index e56ed77bda20..54e245aec86c 100644 --- a/test/unit/node/app.test.ts +++ b/test/unit/node/app.test.ts @@ -3,7 +3,7 @@ import { promises } from "fs" import * as http from "http" import * as https from "https" import * as path from "path" -import { createApp, ensureAddress, handleArgsSocketCatchError, listen } from "../../../src/node/app" +import { createApp, ensureAddress, handleArgsSocketCatchError, isFdOpts, listen } from "../../../src/node/app" import { OptionalString, setDefaults } from "../../../src/node/cli" import { generateCertificate } from "../../../src/node/util" import { clean, mockLogger, getAvailablePort, tmpdir } from "../../utils/helpers" @@ -261,3 +261,47 @@ describe("listen", () => { } }) }) + +describe("listen (socket-fd)", () => { + // Wrap a bound-but-not-yet-listening TCP socket so we get a real file + // descriptor that listen({ fd }) can adopt, mirroring the systemd socket + // activation case where the process inherits an fd and calls listen(2) on it. + // Using a live net.Server's fd instead fails with EEXIST because the socket + // is already listening in-process. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { TCP, constants: TCPConstants } = (process as any).binding("tcp_wrap") + + let inherited: any + let httpServer: http.Server + let unlinkSpy: jest.SpyInstance + + beforeEach(async () => { + mockLogger() + unlinkSpy = jest.spyOn(promises, "unlink") + inherited = new TCP(TCPConstants.SERVER) + inherited.bind("127.0.0.1", 0) + httpServer = http.createServer() + }) + + afterEach(() => { + httpServer.close() + try { + inherited.close() + } catch { + // The fd is adopted by httpServer.close() above; ignore double-close. + } + jest.clearAllMocks() + }) + + it("isFdOpts detects a numeric socket-fd", () => { + expect(isFdOpts({ "socket-fd": 3 })).toBe(true) + expect(isFdOpts({ socket: "/tmp/x.sock" } as any)).toBe(false) + }) + + it("listens on an inherited fd without unlinking", async () => { + const fd = inherited.fd as number + await listen(httpServer, { "socket-fd": fd }) + expect(httpServer.address()).not.toBeNull() + expect(unlinkSpy).not.toHaveBeenCalled() + }) +})