mirror of
https://github.com/coder/code-server.git
synced 2026-10-31 02:37:34 +08:00
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.
311 lines
9.1 KiB
TypeScript
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()
|
|
})
|
|
})
|