From 863344805520569a158795bccb978ee0644a5f3e Mon Sep 17 00:00:00 2001 From: Den Kong Date: Sat, 5 Sep 2026 16:55:43 +0800 Subject: [PATCH] Default the session socket to a named pipe on windows The session socket defaults to a path inside the user data directory, which windows cannot bind: it has no Unix sockets. The bind failure is caught and warned about, and the server is returned anyway, so startup prints warn Could not create socket at ...\code-server-ipc.sock info Session server listening on ...\code-server-ipc.sock about a socket that does not exist, and nothing listens on it. That socket is what shouldOpenInExistingInstance consults, so on windows `code-server ` run from a shell can never find a running instance and starts its own server instead. Windows has the same facility under a different name, so this defaults to a named pipe there. The name is derived from the user data directory, so instances with separate data directories do not collide and a later invocation with the same one finds the first, and it is case-folded first because windows compares paths that way and the same directory typed two ways should not produce two pipes. An explicitly passed --session-socket is untouched on every platform. Nothing downstream needed changing: the paths are only ever handed to net.connect and http.request, both of which take a pipe name on windows, and listen()'s unlink-first only has to tolerate ENOENT, which it already does. Measured against a real windows build, one flag apart: --session-socket '\.\pipe\...' no warning; "Session server listening on" that pipe; canConnect true; GET /session answers 200 (default) the warning above, the phantom "listening" line, and no such file on disk The helper takes the platform rather than reading process.platform directly, following getEnvPaths and constructOpenOptions, so both branches are tested where the tests run. --- src/node/cli.ts | 22 +++++++++++++++++++++- test/unit/node/cli.test.ts | 23 ++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/node/cli.ts b/src/node/cli.ts index 926f6ff1fdef..623268f47e32 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -1,4 +1,5 @@ import { field, Level, logger } from "@coder/logger" +import * as crypto from "crypto" import { promises as fs } from "fs" import { load } from "js-yaml" import * as path from "path" @@ -561,7 +562,7 @@ export async function setDefaults(cliArgs: UserProvidedArgs, configArgs?: Config } if (!args["session-socket"]) { - args["session-socket"] = path.join(args["user-data-dir"], "code-server-ipc.sock") + args["session-socket"] = defaultSessionSocket(args["user-data-dir"]) } process.env.CODE_SERVER_SESSION_SOCKET = args["session-socket"] @@ -710,6 +711,25 @@ export async function setDefaults(cliArgs: UserProvidedArgs, configArgs?: Config } as DefaultedArgs // TODO: Technically no guarantee this is fulfilled. } +/** + * The session socket to use when one was not given. + * + * Windows has no Unix sockets, so there it is a named pipe, which lives in its + * own namespace rather than on disk and so cannot be placed inside the user + * data directory. The name is derived from that directory anyway, so that two + * instances with separate data directories do not collide and a later + * invocation with the same one finds the first. Windows paths are compared + * without regard to case, so the name is folded before it is hashed; otherwise + * the same directory typed two ways would produce two pipes. + */ +export function defaultSessionSocket(userDataDir: string, platform: NodeJS.Platform = process.platform): string { + if (platform !== "win32") { + return path.join(userDataDir, "code-server-ipc.sock") + } + const name = crypto.createHash("sha256").update(path.resolve(userDataDir).toLowerCase()).digest("hex").slice(0, 16) + return String.raw`\\.\pipe\code-server-ipc-${name}` +} + export function getResolvedPathsFromArgs(args: UserProvidedArgs): string[] { return (args._ ?? []).map((p) => path.resolve(p)) } diff --git a/test/unit/node/cli.test.ts b/test/unit/node/cli.test.ts index 53eb066c1e70..17f57a9666ef 100644 --- a/test/unit/node/cli.test.ts +++ b/test/unit/node/cli.test.ts @@ -5,6 +5,7 @@ import { UserProvidedArgs, bindAddrFromArgs, defaultConfigFile, + defaultSessionSocket, parse, parseConfigFile, setDefaults, @@ -37,7 +38,7 @@ const defaults = { usingEnvHashedPassword: false, "extensions-dir": path.join(paths.data, "extensions"), "user-data-dir": paths.data, - "session-socket": path.join(paths.data, "code-server-ipc.sock"), + "session-socket": defaultSessionSocket(paths.data), "app-name": "code-server", _: [], } @@ -977,6 +978,26 @@ describe("bindAddrFromArgs", () => { }) }) +describe("defaultSessionSocket", () => { + const dataDir = path.join("/home/coder/.local/share", "code-server") + + it("should put the socket in the user data directory", () => { + expect(defaultSessionSocket(dataDir, "linux")).toBe(path.join(dataDir, "code-server-ipc.sock")) + }) + + it("should use a named pipe on windows", () => { + expect(defaultSessionSocket(dataDir, "win32")).toMatch(/^\\\\\.\\pipe\\code-server-ipc-[0-9a-f]{16}$/) + }) + + it("should give separate data directories separate pipes", () => { + expect(defaultSessionSocket(dataDir, "win32")).not.toBe(defaultSessionSocket(dataDir + "-other", "win32")) + }) + + it("should give one data directory one pipe however it is spelled", () => { + expect(defaultSessionSocket(dataDir.toUpperCase(), "win32")).toBe(defaultSessionSocket(dataDir, "win32")) + }) +}) + describe("defaultConfigFile", () => { it("should return the default config file as a string", async () => { const password = await generatePassword()