Refactor to use event Emitter

This commit is contained in:
Andrew Baldwin 2025-10-24 02:21:21 +02:00
parent a52383d7f7
commit ea538ed79e
3 changed files with 56 additions and 40 deletions

View file

@ -1,26 +1,30 @@
import { logger } from "@coder/logger" import { logger } from "@coder/logger"
import { promises as fs } from "fs" 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. * Provides a heartbeat using a local file to indicate activity.
*/ */
export class Heart { export class Heart {
private heartbeatTimer?: NodeJS.Timeout private heartbeatTimer?: NodeJS.Timeout
private idleShutdownTimer?: NodeJS.Timeout
private heartbeatInterval = 60000 private heartbeatInterval = 60000
public lastHeartbeat = 0 public lastHeartbeat = 0
private readonly _onChange = new Emitter<"alive" | "idle" | "unknown">()
readonly onChange = this._onChange.event
private state: "alive" | "idle" | "unknown" = "idle"
public constructor( public constructor(
private readonly heartbeatPath: string, private readonly heartbeatPath: string,
private idleTimeoutSeconds: number | undefined,
private readonly isActive: () => Promise<boolean>, private readonly isActive: () => Promise<boolean>,
) { ) {
this.beat = this.beat.bind(this) this.beat = this.beat.bind(this)
this.alive = this.alive.bind(this) this.alive = this.alive.bind(this)
}
if (this.idleTimeoutSeconds) { private setState(state: typeof this.state) {
this.idleShutdownTimer = setTimeout(() => this.exitIfIdle(), this.idleTimeoutSeconds * 1000) if (this.state !== state) {
this.state = state
this._onChange.emit(this.state)
} }
} }
@ -35,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
} }
@ -43,13 +48,22 @@ export class Heart {
if (typeof this.heartbeatTimer !== "undefined") { if (typeof this.heartbeatTimer !== "undefined") {
clearTimeout(this.heartbeatTimer) clearTimeout(this.heartbeatTimer)
} }
if (typeof this.idleShutdownTimer !== "undefined") {
clearInterval(this.idleShutdownTimer) this.heartbeatTimer = setTimeout(async () => {
} try {
this.heartbeatTimer = setTimeout(() => heartbeatTimer(this.isActive, this.beat), this.heartbeatInterval) if (await this.isActive()) {
if (this.idleTimeoutSeconds) { this.beat()
this.idleShutdownTimer = setTimeout(() => this.exitIfIdle(), this.idleTimeoutSeconds * 1000) } else {
} this.setState("idle")
}
} 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) {
@ -65,26 +79,4 @@ export class Heart {
clearTimeout(this.heartbeatTimer) 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)
}
} }

View file

@ -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()}`)
@ -168,6 +169,23 @@ export const runCodeServer = async (
if (args["idle-timeout-seconds"]) { if (args["idle-timeout-seconds"]) {
logger.info(` - Idle timeout set to ${args["idle-timeout-seconds"]} 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"]) { if (args["disable-proxy"]) {

View file

@ -28,8 +28,11 @@ 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 (
const heart = new Heart(path.join(paths.data, "heartbeat"), args["idle-timeout-seconds"], 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) => { 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
// connections. Feels like it must be a bug? For now add a timer to make // 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<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,
} }
} }