mirror of
https://github.com/cdr/code-server.git
synced 2025-12-06 00:14:08 +01:00
Add idle timeout (#7539)
This commit is contained in:
parent
811ec6c1d6
commit
db8a41bce1
7 changed files with 153 additions and 49 deletions
|
|
@ -322,12 +322,8 @@ As long as there is an active browser connection, code-server touches
|
||||||
`~/.local/share/code-server/heartbeat` once a minute.
|
`~/.local/share/code-server/heartbeat` once a minute.
|
||||||
|
|
||||||
If you want to shutdown code-server if there hasn't been an active connection
|
If you want to shutdown code-server if there hasn't been an active connection
|
||||||
after a predetermined amount of time, you can do so by checking continuously for
|
after a predetermined amount of time, you can use the --idle-timeout-seconds flag
|
||||||
the last modified time on the heartbeat file. If it is older than X minutes (or
|
or set an `CODE_SERVER_IDLE_TIMEOUT_SECONDS` environment variable.
|
||||||
whatever amount of time you'd like), you can kill code-server.
|
|
||||||
|
|
||||||
Eventually, [#1636](https://github.com/coder/code-server/issues/1636) will make
|
|
||||||
this process better.
|
|
||||||
|
|
||||||
## How do I change the password?
|
## How do I change the password?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ export interface UserProvidedArgs extends UserProvidedCodeArgs {
|
||||||
"welcome-text"?: string
|
"welcome-text"?: string
|
||||||
"abs-proxy-base-path"?: string
|
"abs-proxy-base-path"?: string
|
||||||
i18n?: string
|
i18n?: string
|
||||||
|
"idle-timeout-seconds"?: number
|
||||||
/* Positional arguments. */
|
/* Positional arguments. */
|
||||||
_?: string[]
|
_?: string[]
|
||||||
}
|
}
|
||||||
|
|
@ -303,6 +304,10 @@ export const options: Options<Required<UserProvidedArgs>> = {
|
||||||
path: true,
|
path: true,
|
||||||
description: "Path to JSON file with custom translations. Merges with default strings and supports all i18n keys.",
|
description: "Path to JSON file with custom translations. Merges with default strings and supports all i18n keys.",
|
||||||
},
|
},
|
||||||
|
"idle-timeout-seconds": {
|
||||||
|
type: "number",
|
||||||
|
description: "Timeout in seconds to wait before shutting down when idle.",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const optionDescriptions = (opts: Partial<Options<Required<UserProvidedArgs>>> = options): string[] => {
|
export const optionDescriptions = (opts: Partial<Options<Required<UserProvidedArgs>>> = options): string[] => {
|
||||||
|
|
@ -396,6 +401,10 @@ export const parse = (
|
||||||
throw new Error("--github-auth can only be set in the config file or passed in via $GITHUB_TOKEN")
|
throw new Error("--github-auth can only be set in the config file or passed in via $GITHUB_TOKEN")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === "idle-timeout-seconds" && Number(value) <= 60) {
|
||||||
|
throw new Error("--idle-timeout-seconds must be greater than 60 seconds.")
|
||||||
|
}
|
||||||
|
|
||||||
const option = options[key]
|
const option = options[key]
|
||||||
if (option.type === "boolean") {
|
if (option.type === "boolean") {
|
||||||
;(args[key] as boolean) = true
|
;(args[key] as boolean) = true
|
||||||
|
|
@ -611,6 +620,16 @@ export async function setDefaults(cliArgs: UserProvidedArgs, configArgs?: Config
|
||||||
args["github-auth"] = process.env.GITHUB_TOKEN
|
args["github-auth"] = process.env.GITHUB_TOKEN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.CODE_SERVER_IDLE_TIMEOUT_SECONDS) {
|
||||||
|
if (isNaN(Number(process.env.CODE_SERVER_IDLE_TIMEOUT_SECONDS))) {
|
||||||
|
logger.info("CODE_SERVER_IDLE_TIMEOUT_SECONDS must be a number")
|
||||||
|
}
|
||||||
|
if (Number(process.env.CODE_SERVER_IDLE_TIMEOUT_SECONDS) <= 60) {
|
||||||
|
throw new Error("--idle-timeout-seconds must be greater than 60 seconds.")
|
||||||
|
}
|
||||||
|
args["idle-timeout-seconds"] = Number(process.env.CODE_SERVER_IDLE_TIMEOUT_SECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure they're not readable by child processes.
|
// Ensure they're not readable by child processes.
|
||||||
delete process.env.PASSWORD
|
delete process.env.PASSWORD
|
||||||
delete process.env.HASHED_PASSWORD
|
delete process.env.HASHED_PASSWORD
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { logger } from "@coder/logger"
|
import { logger } from "@coder/logger"
|
||||||
import { promises as fs } from "fs"
|
import { promises as fs } from "fs"
|
||||||
|
import { Emitter } from "../common/emitter"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides a heartbeat using a local file to indicate activity.
|
* Provides a heartbeat using a local file to indicate activity.
|
||||||
|
|
@ -8,6 +9,9 @@ export class Heart {
|
||||||
private heartbeatTimer?: NodeJS.Timeout
|
private heartbeatTimer?: NodeJS.Timeout
|
||||||
private heartbeatInterval = 60000
|
private heartbeatInterval = 60000
|
||||||
public lastHeartbeat = 0
|
public lastHeartbeat = 0
|
||||||
|
private readonly _onChange = new Emitter<"alive" | "expired" | "unknown">()
|
||||||
|
readonly onChange = this._onChange.event
|
||||||
|
private state: "alive" | "expired" | "unknown" = "expired"
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly heartbeatPath: string,
|
private readonly heartbeatPath: string,
|
||||||
|
|
@ -17,6 +21,13 @@ export class Heart {
|
||||||
this.alive = this.alive.bind(this)
|
this.alive = this.alive.bind(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setState(state: typeof this.state) {
|
||||||
|
if (this.state !== state) {
|
||||||
|
this.state = state
|
||||||
|
this._onChange.emit(this.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public alive(): boolean {
|
public alive(): boolean {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
return now - this.lastHeartbeat < this.heartbeatInterval
|
return now - this.lastHeartbeat < this.heartbeatInterval
|
||||||
|
|
@ -28,6 +39,7 @@ export class Heart {
|
||||||
*/
|
*/
|
||||||
public async beat(): Promise<void> {
|
public async beat(): Promise<void> {
|
||||||
if (this.alive()) {
|
if (this.alive()) {
|
||||||
|
this.setState("alive")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,7 +48,22 @@ export class Heart {
|
||||||
if (typeof this.heartbeatTimer !== "undefined") {
|
if (typeof this.heartbeatTimer !== "undefined") {
|
||||||
clearTimeout(this.heartbeatTimer)
|
clearTimeout(this.heartbeatTimer)
|
||||||
}
|
}
|
||||||
this.heartbeatTimer = setTimeout(() => heartbeatTimer(this.isActive, this.beat), this.heartbeatInterval)
|
|
||||||
|
this.heartbeatTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
if (await this.isActive()) {
|
||||||
|
this.beat()
|
||||||
|
} else {
|
||||||
|
this.setState("expired")
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.warn((error as Error).message)
|
||||||
|
this.setState("unknown")
|
||||||
|
}
|
||||||
|
}, this.heartbeatInterval)
|
||||||
|
|
||||||
|
this.setState("alive")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fs.writeFile(this.heartbeatPath, "")
|
return await fs.writeFile(this.heartbeatPath, "")
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -53,20 +80,3 @@ export class Heart {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function for the heartbeatTimer.
|
|
||||||
*
|
|
||||||
* If heartbeat is active, call beat. Otherwise do nothing.
|
|
||||||
*
|
|
||||||
* Extracted to make it easier to test.
|
|
||||||
*/
|
|
||||||
export async function heartbeatTimer(isActive: Heart["isActive"], beat: Heart["beat"]) {
|
|
||||||
try {
|
|
||||||
if (await isActive()) {
|
|
||||||
beat()
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.warn((error as Error).message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { loadCustomStrings } from "./i18n"
|
||||||
import { register } from "./routes"
|
import { register } from "./routes"
|
||||||
import { VSCodeModule } from "./routes/vscode"
|
import { VSCodeModule } from "./routes/vscode"
|
||||||
import { isDirectory, open } from "./util"
|
import { isDirectory, open } from "./util"
|
||||||
|
import { wrapper } from "./wrapper"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return true if the user passed an extension-related VS Code flag.
|
* Return true if the user passed an extension-related VS Code flag.
|
||||||
|
|
@ -141,7 +142,7 @@ export const runCodeServer = async (
|
||||||
const app = await createApp(args)
|
const app = await createApp(args)
|
||||||
const protocol = args.cert ? "https" : "http"
|
const protocol = args.cert ? "https" : "http"
|
||||||
const serverAddress = ensureAddress(app.server, protocol)
|
const serverAddress = ensureAddress(app.server, protocol)
|
||||||
const disposeRoutes = await register(app, args)
|
const { disposeRoutes, heart } = await register(app, args)
|
||||||
|
|
||||||
logger.info(`Using config file ${args.config}`)
|
logger.info(`Using config file ${args.config}`)
|
||||||
logger.info(`${protocol.toUpperCase()} server listening on ${serverAddress.toString()}`)
|
logger.info(`${protocol.toUpperCase()} server listening on ${serverAddress.toString()}`)
|
||||||
|
|
@ -166,6 +167,27 @@ export const runCodeServer = async (
|
||||||
logger.info(" - Not serving HTTPS")
|
logger.info(" - Not serving HTTPS")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args["idle-timeout-seconds"]) {
|
||||||
|
logger.info(` - Idle timeout set to ${args["idle-timeout-seconds"]} seconds`)
|
||||||
|
|
||||||
|
let idleShutdownTimer: NodeJS.Timeout | undefined
|
||||||
|
const startIdleShutdownTimer = () => {
|
||||||
|
idleShutdownTimer = setTimeout(() => {
|
||||||
|
logger.warn(`Idle timeout of ${args["idle-timeout-seconds"]} seconds exceeded`)
|
||||||
|
wrapper.exit(0)
|
||||||
|
}, args["idle-timeout-seconds"]! * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
startIdleShutdownTimer()
|
||||||
|
|
||||||
|
heart.onChange((state) => {
|
||||||
|
clearTimeout(idleShutdownTimer)
|
||||||
|
if (state === "expired") {
|
||||||
|
startIdleShutdownTimer()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (args["disable-proxy"]) {
|
if (args["disable-proxy"]) {
|
||||||
logger.info(" - Proxy disabled")
|
logger.info(" - Proxy disabled")
|
||||||
} else if (args["proxy-domain"].length > 0) {
|
} else if (args["proxy-domain"].length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,10 @@ import * as vscode from "./vscode"
|
||||||
/**
|
/**
|
||||||
* Register all routes and middleware.
|
* Register all routes and middleware.
|
||||||
*/
|
*/
|
||||||
export const register = async (app: App, args: DefaultedArgs): Promise<Disposable["dispose"]> => {
|
export const register = async (
|
||||||
|
app: App,
|
||||||
|
args: DefaultedArgs,
|
||||||
|
): Promise<{ disposeRoutes: Disposable["dispose"]; heart: Heart }> => {
|
||||||
const heart = new Heart(path.join(paths.data, "heartbeat"), async () => {
|
const heart = new Heart(path.join(paths.data, "heartbeat"), async () => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// getConnections appears to not call the callback when there are no more
|
// getConnections appears to not call the callback when there are no more
|
||||||
|
|
@ -173,8 +176,11 @@ export const register = async (app: App, args: DefaultedArgs): Promise<Disposabl
|
||||||
app.router.use(errorHandler)
|
app.router.use(errorHandler)
|
||||||
app.wsRouter.use(wsErrorHandler)
|
app.wsRouter.use(wsErrorHandler)
|
||||||
|
|
||||||
return () => {
|
return {
|
||||||
heart.dispose()
|
disposeRoutes: () => {
|
||||||
vscode.dispose()
|
heart.dispose()
|
||||||
|
vscode.dispose()
|
||||||
|
},
|
||||||
|
heart,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { logger } from "@coder/logger"
|
import { logger } from "@coder/logger"
|
||||||
import { readFile, writeFile, stat, utimes } from "fs/promises"
|
import { readFile, writeFile, stat, utimes } from "fs/promises"
|
||||||
import { Heart, heartbeatTimer } from "../../../src/node/heart"
|
import { Heart } from "../../../src/node/heart"
|
||||||
import { clean, mockLogger, tmpdir } from "../../utils/helpers"
|
import { clean, mockLogger, tmpdir } from "../../utils/helpers"
|
||||||
|
|
||||||
const mockIsActive = (resolveTo: boolean) => jest.fn().mockResolvedValue(resolveTo)
|
const mockIsActive = (resolveTo: boolean) => jest.fn().mockResolvedValue(resolveTo)
|
||||||
|
|
@ -82,7 +82,52 @@ describe("Heart", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("heartbeatTimer", () => {
|
describe("heartbeatTimer", () => {
|
||||||
beforeAll(() => {
|
const testName = "heartbeatTimer"
|
||||||
|
let testDir = ""
|
||||||
|
beforeAll(async () => {
|
||||||
|
await clean(testName)
|
||||||
|
testDir = await tmpdir(testName)
|
||||||
|
mockLogger()
|
||||||
|
})
|
||||||
|
afterAll(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks()
|
||||||
|
jest.clearAllTimers()
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
it("should call isActive when timeout expires", async () => {
|
||||||
|
const isActive = true
|
||||||
|
const mockIsActive = jest.fn().mockResolvedValue(isActive)
|
||||||
|
const heart = new Heart(`${testDir}/shutdown.txt`, mockIsActive)
|
||||||
|
await heart.beat()
|
||||||
|
jest.advanceTimersByTime(60 * 1000)
|
||||||
|
expect(mockIsActive).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it("should log a warning when isActive rejects", async () => {
|
||||||
|
const errorMsg = "oh no"
|
||||||
|
const error = new Error(errorMsg)
|
||||||
|
const mockIsActive = jest.fn().mockRejectedValue(error)
|
||||||
|
const heart = new Heart(`${testDir}/shutdown.txt`, mockIsActive)
|
||||||
|
await heart.beat()
|
||||||
|
jest.advanceTimersByTime(60 * 1000)
|
||||||
|
|
||||||
|
expect(mockIsActive).toHaveBeenCalled()
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(errorMsg)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("stateChange", () => {
|
||||||
|
const testName = "stateChange"
|
||||||
|
let testDir = ""
|
||||||
|
let heart: Heart
|
||||||
|
beforeAll(async () => {
|
||||||
|
await clean(testName)
|
||||||
|
testDir = await tmpdir(testName)
|
||||||
mockLogger()
|
mockLogger()
|
||||||
})
|
})
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
@ -90,23 +135,28 @@ describe("heartbeatTimer", () => {
|
||||||
})
|
})
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.resetAllMocks()
|
jest.resetAllMocks()
|
||||||
|
if (heart) {
|
||||||
|
heart.dispose()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
it("should call beat when isActive resolves to true", async () => {
|
it("should change to alive after a beat", async () => {
|
||||||
const isActive = true
|
heart = new Heart(`${testDir}/shutdown.txt`, mockIsActive(true))
|
||||||
const mockIsActive = jest.fn().mockResolvedValue(isActive)
|
const mockOnChange = jest.fn()
|
||||||
const mockBeatFn = jest.fn()
|
heart.onChange(mockOnChange)
|
||||||
await heartbeatTimer(mockIsActive, mockBeatFn)
|
await heart.beat()
|
||||||
expect(mockIsActive).toHaveBeenCalled()
|
|
||||||
expect(mockBeatFn).toHaveBeenCalled()
|
expect(mockOnChange.mock.calls[0][0]).toBe("alive")
|
||||||
})
|
})
|
||||||
it("should log a warning when isActive rejects", async () => {
|
it.only("should change to expired when not active", async () => {
|
||||||
const errorMsg = "oh no"
|
jest.useFakeTimers()
|
||||||
const error = new Error(errorMsg)
|
heart = new Heart(`${testDir}/shutdown.txt`, () => new Promise((resolve) => resolve(false)))
|
||||||
const mockIsActive = jest.fn().mockRejectedValue(error)
|
const mockOnChange = jest.fn()
|
||||||
const mockBeatFn = jest.fn()
|
heart.onChange(mockOnChange)
|
||||||
await heartbeatTimer(mockIsActive, mockBeatFn)
|
await heart.beat()
|
||||||
expect(mockIsActive).toHaveBeenCalled()
|
|
||||||
expect(mockBeatFn).not.toHaveBeenCalled()
|
await jest.advanceTimersByTime(60 * 1000)
|
||||||
expect(logger.warn).toHaveBeenCalledWith(errorMsg)
|
expect(mockOnChange.mock.calls[1][0]).toBe("expired")
|
||||||
|
jest.clearAllTimers()
|
||||||
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ jest.mock("@coder/logger", () => ({
|
||||||
debug: jest.fn(),
|
debug: jest.fn(),
|
||||||
warn: jest.fn(),
|
warn: jest.fn(),
|
||||||
error: jest.fn(),
|
error: jest.fn(),
|
||||||
|
named: jest.fn(),
|
||||||
level: 0,
|
level: 0,
|
||||||
},
|
},
|
||||||
field: jest.fn(),
|
field: jest.fn(),
|
||||||
|
|
@ -94,7 +95,7 @@ describe("main", () => {
|
||||||
|
|
||||||
// Mock routes module
|
// Mock routes module
|
||||||
jest.doMock("../../../src/node/routes", () => ({
|
jest.doMock("../../../src/node/routes", () => ({
|
||||||
register: jest.fn().mockResolvedValue(jest.fn()),
|
register: jest.fn().mockResolvedValue({ disposeRoutes: jest.fn() }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock loadCustomStrings to succeed
|
// Mock loadCustomStrings to succeed
|
||||||
|
|
@ -131,7 +132,7 @@ describe("main", () => {
|
||||||
|
|
||||||
// Mock routes module
|
// Mock routes module
|
||||||
jest.doMock("../../../src/node/routes", () => ({
|
jest.doMock("../../../src/node/routes", () => ({
|
||||||
register: jest.fn().mockResolvedValue(jest.fn()),
|
register: jest.fn().mockResolvedValue({ disposeRoutes: jest.fn() }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Import runCodeServer after mocking
|
// Import runCodeServer after mocking
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue