Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/node/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -305,6 +306,10 @@ export const options: Options<Required<UserProvidedArgs>> = {
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." },
Expand Down Expand Up @@ -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]}`)
Expand Down
71 changes: 51 additions & 20 deletions src/node/main.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { field, logger } from "@coder/logger"
import { promises as fs } from "fs"
import http from "http"
import * as os from "os"
import * as path from "path"
import { Disposable } from "../common/emitter"
import { plural } from "../common/util"
import { generateUuid, plural } from "../common/util"
import { createApp, ensureAddress } from "./app"
import { AuthType, DefaultedArgs, Feature, toCodeArgs, UserProvidedArgs } from "./cli"
import { commit, version, vsRootPath } from "./constants"
import { commit, tmpdir, 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, isNodeJSErrnoException, open } from "./util"
import { wrapper } from "./wrapper"

/**
Expand Down Expand Up @@ -100,23 +101,53 @@ export const openInExistingInstance = async (args: DefaultedArgs, socketPath: st
logger.error("Please specify at least one file or folder")
process.exit(1)
}
const vscode = http.request(
{
path: "/",
method: "POST",
socketPath,
},
(response) => {
response.on("data", (message) => {
logger.debug("got message from Code", field("message", message.toString()))
})
},
)
vscode.on("error", (error: unknown) => {
logger.error("got error from Code", field("error", error))
})
vscode.write(JSON.stringify(pipeArgs))
vscode.end()
let marker: string | undefined
if (args.wait) {
if (pipeArgs.fileURIs.length === 0) {
logger.error("--wait requires at least one file")
process.exit(1)
}
marker = path.join(tmpdir, generateUuid(16))
await fs.mkdir(tmpdir, { recursive: true })
await fs.writeFile(marker, "")
pipeArgs.waitMarkerFilePath = marker
}
try {
await new Promise<void>((resolve, reject) => {
const vscode = http.request(
{
path: "/",
method: "POST",
socketPath,
},
(response) => {
response.on("data", (message) => {
logger.debug("got message from Code", field("message", message.toString()))
})
response.on("end", resolve)
},
)
vscode.on("error", reject)
vscode.write(JSON.stringify(pipeArgs))
vscode.end()
})
} catch (error) {
// Try to clean up the marker, but do not let removal errors override the original error.
if (marker) {
try {
await fs.rm(marker)
} catch (rmError) {
if (!isNodeJSErrnoException(rmError) || rmError.code !== "ENOENT") {
logger.error("failed to clean up wait marker", field("error", rmError))
}
}
}
throw error
}
// Code deletes the marker file once the opened files are closed.
while (marker && (await isFile(marker))) {
await new Promise((resolve) => setTimeout(resolve, 1000))
}
}

export const runCodeServer = async (
Expand Down
8 changes: 8 additions & 0 deletions test/unit/node/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
68 changes: 68 additions & 0 deletions test/unit/node/main.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -173,4 +175,70 @@ 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<void>((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<void>((resolve) => server.listen(socketPath, resolve))

// Run the wait loop's poll timer as soon as it has been scheduled.
const runNextPoll = async () => {
while (jest.getTimerCount() === 0) {
await fs.stat(tempDir)
}
jest.runOnlyPendingTimers()
}

jest.useFakeTimers()
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 runNextPoll()
await runNextPoll()
expect(returned).toBe(false)

// Code deletes the marker once the file is closed.
await fs.rm(marker!)
await runNextPoll()
await opening

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly here, after removing we would do jest.runAllTimers() again to make it resolve instantly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, the removal is followed by running the pending timer.

expect(returned).toBe(true)
} finally {
jest.useRealTimers()
server.close()
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no need to clean up the file because everything in the tmpdir gets deleted as part of the test cleanup afterward.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the cleanup.

})
})
})