Skip to content

Commit db8a41b

Browse files
Add idle timeout (#7539)
1 parent 811ec6c commit db8a41b

File tree

7 files changed

+144
-40
lines changed

7 files changed

+144
-40
lines changed

docs/FAQ.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -322,12 +322,8 @@ As long as there is an active browser connection, code-server touches
322322
`~/.local/share/code-server/heartbeat` once a minute.
323323

324324
If you want to shutdown code-server if there hasn't been an active connection
325-
after a predetermined amount of time, you can do so by checking continuously for
326-
the last modified time on the heartbeat file. If it is older than X minutes (or
327-
whatever amount of time you'd like), you can kill code-server.
328-
329-
Eventually, [#1636](https://github.com/coder/code-server/issues/1636) will make
330-
this process better.
325+
after a predetermined amount of time, you can use the --idle-timeout-seconds flag
326+
or set an `CODE_SERVER_IDLE_TIMEOUT_SECONDS` environment variable.
331327

332328
## How do I change the password?
333329

src/node/cli.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export interface UserProvidedArgs extends UserProvidedCodeArgs {
9494
"welcome-text"?: string
9595
"abs-proxy-base-path"?: string
9696
i18n?: string
97+
"idle-timeout-seconds"?: number
9798
/* Positional arguments. */
9899
_?: string[]
99100
}
@@ -303,6 +304,10 @@ export const options: Options<Required<UserProvidedArgs>> = {
303304
path: true,
304305
description: "Path to JSON file with custom translations. Merges with default strings and supports all i18n keys.",
305306
},
307+
"idle-timeout-seconds": {
308+
type: "number",
309+
description: "Timeout in seconds to wait before shutting down when idle.",
310+
},
306311
}
307312

308313
export const optionDescriptions = (opts: Partial<Options<Required<UserProvidedArgs>>> = options): string[] => {
@@ -396,6 +401,10 @@ export const parse = (
396401
throw new Error("--github-auth can only be set in the config file or passed in via $GITHUB_TOKEN")
397402
}
398403

404+
if (key === "idle-timeout-seconds" && Number(value) <= 60) {
405+
throw new Error("--idle-timeout-seconds must be greater than 60 seconds.")
406+
}
407+
399408
const option = options[key]
400409
if (option.type === "boolean") {
401410
;(args[key] as boolean) = true
@@ -611,6 +620,16 @@ export async function setDefaults(cliArgs: UserProvidedArgs, configArgs?: Config
611620
args["github-auth"] = process.env.GITHUB_TOKEN
612621
}
613622

623+
if (process.env.CODE_SERVER_IDLE_TIMEOUT_SECONDS) {
624+
if (isNaN(Number(process.env.CODE_SERVER_IDLE_TIMEOUT_SECONDS))) {
625+
logger.info("CODE_SERVER_IDLE_TIMEOUT_SECONDS must be a number")
626+
}
627+
if (Number(process.env.CODE_SERVER_IDLE_TIMEOUT_SECONDS) <= 60) {
628+
throw new Error("--idle-timeout-seconds must be greater than 60 seconds.")
629+
}
630+
args["idle-timeout-seconds"] = Number(process.env.CODE_SERVER_IDLE_TIMEOUT_SECONDS)
631+
}
632+
614633
// Ensure they're not readable by child processes.
615634
delete process.env.PASSWORD
616635
delete process.env.HASHED_PASSWORD

src/node/heart.ts

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { logger } from "@coder/logger"
22
import { promises as fs } from "fs"
3+
import { Emitter } from "../common/emitter"
34

45
/**
56
* Provides a heartbeat using a local file to indicate activity.
@@ -8,6 +9,9 @@ export class Heart {
89
private heartbeatTimer?: NodeJS.Timeout
910
private heartbeatInterval = 60000
1011
public lastHeartbeat = 0
12+
private readonly _onChange = new Emitter<"alive" | "expired" | "unknown">()
13+
readonly onChange = this._onChange.event
14+
private state: "alive" | "expired" | "unknown" = "expired"
1115

1216
public constructor(
1317
private readonly heartbeatPath: string,
@@ -17,6 +21,13 @@ export class Heart {
1721
this.alive = this.alive.bind(this)
1822
}
1923

24+
private setState(state: typeof this.state) {
25+
if (this.state !== state) {
26+
this.state = state
27+
this._onChange.emit(this.state)
28+
}
29+
}
30+
2031
public alive(): boolean {
2132
const now = Date.now()
2233
return now - this.lastHeartbeat < this.heartbeatInterval
@@ -28,6 +39,7 @@ export class Heart {
2839
*/
2940
public async beat(): Promise<void> {
3041
if (this.alive()) {
42+
this.setState("alive")
3143
return
3244
}
3345

@@ -36,7 +48,22 @@ export class Heart {
3648
if (typeof this.heartbeatTimer !== "undefined") {
3749
clearTimeout(this.heartbeatTimer)
3850
}
39-
this.heartbeatTimer = setTimeout(() => heartbeatTimer(this.isActive, this.beat), this.heartbeatInterval)
51+
52+
this.heartbeatTimer = setTimeout(async () => {
53+
try {
54+
if (await this.isActive()) {
55+
this.beat()
56+
} else {
57+
this.setState("expired")
58+
}
59+
} catch (error: unknown) {
60+
logger.warn((error as Error).message)
61+
this.setState("unknown")
62+
}
63+
}, this.heartbeatInterval)
64+
65+
this.setState("alive")
66+
4067
try {
4168
return await fs.writeFile(this.heartbeatPath, "")
4269
} catch (error: any) {
@@ -53,20 +80,3 @@ export class Heart {
5380
}
5481
}
5582
}
56-
57-
/**
58-
* Helper function for the heartbeatTimer.
59-
*
60-
* If heartbeat is active, call beat. Otherwise do nothing.
61-
*
62-
* Extracted to make it easier to test.
63-
*/
64-
export async function heartbeatTimer(isActive: Heart["isActive"], beat: Heart["beat"]) {
65-
try {
66-
if (await isActive()) {
67-
beat()
68-
}
69-
} catch (error: unknown) {
70-
logger.warn((error as Error).message)
71-
}
72-
}

src/node/main.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { loadCustomStrings } from "./i18n"
1111
import { register } from "./routes"
1212
import { VSCodeModule } from "./routes/vscode"
1313
import { isDirectory, open } from "./util"
14+
import { wrapper } from "./wrapper"
1415

1516
/**
1617
* Return true if the user passed an extension-related VS Code flag.
@@ -141,7 +142,7 @@ export const runCodeServer = async (
141142
const app = await createApp(args)
142143
const protocol = args.cert ? "https" : "http"
143144
const serverAddress = ensureAddress(app.server, protocol)
144-
const disposeRoutes = await register(app, args)
145+
const { disposeRoutes, heart } = await register(app, args)
145146

146147
logger.info(`Using config file ${args.config}`)
147148
logger.info(`${protocol.toUpperCase()} server listening on ${serverAddress.toString()}`)
@@ -166,6 +167,27 @@ export const runCodeServer = async (
166167
logger.info(" - Not serving HTTPS")
167168
}
168169

170+
if (args["idle-timeout-seconds"]) {
171+
logger.info(` - Idle timeout set to ${args["idle-timeout-seconds"]} seconds`)
172+
173+
let idleShutdownTimer: NodeJS.Timeout | undefined
174+
const startIdleShutdownTimer = () => {
175+
idleShutdownTimer = setTimeout(() => {
176+
logger.warn(`Idle timeout of ${args["idle-timeout-seconds"]} seconds exceeded`)
177+
wrapper.exit(0)
178+
}, args["idle-timeout-seconds"]! * 1000)
179+
}
180+
181+
startIdleShutdownTimer()
182+
183+
heart.onChange((state) => {
184+
clearTimeout(idleShutdownTimer)
185+
if (state === "expired") {
186+
startIdleShutdownTimer()
187+
}
188+
})
189+
}
190+
169191
if (args["disable-proxy"]) {
170192
logger.info(" - Proxy disabled")
171193
} else if (args["proxy-domain"].length > 0) {

src/node/routes/index.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ import * as vscode from "./vscode"
2828
/**
2929
* Register all routes and middleware.
3030
*/
31-
export const register = async (app: App, args: DefaultedArgs): Promise<Disposable["dispose"]> => {
31+
export const register = async (
32+
app: App,
33+
args: DefaultedArgs,
34+
): Promise<{ disposeRoutes: Disposable["dispose"]; heart: Heart }> => {
3235
const heart = new Heart(path.join(paths.data, "heartbeat"), async () => {
3336
return new Promise((resolve, reject) => {
3437
// 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
173176
app.router.use(errorHandler)
174177
app.wsRouter.use(wsErrorHandler)
175178

176-
return () => {
177-
heart.dispose()
178-
vscode.dispose()
179+
return {
180+
disposeRoutes: () => {
181+
heart.dispose()
182+
vscode.dispose()
183+
},
184+
heart,
179185
}
180186
}

test/unit/node/heart.test.ts

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { logger } from "@coder/logger"
22
import { readFile, writeFile, stat, utimes } from "fs/promises"
3-
import { Heart, heartbeatTimer } from "../../../src/node/heart"
3+
import { Heart } from "../../../src/node/heart"
44
import { clean, mockLogger, tmpdir } from "../../utils/helpers"
55

66
const mockIsActive = (resolveTo: boolean) => jest.fn().mockResolvedValue(resolveTo)
@@ -82,31 +82,81 @@ describe("Heart", () => {
8282
})
8383

8484
describe("heartbeatTimer", () => {
85-
beforeAll(() => {
85+
const testName = "heartbeatTimer"
86+
let testDir = ""
87+
beforeAll(async () => {
88+
await clean(testName)
89+
testDir = await tmpdir(testName)
8690
mockLogger()
8791
})
8892
afterAll(() => {
8993
jest.restoreAllMocks()
9094
})
95+
beforeEach(() => {
96+
jest.useFakeTimers()
97+
})
9198
afterEach(() => {
9299
jest.resetAllMocks()
100+
jest.clearAllTimers()
101+
jest.useRealTimers()
93102
})
94-
it("should call beat when isActive resolves to true", async () => {
103+
it("should call isActive when timeout expires", async () => {
95104
const isActive = true
96105
const mockIsActive = jest.fn().mockResolvedValue(isActive)
97-
const mockBeatFn = jest.fn()
98-
await heartbeatTimer(mockIsActive, mockBeatFn)
106+
const heart = new Heart(`${testDir}/shutdown.txt`, mockIsActive)
107+
await heart.beat()
108+
jest.advanceTimersByTime(60 * 1000)
99109
expect(mockIsActive).toHaveBeenCalled()
100-
expect(mockBeatFn).toHaveBeenCalled()
101110
})
102111
it("should log a warning when isActive rejects", async () => {
103112
const errorMsg = "oh no"
104113
const error = new Error(errorMsg)
105114
const mockIsActive = jest.fn().mockRejectedValue(error)
106-
const mockBeatFn = jest.fn()
107-
await heartbeatTimer(mockIsActive, mockBeatFn)
115+
const heart = new Heart(`${testDir}/shutdown.txt`, mockIsActive)
116+
await heart.beat()
117+
jest.advanceTimersByTime(60 * 1000)
118+
108119
expect(mockIsActive).toHaveBeenCalled()
109-
expect(mockBeatFn).not.toHaveBeenCalled()
110120
expect(logger.warn).toHaveBeenCalledWith(errorMsg)
111121
})
112122
})
123+
124+
describe("stateChange", () => {
125+
const testName = "stateChange"
126+
let testDir = ""
127+
let heart: Heart
128+
beforeAll(async () => {
129+
await clean(testName)
130+
testDir = await tmpdir(testName)
131+
mockLogger()
132+
})
133+
afterAll(() => {
134+
jest.restoreAllMocks()
135+
})
136+
afterEach(() => {
137+
jest.resetAllMocks()
138+
if (heart) {
139+
heart.dispose()
140+
}
141+
})
142+
it("should change to alive after a beat", async () => {
143+
heart = new Heart(`${testDir}/shutdown.txt`, mockIsActive(true))
144+
const mockOnChange = jest.fn()
145+
heart.onChange(mockOnChange)
146+
await heart.beat()
147+
148+
expect(mockOnChange.mock.calls[0][0]).toBe("alive")
149+
})
150+
it.only("should change to expired when not active", async () => {
151+
jest.useFakeTimers()
152+
heart = new Heart(`${testDir}/shutdown.txt`, () => new Promise((resolve) => resolve(false)))
153+
const mockOnChange = jest.fn()
154+
heart.onChange(mockOnChange)
155+
await heart.beat()
156+
157+
await jest.advanceTimersByTime(60 * 1000)
158+
expect(mockOnChange.mock.calls[1][0]).toBe("expired")
159+
jest.clearAllTimers()
160+
jest.useRealTimers()
161+
})
162+
})

test/unit/node/main.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jest.mock("@coder/logger", () => ({
1616
debug: jest.fn(),
1717
warn: jest.fn(),
1818
error: jest.fn(),
19+
named: jest.fn(),
1920
level: 0,
2021
},
2122
field: jest.fn(),
@@ -94,7 +95,7 @@ describe("main", () => {
9495

9596
// Mock routes module
9697
jest.doMock("../../../src/node/routes", () => ({
97-
register: jest.fn().mockResolvedValue(jest.fn()),
98+
register: jest.fn().mockResolvedValue({ disposeRoutes: jest.fn() }),
9899
}))
99100

100101
// Mock loadCustomStrings to succeed
@@ -131,7 +132,7 @@ describe("main", () => {
131132

132133
// Mock routes module
133134
jest.doMock("../../../src/node/routes", () => ({
134-
register: jest.fn().mockResolvedValue(jest.fn()),
135+
register: jest.fn().mockResolvedValue({ disposeRoutes: jest.fn() }),
135136
}))
136137

137138
// Import runCodeServer after mocking

0 commit comments

Comments
 (0)