Files
code-server/test/unit/node/app.test.ts
T
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

311 lines
9.1 KiB
TypeScript

import { logger } from "@coder/logger"
import { promises } from "fs"
import * as http from "http"
import * as https from "https"
import * as path from "path"
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"
describe("createApp", () => {
let unlinkSpy: jest.SpyInstance
let port: number
let tmpDirPath: string
let tmpFilePath: string
beforeAll(async () => {
mockLogger()
const testName = "app"
await clean(testName)
tmpDirPath = await tmpdir(testName)
tmpFilePath = path.join(tmpDirPath, "unlink-socket-file")
})
beforeEach(async () => {
// NOTE:@jsjoeio
// Be mindful when spying.
// You can't spy on fs functions if you do import * as fs
// You have to import individually, like we do here with promises
// then you can spy on those modules methods, like unlink.
// See: https://github.com/aelbore/esbuild-jest/issues/26#issuecomment-893763840
unlinkSpy = jest.spyOn(promises, "unlink")
port = await getAvailablePort()
})
afterEach(() => {
jest.clearAllMocks()
})
it("should return an Express app, a WebSockets Express app and an http server", async () => {
const defaultArgs = await setDefaults({
port,
})
const app = await createApp(defaultArgs)
// This doesn't check much, but it's a good sanity check
// to ensure we actually get back values from createApp
expect(app.router).not.toBeNull()
expect(app.wsRouter).not.toBeNull()
expect(app.server).toBeInstanceOf(http.Server)
// Cleanup
app.dispose()
})
it("should handle error events on the server", async () => {
const defaultArgs = await setDefaults({
port,
})
const app = await createApp(defaultArgs)
const testError = new Error("Test error")
// We can easily test how the server handles errors
// By emitting an error event
// Ref: https://stackoverflow.com/a/33872506/3015595
app.server.emit("error", testError)
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toHaveBeenCalledWith(`http server error: ${testError.message} ${testError.stack}`)
// Cleanup
app.dispose()
})
it("should reject errors that happen before the server can listen", async () => {
// We listen on an invalid port
// causing the app to reject the Promise called at startup
const port = 2
const defaultArgs = await setDefaults({
port,
})
async function masterBall() {
const app = await createApp(defaultArgs)
const testError = new Error("Test error")
app.server.emit("error", testError)
// Cleanup
app.dispose()
}
expect(() => masterBall()).rejects.toThrow("listen EACCES: permission denied")
})
it("should unlink a socket before listening on the socket", async () => {
await promises.writeFile(tmpFilePath, "")
const defaultArgs = await setDefaults({
socket: tmpFilePath,
})
const app = await createApp(defaultArgs)
expect(unlinkSpy).toHaveBeenCalledWith(tmpFilePath)
app.dispose()
})
it("should change the file mode of a socket", async () => {
const defaultArgs = await setDefaults({
socket: tmpFilePath,
"socket-mode": "777",
})
const app = await createApp(defaultArgs)
expect((await promises.stat(tmpFilePath)).mode & 0o777).toBe(0o777)
app.dispose()
})
it("should create an https server if args.cert exists", async () => {
const testCertificate = await generateCertificate("localhost")
const cert = new OptionalString(testCertificate.cert)
const defaultArgs = await setDefaults({
port,
cert,
["cert-key"]: testCertificate.certKey,
})
const app = await createApp(defaultArgs)
// This doesn't check much, but it's a good sanity check
// to ensure we actually get an https.Server
expect(app.server).toBeInstanceOf(https.Server)
// Cleanup
app.dispose()
})
})
describe("ensureAddress", () => {
let mockServer: http.Server
beforeEach(() => {
mockServer = http.createServer()
})
afterEach(() => {
mockServer.close()
})
it("should throw and error if 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 () => {
mockServer.address = () => "/path/to/unix.sock"
const address = ensureAddress(mockServer, "http")
expect(address.toString()).toBe(`/path/to/unix.sock`)
})
it("should construct URL with an IPv4 address", async () => {
mockServer.address = () => ({ address: "1.2.3.4", port: 5678, family: "IPv4" })
const address = ensureAddress(mockServer, "http")
expect(address.toString()).toBe(`http://1.2.3.4:5678/`)
})
it("should construct URL with an IPv6 address", async () => {
mockServer.address = () => ({ address: "a:b:c:d::1234", port: 5678, family: "IPv6" })
const address = ensureAddress(mockServer, "http")
expect(address.toString()).toBe(`http://[a:b:c:d::1234]:5678/`)
})
})
describe("handleArgsSocketCatchError", () => {
beforeAll(() => {
mockLogger()
})
afterEach(() => {
jest.clearAllMocks()
})
it("should log an error if its not an NodeJS.ErrnoException", () => {
const message = "other message"
const error = new Error(message)
expect(() => {
handleArgsSocketCatchError(error)
}).toThrowError(error)
})
it("should log an error if its not an NodeJS.ErrnoException (and the error has a message)", () => {
const errorMessage = "handleArgsSocketCatchError Error"
const error = new Error(errorMessage)
expect(() => {
handleArgsSocketCatchError(error)
}).toThrowError(error)
})
it("should not log an error if its a NodeJS.ErrnoException", () => {
const code = "ENOENT"
const error: NodeJS.ErrnoException = new Error(code)
error.code = code
handleArgsSocketCatchError(error)
expect(() => {
handleArgsSocketCatchError(error)
}).not.toThrowError()
})
it("should log an error if the code is not ENOENT (and the error has a message)", () => {
const errorMessage = "no access"
const error: NodeJS.ErrnoException = new Error()
error.code = "EACCESS"
error.message = errorMessage
expect(() => {
handleArgsSocketCatchError(error)
}).toThrowError(error)
})
it("should log an error if the code is not ENOENT", () => {
const code = "EACCESS"
const error: NodeJS.ErrnoException = new Error(code)
error.code = code
expect(() => {
handleArgsSocketCatchError(error)
}).toThrowError(error)
})
})
describe("listen", () => {
let tmpDirPath: string
let mockServer: http.Server
const testName = "listen"
beforeEach(async () => {
await clean(testName)
mockLogger()
tmpDirPath = await tmpdir(testName)
mockServer = http.createServer()
})
afterEach(() => {
mockServer.close()
jest.clearAllMocks()
})
it("should throw an error if a directory is passed in instead of a file", async () => {
const errorMessage = "EISDIR: illegal operation on a directory, unlink"
const port = await getAvailablePort()
const mockArgs = { port, host: "0.0.0.0", socket: tmpDirPath }
try {
await listen(mockServer, mockArgs)
} catch (error) {
expect(error).toBeInstanceOf(Error)
expect((error as any).message).toMatch(errorMessage)
}
})
})
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()
})
})