From d11213d182b103754c16fb185880e5d7a83cb06d Mon Sep 17 00:00:00 2001 From: b84330808 Date: Sun, 8 Nov 2026 00:35:36 +0900 Subject: [PATCH] Add --wait to code-server's own CLI Using `code-server --wait --reuse-window` as GIT_EDITOR fails with "Unknown option --wait" whenever code-server's own script is the one on PATH rather than the remote-cli script VS Code adds to the integrated terminal (#8089). It also cannot be used as an editor from a shell outside the integrated terminal. With --wait, code-server now creates a marker file, passes it to Code with the open request, and returns once Code deletes it, which Code does when the opened files are closed. This is what VS Code's remote-cli does. Like --reuse-window, --wait always opens in an existing instance. --- CHANGELOG.md | 7 +++++ src/node/cli.ts | 9 ++++-- src/node/main.ts | 21 ++++++++++++- test/unit/node/cli.test.ts | 8 +++++ test/unit/node/main.test.ts | 59 +++++++++++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d4631710f6d..11bda15ee805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ Code v99.99.999 ## Unreleased +### Added + +- New flag `--wait` that waits for the opened files to be closed before + returning, so `code-server --wait` can be used as `EDITOR` or `GIT_EDITOR`. + Before, code-server's own script failed with `Unknown option --wait`, so this + only worked when the integrated terminal found the `remote-cli` script first. + ## [4.145.2](https://github.com/coder/code-server/releases/tag/v4.145.2) - 2026-11-07 Code v1.145.2 diff --git a/src/node/cli.ts b/src/node/cli.ts index 8ed868577356..cd8e96781558 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -94,6 +94,7 @@ export interface UserProvidedArgs extends UserProvidedCodeArgs { "skip-auth-preflight"?: boolean "reuse-window"?: boolean "new-window"?: boolean + wait?: boolean "ignore-last-opened"?: boolean verbose?: boolean "app-name"?: string @@ -305,6 +306,10 @@ export const options: Options> = { short: "r", description: "Force to open a file or folder in an already opened window.", }, + wait: { + type: "boolean", + description: "Wait for the files to be closed before returning. Requires an already opened window.", + }, log: { type: LogLevel }, verbose: { type: "boolean", short: "vvv", description: "Enable verbose logging." }, @@ -916,11 +921,11 @@ export const shouldOpenInExistingInstance = async ( // existing instance since these flags have no effect otherwise. That means // if there is no existing instance we should error rather than falling back // to spawning code-server normally. - const openInFlagCount = ["reuse-window", "new-window"].reduce((prev, cur) => { + const openInFlagCount = ["reuse-window", "new-window", "wait"].reduce((prev, cur) => { return args[cur as keyof UserProvidedArgs] ? prev + 1 : prev }, 0) if (openInFlagCount > 0) { - logger.debug("Found --reuse-window or --new-window") + logger.debug("Found --reuse-window, --new-window or --wait") const socketPath = await client.getConnectedSocketPath(paths[0]) if (!socketPath) { throw new Error(`No opened code-server instances found to handle ${paths[0]}`) diff --git a/src/node/main.ts b/src/node/main.ts index 7fcc18b1aad5..296e5fa86901 100644 --- a/src/node/main.ts +++ b/src/node/main.ts @@ -1,4 +1,6 @@ import { field, logger } from "@coder/logger" +import * as crypto from "crypto" +import { promises as fs } from "fs" import http from "http" import * as os from "os" import * as path from "path" @@ -10,7 +12,7 @@ import { commit, version, vsRootPath } from "./constants" import { loadCustomStrings } from "./i18n" import { register } from "./routes" import { VSCodeModule } from "./routes/vscode" -import { isDirectory, open } from "./util" +import { isDirectory, isFile, open } from "./util" import { wrapper } from "./wrapper" /** @@ -100,6 +102,15 @@ export const openInExistingInstance = async (args: DefaultedArgs, socketPath: st logger.error("Please specify at least one file or folder") process.exit(1) } + if (args.wait) { + if (pipeArgs.fileURIs.length === 0) { + logger.error("--wait requires at least one file") + process.exit(1) + } + pipeArgs.waitMarkerFilePath = path.join(os.tmpdir(), `code-server-wait-${crypto.randomBytes(8).toString("hex")}`) + await fs.writeFile(pipeArgs.waitMarkerFilePath, "") + } + let failed = false const vscode = http.request( { path: "/", @@ -114,9 +125,17 @@ export const openInExistingInstance = async (args: DefaultedArgs, socketPath: st ) vscode.on("error", (error: unknown) => { logger.error("got error from Code", field("error", error)) + failed = true }) vscode.write(JSON.stringify(pipeArgs)) vscode.end() + if (pipeArgs.waitMarkerFilePath) { + // Code deletes the marker file once the opened files are closed. + while (!failed && (await isFile(pipeArgs.waitMarkerFilePath))) { + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + await fs.rm(pipeArgs.waitMarkerFilePath, { force: true }) + } } export const runCodeServer = async ( diff --git a/test/unit/node/cli.test.ts b/test/unit/node/cli.test.ts index ba5ad6950629..ebd0ab58a61a 100644 --- a/test/unit/node/cli.test.ts +++ b/test/unit/node/cli.test.ts @@ -697,6 +697,14 @@ describe("cli", () => { vscodeSockets.close() }) + it("should not start a new instance if --wait is set", async () => { + const sessionSocket = path.join(tmpDirPath, "session-socket") + const args: UserProvidedArgs = { wait: true, _: ["./file"] } + // There is no instance to open the file in, so this must fail rather than + // falling back to starting code-server. + await expect(shouldOpenInExistingInstance(args, sessionSocket)).rejects.toThrow() + }) + it("should use existing if --new-window is set", async () => { const sessionSocket = path.join(tmpDirPath, "session-socket") const server = await makeEditorSessionManagerServer(sessionSocket, new EditorSessionManager()) diff --git a/test/unit/node/main.test.ts b/test/unit/node/main.test.ts index 39ba2ca8bf9b..9affc5c8b783 100644 --- a/test/unit/node/main.test.ts +++ b/test/unit/node/main.test.ts @@ -1,7 +1,9 @@ import { promises as fs } from "fs" +import * as http from "http" import * as path from "path" import { setDefaults, parse } from "../../../src/node/cli" import { loadCustomStrings } from "../../../src/node/i18n" +import type { OpenCommandPipeArgs } from "../../../src/node/main" import { tmpdir } from "../../utils/helpers" // Mock the i18n module @@ -173,4 +175,61 @@ describe("main", () => { expect(mockedLoadCustomStrings).toHaveBeenCalledWith(nonExistentFile) }) }) + + describe("openInExistingInstance", () => { + it("should wait for the file to be closed with --wait", async () => { + const file = path.join(tempDir, "git-rebase-todo") + await fs.writeFile(file, "") + + // Stand in for the socket Code listens on for CLI requests. + let pipeArgs: OpenCommandPipeArgs | undefined + let onRequest = () => {} + const requested = new Promise((resolve) => (onRequest = resolve)) + const server = http.createServer((req, res) => { + let body = "" + req.on("data", (chunk) => (body += chunk)) + req.on("end", () => { + pipeArgs = JSON.parse(body) + res.end() + onRequest() + }) + }) + const socketPath = + process.platform === "win32" + ? String.raw`\\.\pipe\vscode-${path.basename(tempDir)}` + : path.join(tempDir, "vscode.sock") + await new Promise((resolve) => server.listen(socketPath, resolve)) + + try { + const args = await setDefaults( + parse([`--config=${path.join(tempDir, "config.yaml")}`, `--user-data-dir=${tempDir}`, "--wait", file]), + ) + const mainModule = await import("../../../src/node/main") + let returned = false + const opening = mainModule.openInExistingInstance(args, socketPath).then(() => { + returned = true + }) + + await requested + expect(pipeArgs?.fileURIs).toStrictEqual([file]) + const marker = pipeArgs?.waitMarkerFilePath + expect(marker).toBeDefined() + expect((await fs.stat(marker!)).isFile()).toBe(true) + + // Still waiting while the file is open. + await new Promise((resolve) => setTimeout(resolve, 1500)) + expect(returned).toBe(false) + + // Code deletes the marker once the file is closed. + await fs.rm(marker!) + await opening + expect(returned).toBe(true) + } finally { + if (pipeArgs?.waitMarkerFilePath) { + await fs.rm(pipeArgs.waitMarkerFilePath, { force: true }) + } + server.close() + } + }) + }) })