Skip to content

Commit 865cf5d

Browse files
Add idle timeout
1 parent 811ec6c commit 865cf5d

File tree

6 files changed

+88
-11
lines changed

6 files changed

+88
-11
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 `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.IDLE_TIMEOUT_SECONDS) {
624+
if (isNaN(Number(process.env.IDLE_TIMEOUT_SECONDS))) {
625+
logger.info("IDLE_TIMEOUT_SECONDS must be a number")
626+
}
627+
if (Number(process.env.IDLE_TIMEOUT_SECONDS)) {
628+
throw new Error("--idle-timeout-seconds must be greater than 60 seconds.")
629+
}
630+
args["idle-timeout-seconds"] = Number(process.env.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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
import { logger } from "@coder/logger"
22
import { promises as fs } from "fs"
3+
import { wrapper } from "./wrapper"
34

45
/**
56
* Provides a heartbeat using a local file to indicate activity.
67
*/
78
export class Heart {
89
private heartbeatTimer?: NodeJS.Timeout
10+
private idleShutdownTimer?: NodeJS.Timeout
911
private heartbeatInterval = 60000
1012
public lastHeartbeat = 0
1113

1214
public constructor(
1315
private readonly heartbeatPath: string,
16+
private idleTimeoutSeconds: number | undefined,
1417
private readonly isActive: () => Promise<boolean>,
1518
) {
1619
this.beat = this.beat.bind(this)
1720
this.alive = this.alive.bind(this)
21+
22+
if (this.idleTimeoutSeconds) {
23+
this.idleShutdownTimer = setTimeout(() => this.exitIfIdle(), this.idleTimeoutSeconds * 1000)
24+
}
1825
}
1926

2027
public alive(): boolean {
@@ -36,7 +43,13 @@ export class Heart {
3643
if (typeof this.heartbeatTimer !== "undefined") {
3744
clearTimeout(this.heartbeatTimer)
3845
}
46+
if (typeof this.idleShutdownTimer !== "undefined") {
47+
clearInterval(this.idleShutdownTimer)
48+
}
3949
this.heartbeatTimer = setTimeout(() => heartbeatTimer(this.isActive, this.beat), this.heartbeatInterval)
50+
if (this.idleTimeoutSeconds) {
51+
this.idleShutdownTimer = setTimeout(() => this.exitIfIdle(), this.idleTimeoutSeconds * 1000)
52+
}
4053
try {
4154
return await fs.writeFile(this.heartbeatPath, "")
4255
} catch (error: any) {
@@ -52,6 +65,11 @@ export class Heart {
5265
clearTimeout(this.heartbeatTimer)
5366
}
5467
}
68+
69+
private exitIfIdle(): void {
70+
logger.warn(`Idle timeout of ${this.idleTimeoutSeconds} seconds exceeded`)
71+
wrapper.exit(0)
72+
}
5573
}
5674

5775
/**

src/node/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ export const runCodeServer = async (
166166
logger.info(" - Not serving HTTPS")
167167
}
168168

169+
if (args["idle-timeout-seconds"]) {
170+
logger.info(` - Idle timeout set to ${args["idle-timeout-seconds"]} seconds`)
171+
}
172+
169173
if (args["disable-proxy"]) {
170174
logger.info(" - Proxy disabled")
171175
} else if (args["proxy-domain"].length > 0) {

src/node/routes/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import * as vscode from "./vscode"
2929
* Register all routes and middleware.
3030
*/
3131
export const register = async (app: App, args: DefaultedArgs): Promise<Disposable["dispose"]> => {
32-
const heart = new Heart(path.join(paths.data, "heartbeat"), async () => {
32+
const heart = new Heart(path.join(paths.data, "heartbeat"), args["idle-timeout-seconds"], async () => {
3333
return new Promise((resolve, reject) => {
3434
// getConnections appears to not call the callback when there are no more
3535
// connections. Feels like it must be a bug? For now add a timer to make

test/unit/node/heart.test.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
import { logger } from "@coder/logger"
22
import { readFile, writeFile, stat, utimes } from "fs/promises"
33
import { Heart, heartbeatTimer } from "../../../src/node/heart"
4+
import { wrapper } from "../../../src/node/wrapper"
45
import { clean, mockLogger, tmpdir } from "../../utils/helpers"
56

67
const mockIsActive = (resolveTo: boolean) => jest.fn().mockResolvedValue(resolveTo)
78

9+
jest.mock("../../../src/node/wrapper", () => {
10+
const original = jest.requireActual("../../../src/node/wrapper")
11+
return {
12+
...original,
13+
wrapper: {
14+
exit: jest.fn(),
15+
},
16+
}
17+
})
18+
819
describe("Heart", () => {
920
const testName = "heartTests"
1021
let testDir = ""
@@ -16,7 +27,7 @@ describe("Heart", () => {
1627
testDir = await tmpdir(testName)
1728
})
1829
beforeEach(() => {
19-
heart = new Heart(`${testDir}/shutdown.txt`, mockIsActive(true))
30+
heart = new Heart(`${testDir}/shutdown.txt`, undefined, mockIsActive(true))
2031
})
2132
afterAll(() => {
2233
jest.restoreAllMocks()
@@ -42,7 +53,7 @@ describe("Heart", () => {
4253

4354
expect(fileContents).toBe(text)
4455

45-
heart = new Heart(pathToFile, mockIsActive(true))
56+
heart = new Heart(pathToFile, undefined, mockIsActive(true))
4657
await heart.beat()
4758
// Check that the heart wrote to the heartbeatFilePath and overwrote our text
4859
const fileContentsAfterBeat = await readFile(pathToFile, { encoding: "utf8" })
@@ -52,7 +63,7 @@ describe("Heart", () => {
5263
expect(fileStatusAfterEdit.mtimeMs).toBeGreaterThan(0)
5364
})
5465
it("should log a warning when given an invalid file path", async () => {
55-
heart = new Heart(`fakeDir/fake.txt`, mockIsActive(false))
66+
heart = new Heart(`fakeDir/fake.txt`, undefined, mockIsActive(false))
5667
await heart.beat()
5768
expect(logger.warn).toHaveBeenCalled()
5869
})
@@ -71,7 +82,7 @@ describe("Heart", () => {
7182
it("should beat twice without warnings", async () => {
7283
// Use fake timers so we can speed up setTimeout
7384
jest.useFakeTimers()
74-
heart = new Heart(`${testDir}/hello.txt`, mockIsActive(true))
85+
heart = new Heart(`${testDir}/hello.txt`, undefined, mockIsActive(true))
7586
await heart.beat()
7687
// we need to speed up clocks, timeouts
7788
// call heartbeat again (and it won't be alive I think)
@@ -110,3 +121,32 @@ describe("heartbeatTimer", () => {
110121
expect(logger.warn).toHaveBeenCalledWith(errorMsg)
111122
})
112123
})
124+
125+
describe("idleTimeout", () => {
126+
const testName = "idleHeartTests"
127+
let testDir = ""
128+
let heart: Heart
129+
beforeAll(async () => {
130+
await clean(testName)
131+
testDir = await tmpdir(testName)
132+
mockLogger()
133+
})
134+
afterAll(() => {
135+
jest.restoreAllMocks()
136+
})
137+
afterEach(() => {
138+
jest.resetAllMocks()
139+
if (heart) {
140+
heart.dispose()
141+
}
142+
})
143+
it("should call beat when isActive resolves to true", async () => {
144+
jest.useFakeTimers()
145+
heart = new Heart(`${testDir}/shutdown.txt`, 60, mockIsActive(true))
146+
147+
jest.advanceTimersByTime(60 * 1000)
148+
expect(wrapper.exit).toHaveBeenCalled()
149+
jest.clearAllTimers()
150+
jest.useRealTimers()
151+
})
152+
})

0 commit comments

Comments
 (0)