From 865cf5db9aca82c9e914d9f9d251c123f40d446d Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Thu, 23 Oct 2025 13:13:03 +0200 Subject: [PATCH 1/5] Add idle timeout --- docs/FAQ.md | 8 ++---- src/node/cli.ts | 19 ++++++++++++++ src/node/heart.ts | 18 ++++++++++++++ src/node/main.ts | 4 +++ src/node/routes/index.ts | 2 +- test/unit/node/heart.test.ts | 48 +++++++++++++++++++++++++++++++++--- 6 files changed, 88 insertions(+), 11 deletions(-) diff --git a/docs/FAQ.md b/docs/FAQ.md index 2e01306cb2f2..5f87a7c6de62 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -322,12 +322,8 @@ As long as there is an active browser connection, code-server touches `~/.local/share/code-server/heartbeat` once a minute. 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 -the last modified time on the heartbeat file. If it is older than X minutes (or -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. +after a predetermined amount of time, you can use the --idle-timeout-seconds flag +or set an `IDLE_TIMEOUT_SECONDS` environment variable. ## How do I change the password? diff --git a/src/node/cli.ts b/src/node/cli.ts index 70ede42a0591..80e21556d41e 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -94,6 +94,7 @@ export interface UserProvidedArgs extends UserProvidedCodeArgs { "welcome-text"?: string "abs-proxy-base-path"?: string i18n?: string + "idle-timeout-seconds"?: number /* Positional arguments. */ _?: string[] } @@ -303,6 +304,10 @@ export const options: Options> = { path: true, 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): 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") } + if (key === "idle-timeout-seconds" && Number(value) <= 60) { + throw new Error("--idle-timeout-seconds must be greater than 60 seconds.") + } + const option = options[key] if (option.type === "boolean") { ;(args[key] as boolean) = true @@ -611,6 +620,16 @@ export async function setDefaults(cliArgs: UserProvidedArgs, configArgs?: Config args["github-auth"] = process.env.GITHUB_TOKEN } + if (process.env.IDLE_TIMEOUT_SECONDS) { + if (isNaN(Number(process.env.IDLE_TIMEOUT_SECONDS))) { + logger.info("IDLE_TIMEOUT_SECONDS must be a number") + } + if (Number(process.env.IDLE_TIMEOUT_SECONDS)) { + throw new Error("--idle-timeout-seconds must be greater than 60 seconds.") + } + args["idle-timeout-seconds"] = Number(process.env.IDLE_TIMEOUT_SECONDS) + } + // Ensure they're not readable by child processes. delete process.env.PASSWORD delete process.env.HASHED_PASSWORD diff --git a/src/node/heart.ts b/src/node/heart.ts index aac917257f23..d29b28f65eca 100644 --- a/src/node/heart.ts +++ b/src/node/heart.ts @@ -1,20 +1,27 @@ import { logger } from "@coder/logger" import { promises as fs } from "fs" +import { wrapper } from "./wrapper" /** * Provides a heartbeat using a local file to indicate activity. */ export class Heart { private heartbeatTimer?: NodeJS.Timeout + private idleShutdownTimer?: NodeJS.Timeout private heartbeatInterval = 60000 public lastHeartbeat = 0 public constructor( private readonly heartbeatPath: string, + private idleTimeoutSeconds: number | undefined, private readonly isActive: () => Promise, ) { this.beat = this.beat.bind(this) this.alive = this.alive.bind(this) + + if (this.idleTimeoutSeconds) { + this.idleShutdownTimer = setTimeout(() => this.exitIfIdle(), this.idleTimeoutSeconds * 1000) + } } public alive(): boolean { @@ -36,7 +43,13 @@ export class Heart { if (typeof this.heartbeatTimer !== "undefined") { clearTimeout(this.heartbeatTimer) } + if (typeof this.idleShutdownTimer !== "undefined") { + clearInterval(this.idleShutdownTimer) + } this.heartbeatTimer = setTimeout(() => heartbeatTimer(this.isActive, this.beat), this.heartbeatInterval) + if (this.idleTimeoutSeconds) { + this.idleShutdownTimer = setTimeout(() => this.exitIfIdle(), this.idleTimeoutSeconds * 1000) + } try { return await fs.writeFile(this.heartbeatPath, "") } catch (error: any) { @@ -52,6 +65,11 @@ export class Heart { clearTimeout(this.heartbeatTimer) } } + + private exitIfIdle(): void { + logger.warn(`Idle timeout of ${this.idleTimeoutSeconds} seconds exceeded`) + wrapper.exit(0) + } } /** diff --git a/src/node/main.ts b/src/node/main.ts index 6f8e28dbdea7..4ede9faa9738 100644 --- a/src/node/main.ts +++ b/src/node/main.ts @@ -166,6 +166,10 @@ export const runCodeServer = async ( logger.info(" - Not serving HTTPS") } + if (args["idle-timeout-seconds"]) { + logger.info(` - Idle timeout set to ${args["idle-timeout-seconds"]} seconds`) + } + if (args["disable-proxy"]) { logger.info(" - Proxy disabled") } else if (args["proxy-domain"].length > 0) { diff --git a/src/node/routes/index.ts b/src/node/routes/index.ts index 2841b5a01113..d47f8d2e77f0 100644 --- a/src/node/routes/index.ts +++ b/src/node/routes/index.ts @@ -29,7 +29,7 @@ import * as vscode from "./vscode" * Register all routes and middleware. */ export const register = async (app: App, args: DefaultedArgs): Promise => { - const heart = new Heart(path.join(paths.data, "heartbeat"), async () => { + const heart = new Heart(path.join(paths.data, "heartbeat"), args["idle-timeout-seconds"], async () => { return new Promise((resolve, reject) => { // getConnections appears to not call the callback when there are no more // connections. Feels like it must be a bug? For now add a timer to make diff --git a/test/unit/node/heart.test.ts b/test/unit/node/heart.test.ts index 7aa6f08dc2bf..f6e0858a8408 100644 --- a/test/unit/node/heart.test.ts +++ b/test/unit/node/heart.test.ts @@ -1,10 +1,21 @@ import { logger } from "@coder/logger" import { readFile, writeFile, stat, utimes } from "fs/promises" import { Heart, heartbeatTimer } from "../../../src/node/heart" +import { wrapper } from "../../../src/node/wrapper" import { clean, mockLogger, tmpdir } from "../../utils/helpers" const mockIsActive = (resolveTo: boolean) => jest.fn().mockResolvedValue(resolveTo) +jest.mock("../../../src/node/wrapper", () => { + const original = jest.requireActual("../../../src/node/wrapper") + return { + ...original, + wrapper: { + exit: jest.fn(), + }, + } +}) + describe("Heart", () => { const testName = "heartTests" let testDir = "" @@ -16,7 +27,7 @@ describe("Heart", () => { testDir = await tmpdir(testName) }) beforeEach(() => { - heart = new Heart(`${testDir}/shutdown.txt`, mockIsActive(true)) + heart = new Heart(`${testDir}/shutdown.txt`, undefined, mockIsActive(true)) }) afterAll(() => { jest.restoreAllMocks() @@ -42,7 +53,7 @@ describe("Heart", () => { expect(fileContents).toBe(text) - heart = new Heart(pathToFile, mockIsActive(true)) + heart = new Heart(pathToFile, undefined, mockIsActive(true)) await heart.beat() // Check that the heart wrote to the heartbeatFilePath and overwrote our text const fileContentsAfterBeat = await readFile(pathToFile, { encoding: "utf8" }) @@ -52,7 +63,7 @@ describe("Heart", () => { expect(fileStatusAfterEdit.mtimeMs).toBeGreaterThan(0) }) it("should log a warning when given an invalid file path", async () => { - heart = new Heart(`fakeDir/fake.txt`, mockIsActive(false)) + heart = new Heart(`fakeDir/fake.txt`, undefined, mockIsActive(false)) await heart.beat() expect(logger.warn).toHaveBeenCalled() }) @@ -71,7 +82,7 @@ describe("Heart", () => { it("should beat twice without warnings", async () => { // Use fake timers so we can speed up setTimeout jest.useFakeTimers() - heart = new Heart(`${testDir}/hello.txt`, mockIsActive(true)) + heart = new Heart(`${testDir}/hello.txt`, undefined, mockIsActive(true)) await heart.beat() // we need to speed up clocks, timeouts // call heartbeat again (and it won't be alive I think) @@ -110,3 +121,32 @@ describe("heartbeatTimer", () => { expect(logger.warn).toHaveBeenCalledWith(errorMsg) }) }) + +describe("idleTimeout", () => { + const testName = "idleHeartTests" + let testDir = "" + let heart: Heart + beforeAll(async () => { + await clean(testName) + testDir = await tmpdir(testName) + mockLogger() + }) + afterAll(() => { + jest.restoreAllMocks() + }) + afterEach(() => { + jest.resetAllMocks() + if (heart) { + heart.dispose() + } + }) + it("should call beat when isActive resolves to true", async () => { + jest.useFakeTimers() + heart = new Heart(`${testDir}/shutdown.txt`, 60, mockIsActive(true)) + + jest.advanceTimersByTime(60 * 1000) + expect(wrapper.exit).toHaveBeenCalled() + jest.clearAllTimers() + jest.useRealTimers() + }) +}) From a52383d7f793a393d0b1a3b45ed3cd6cece09ff9 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Sat, 25 Oct 2025 21:26:27 +0200 Subject: [PATCH 2/5] Rename env variable --- docs/FAQ.md | 2 +- src/node/cli.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/FAQ.md b/docs/FAQ.md index 5f87a7c6de62..a695cc64ac6a 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -323,7 +323,7 @@ As long as there is an active browser connection, code-server touches If you want to shutdown code-server if there hasn't been an active connection after a predetermined amount of time, you can use the --idle-timeout-seconds flag -or set an `IDLE_TIMEOUT_SECONDS` environment variable. +or set an `CODE_SERVER_IDLE_TIMEOUT_SECONDS` environment variable. ## How do I change the password? diff --git a/src/node/cli.ts b/src/node/cli.ts index 80e21556d41e..0fce9cfbf25f 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -620,14 +620,14 @@ export async function setDefaults(cliArgs: UserProvidedArgs, configArgs?: Config args["github-auth"] = process.env.GITHUB_TOKEN } - if (process.env.IDLE_TIMEOUT_SECONDS) { - if (isNaN(Number(process.env.IDLE_TIMEOUT_SECONDS))) { - logger.info("IDLE_TIMEOUT_SECONDS must be a number") + 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.IDLE_TIMEOUT_SECONDS)) { + 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.IDLE_TIMEOUT_SECONDS) + args["idle-timeout-seconds"] = Number(process.env.CODE_SERVER_IDLE_TIMEOUT_SECONDS) } // Ensure they're not readable by child processes. From ea538ed79eea0972f1f9eb63cdeecbc60c1db8d4 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Fri, 24 Oct 2025 02:21:21 +0200 Subject: [PATCH 3/5] Refactor to use event Emitter --- src/node/heart.ts | 60 +++++++++++++++++----------------------- src/node/main.ts | 20 +++++++++++++- src/node/routes/index.ts | 16 +++++++---- 3 files changed, 56 insertions(+), 40 deletions(-) diff --git a/src/node/heart.ts b/src/node/heart.ts index d29b28f65eca..fa4835c249b4 100644 --- a/src/node/heart.ts +++ b/src/node/heart.ts @@ -1,26 +1,30 @@ import { logger } from "@coder/logger" import { promises as fs } from "fs" -import { wrapper } from "./wrapper" +import { Emitter } from "../common/emitter" /** * Provides a heartbeat using a local file to indicate activity. */ export class Heart { private heartbeatTimer?: NodeJS.Timeout - private idleShutdownTimer?: NodeJS.Timeout private heartbeatInterval = 60000 public lastHeartbeat = 0 + private readonly _onChange = new Emitter<"alive" | "idle" | "unknown">() + readonly onChange = this._onChange.event + private state: "alive" | "idle" | "unknown" = "idle" public constructor( private readonly heartbeatPath: string, - private idleTimeoutSeconds: number | undefined, private readonly isActive: () => Promise, ) { this.beat = this.beat.bind(this) this.alive = this.alive.bind(this) + } - if (this.idleTimeoutSeconds) { - this.idleShutdownTimer = setTimeout(() => this.exitIfIdle(), this.idleTimeoutSeconds * 1000) + private setState(state: typeof this.state) { + if (this.state !== state) { + this.state = state + this._onChange.emit(this.state) } } @@ -35,6 +39,7 @@ export class Heart { */ public async beat(): Promise { if (this.alive()) { + this.setState("alive") return } @@ -43,13 +48,22 @@ export class Heart { if (typeof this.heartbeatTimer !== "undefined") { clearTimeout(this.heartbeatTimer) } - if (typeof this.idleShutdownTimer !== "undefined") { - clearInterval(this.idleShutdownTimer) - } - this.heartbeatTimer = setTimeout(() => heartbeatTimer(this.isActive, this.beat), this.heartbeatInterval) - if (this.idleTimeoutSeconds) { - this.idleShutdownTimer = setTimeout(() => this.exitIfIdle(), this.idleTimeoutSeconds * 1000) - } + + this.heartbeatTimer = setTimeout(async () => { + try { + if (await this.isActive()) { + this.beat() + } else { + this.setState("idle") + } + } catch (error: unknown) { + logger.warn((error as Error).message) + this.setState("unknown") + } + }, this.heartbeatInterval) + + this.setState("alive") + try { return await fs.writeFile(this.heartbeatPath, "") } catch (error: any) { @@ -65,26 +79,4 @@ export class Heart { clearTimeout(this.heartbeatTimer) } } - - private exitIfIdle(): void { - logger.warn(`Idle timeout of ${this.idleTimeoutSeconds} seconds exceeded`) - wrapper.exit(0) - } -} - -/** - * 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) - } } diff --git a/src/node/main.ts b/src/node/main.ts index 4ede9faa9738..5846b766178d 100644 --- a/src/node/main.ts +++ b/src/node/main.ts @@ -11,6 +11,7 @@ import { loadCustomStrings } from "./i18n" import { register } from "./routes" import { VSCodeModule } from "./routes/vscode" import { isDirectory, open } from "./util" +import { wrapper } from "./wrapper" /** * 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 protocol = args.cert ? "https" : "http" 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(`${protocol.toUpperCase()} server listening on ${serverAddress.toString()}`) @@ -168,6 +169,23 @@ export const runCodeServer = async ( 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 === "idle") { + startIdleShutdownTimer() + } + }) } if (args["disable-proxy"]) { diff --git a/src/node/routes/index.ts b/src/node/routes/index.ts index d47f8d2e77f0..28bfc58d3ee7 100644 --- a/src/node/routes/index.ts +++ b/src/node/routes/index.ts @@ -28,8 +28,11 @@ import * as vscode from "./vscode" /** * Register all routes and middleware. */ -export const register = async (app: App, args: DefaultedArgs): Promise => { - const heart = new Heart(path.join(paths.data, "heartbeat"), args["idle-timeout-seconds"], async () => { +export const register = async ( + app: App, + args: DefaultedArgs, +): Promise<{ disposeRoutes: Disposable["dispose"]; heart: Heart }> => { + const heart = new Heart(path.join(paths.data, "heartbeat"), async () => { return new Promise((resolve, reject) => { // getConnections appears to not call the callback when there are no more // connections. Feels like it must be a bug? For now add a timer to make @@ -173,8 +176,11 @@ export const register = async (app: App, args: DefaultedArgs): Promise { - heart.dispose() - vscode.dispose() + return { + disposeRoutes: () => { + heart.dispose() + vscode.dispose() + }, + heart, } } From cecb83018f765542d1bb2e39d5f0778e237bb85a Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Sat, 25 Oct 2025 21:18:16 +0200 Subject: [PATCH 4/5] Update unit tests --- test/unit/node/heart.test.ts | 70 ++++++++++++++++++++---------------- test/unit/node/main.test.ts | 5 +-- 2 files changed, 43 insertions(+), 32 deletions(-) diff --git a/test/unit/node/heart.test.ts b/test/unit/node/heart.test.ts index f6e0858a8408..0af7cb9fb995 100644 --- a/test/unit/node/heart.test.ts +++ b/test/unit/node/heart.test.ts @@ -1,21 +1,10 @@ import { logger } from "@coder/logger" import { readFile, writeFile, stat, utimes } from "fs/promises" -import { Heart, heartbeatTimer } from "../../../src/node/heart" -import { wrapper } from "../../../src/node/wrapper" +import { Heart } from "../../../src/node/heart" import { clean, mockLogger, tmpdir } from "../../utils/helpers" const mockIsActive = (resolveTo: boolean) => jest.fn().mockResolvedValue(resolveTo) -jest.mock("../../../src/node/wrapper", () => { - const original = jest.requireActual("../../../src/node/wrapper") - return { - ...original, - wrapper: { - exit: jest.fn(), - }, - } -}) - describe("Heart", () => { const testName = "heartTests" let testDir = "" @@ -27,7 +16,7 @@ describe("Heart", () => { testDir = await tmpdir(testName) }) beforeEach(() => { - heart = new Heart(`${testDir}/shutdown.txt`, undefined, mockIsActive(true)) + heart = new Heart(`${testDir}/shutdown.txt`, mockIsActive(true)) }) afterAll(() => { jest.restoreAllMocks() @@ -53,7 +42,7 @@ describe("Heart", () => { expect(fileContents).toBe(text) - heart = new Heart(pathToFile, undefined, mockIsActive(true)) + heart = new Heart(pathToFile, mockIsActive(true)) await heart.beat() // Check that the heart wrote to the heartbeatFilePath and overwrote our text const fileContentsAfterBeat = await readFile(pathToFile, { encoding: "utf8" }) @@ -63,7 +52,7 @@ describe("Heart", () => { expect(fileStatusAfterEdit.mtimeMs).toBeGreaterThan(0) }) it("should log a warning when given an invalid file path", async () => { - heart = new Heart(`fakeDir/fake.txt`, undefined, mockIsActive(false)) + heart = new Heart(`fakeDir/fake.txt`, mockIsActive(false)) await heart.beat() expect(logger.warn).toHaveBeenCalled() }) @@ -82,7 +71,7 @@ describe("Heart", () => { it("should beat twice without warnings", async () => { // Use fake timers so we can speed up setTimeout jest.useFakeTimers() - heart = new Heart(`${testDir}/hello.txt`, undefined, mockIsActive(true)) + heart = new Heart(`${testDir}/hello.txt`, mockIsActive(true)) await heart.beat() // we need to speed up clocks, timeouts // call heartbeat again (and it won't be alive I think) @@ -93,37 +82,47 @@ describe("Heart", () => { }) 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 beat when isActive resolves to true", async () => { + it("should call isActive when timeout expires", async () => { const isActive = true const mockIsActive = jest.fn().mockResolvedValue(isActive) - const mockBeatFn = jest.fn() - await heartbeatTimer(mockIsActive, mockBeatFn) + const heart = new Heart(`${testDir}/shutdown.txt`, mockIsActive) + await heart.beat() + jest.advanceTimersByTime(60 * 1000) expect(mockIsActive).toHaveBeenCalled() - expect(mockBeatFn).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 mockBeatFn = jest.fn() - await heartbeatTimer(mockIsActive, mockBeatFn) + const heart = new Heart(`${testDir}/shutdown.txt`, mockIsActive) + await heart.beat() + jest.advanceTimersByTime(60 * 1000) + expect(mockIsActive).toHaveBeenCalled() - expect(mockBeatFn).not.toHaveBeenCalled() expect(logger.warn).toHaveBeenCalledWith(errorMsg) }) }) -describe("idleTimeout", () => { - const testName = "idleHeartTests" +describe("stateChange", () => { + const testName = "stateChange" let testDir = "" let heart: Heart beforeAll(async () => { @@ -140,12 +139,23 @@ describe("idleTimeout", () => { heart.dispose() } }) - it("should call beat when isActive resolves to true", async () => { + it("should change to alive after a beat", async () => { + heart = new Heart(`${testDir}/shutdown.txt`, mockIsActive(true)) + const mockOnChange = jest.fn() + heart.onChange(mockOnChange) + await heart.beat() + + expect(mockOnChange.mock.calls[0][0]).toBe("alive") + }) + it.only("should change to idle when not active", async () => { jest.useFakeTimers() - heart = new Heart(`${testDir}/shutdown.txt`, 60, mockIsActive(true)) + heart = new Heart(`${testDir}/shutdown.txt`, () => new Promise((resolve) => resolve(false))) + const mockOnChange = jest.fn() + heart.onChange(mockOnChange) + await heart.beat() - jest.advanceTimersByTime(60 * 1000) - expect(wrapper.exit).toHaveBeenCalled() + await jest.advanceTimersByTime(60 * 1000) + expect(mockOnChange.mock.calls[1][0]).toBe("idle") jest.clearAllTimers() jest.useRealTimers() }) diff --git a/test/unit/node/main.test.ts b/test/unit/node/main.test.ts index 09ee6b512ef9..39ba2ca8bf9b 100644 --- a/test/unit/node/main.test.ts +++ b/test/unit/node/main.test.ts @@ -16,6 +16,7 @@ jest.mock("@coder/logger", () => ({ debug: jest.fn(), warn: jest.fn(), error: jest.fn(), + named: jest.fn(), level: 0, }, field: jest.fn(), @@ -94,7 +95,7 @@ describe("main", () => { // Mock routes module jest.doMock("../../../src/node/routes", () => ({ - register: jest.fn().mockResolvedValue(jest.fn()), + register: jest.fn().mockResolvedValue({ disposeRoutes: jest.fn() }), })) // Mock loadCustomStrings to succeed @@ -131,7 +132,7 @@ describe("main", () => { // Mock routes module jest.doMock("../../../src/node/routes", () => ({ - register: jest.fn().mockResolvedValue(jest.fn()), + register: jest.fn().mockResolvedValue({ disposeRoutes: jest.fn() }), })) // Import runCodeServer after mocking From bbbd6edc6a8c957f8dcc0205bc3ff56fba747632 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Sat, 25 Oct 2025 21:29:58 +0200 Subject: [PATCH 5/5] Update idle state to expired --- src/node/heart.ts | 6 +++--- src/node/main.ts | 2 +- test/unit/node/heart.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/node/heart.ts b/src/node/heart.ts index fa4835c249b4..b78f4edb80d2 100644 --- a/src/node/heart.ts +++ b/src/node/heart.ts @@ -9,9 +9,9 @@ export class Heart { private heartbeatTimer?: NodeJS.Timeout private heartbeatInterval = 60000 public lastHeartbeat = 0 - private readonly _onChange = new Emitter<"alive" | "idle" | "unknown">() + private readonly _onChange = new Emitter<"alive" | "expired" | "unknown">() readonly onChange = this._onChange.event - private state: "alive" | "idle" | "unknown" = "idle" + private state: "alive" | "expired" | "unknown" = "expired" public constructor( private readonly heartbeatPath: string, @@ -54,7 +54,7 @@ export class Heart { if (await this.isActive()) { this.beat() } else { - this.setState("idle") + this.setState("expired") } } catch (error: unknown) { logger.warn((error as Error).message) diff --git a/src/node/main.ts b/src/node/main.ts index 5846b766178d..c2d3bd57852b 100644 --- a/src/node/main.ts +++ b/src/node/main.ts @@ -182,7 +182,7 @@ export const runCodeServer = async ( heart.onChange((state) => { clearTimeout(idleShutdownTimer) - if (state === "idle") { + if (state === "expired") { startIdleShutdownTimer() } }) diff --git a/test/unit/node/heart.test.ts b/test/unit/node/heart.test.ts index 0af7cb9fb995..7ad0d21752f2 100644 --- a/test/unit/node/heart.test.ts +++ b/test/unit/node/heart.test.ts @@ -147,7 +147,7 @@ describe("stateChange", () => { expect(mockOnChange.mock.calls[0][0]).toBe("alive") }) - it.only("should change to idle when not active", async () => { + it.only("should change to expired when not active", async () => { jest.useFakeTimers() heart = new Heart(`${testDir}/shutdown.txt`, () => new Promise((resolve) => resolve(false))) const mockOnChange = jest.fn() @@ -155,7 +155,7 @@ describe("stateChange", () => { await heart.beat() await jest.advanceTimersByTime(60 * 1000) - expect(mockOnChange.mock.calls[1][0]).toBe("idle") + expect(mockOnChange.mock.calls[1][0]).toBe("expired") jest.clearAllTimers() jest.useRealTimers() })