refactor(heart): bind class methods and make beat async (#5142)

* feat: set up new test for beat twice

* refactor: make Heart.beat() async

This allows us to properly await heart.beat() in our tests and remove
the HACK I added before.

* refactor: bind heart methods .beat and .alive

This allows the functions to maintain access to the Heart instance (or
`this`) even when they are passed to other functions. We do this because
we pass both `isActive` and `beat` to `heartbeatTimer`.

* feat(heart): add test to ensure no warnings called

* fixup!: revert setTimeout for heartbeatTimer

* fixup!: return promise in beat
This commit is contained in:
Joe Previte 2022-05-04 16:05:48 -07:00 committed by GitHub
parent 7027ec7d60
commit 88e971c609
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 28 additions and 17 deletions

View File

@ -9,7 +9,10 @@ export class Heart {
private heartbeatInterval = 60000 private heartbeatInterval = 60000
public lastHeartbeat = 0 public lastHeartbeat = 0
public constructor(private readonly heartbeatPath: string, private readonly isActive: () => Promise<boolean>) {} public constructor(private readonly heartbeatPath: string, private readonly isActive: () => Promise<boolean>) {
this.beat = this.beat.bind(this)
this.alive = this.alive.bind(this)
}
public alive(): boolean { public alive(): boolean {
const now = Date.now() const now = Date.now()
@ -20,20 +23,22 @@ export class Heart {
* timeout and start or reset a timer that keeps running as long as there is * timeout and start or reset a timer that keeps running as long as there is
* activity. Failures are logged as warnings. * activity. Failures are logged as warnings.
*/ */
public beat(): void { public async beat(): Promise<void> {
if (this.alive()) { if (this.alive()) {
return return
} }
logger.trace("heartbeat") logger.trace("heartbeat")
fs.writeFile(this.heartbeatPath, "").catch((error) => {
logger.warn(error.message)
})
this.lastHeartbeat = Date.now() this.lastHeartbeat = Date.now()
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(() => heartbeatTimer(this.isActive, this.beat), this.heartbeatInterval)
try {
return await fs.writeFile(this.heartbeatPath, "")
} catch (error: any) {
logger.warn(error.message)
}
} }
/** /**

View File

@ -56,6 +56,8 @@ export const register = async (app: App, args: DefaultedArgs): Promise<Disposabl
// /healthz|/healthz/ needs to be excluded otherwise health checks will make // /healthz|/healthz/ needs to be excluded otherwise health checks will make
// it look like code-server is always in use. // it look like code-server is always in use.
if (!/^\/healthz\/?$/.test(req.url)) { if (!/^\/healthz\/?$/.test(req.url)) {
// NOTE@jsjoeio - intentionally not awaiting the .beat() call here because
// we don't want to slow down the request.
heart.beat() heart.beat()
} }

View File

@ -23,6 +23,7 @@ describe("Heart", () => {
}) })
afterEach(() => { afterEach(() => {
jest.resetAllMocks() jest.resetAllMocks()
jest.useRealTimers()
if (heart) { if (heart) {
heart.dispose() heart.dispose()
} }
@ -42,11 +43,7 @@ describe("Heart", () => {
expect(fileContents).toBe(text) expect(fileContents).toBe(text)
heart = new Heart(pathToFile, mockIsActive(true)) heart = new Heart(pathToFile, mockIsActive(true))
heart.beat() await heart.beat()
// HACK@jsjoeio - beat has some async logic but is not an async method
// Therefore, we have to create an artificial wait in order to make sure
// all async code has completed before asserting
await new Promise((r) => setTimeout(r, 100))
// Check that the heart wrote to the heartbeatFilePath and overwrote our text // Check that the heart wrote to the heartbeatFilePath and overwrote our text
const fileContentsAfterBeat = await readFile(pathToFile, { encoding: "utf8" }) const fileContentsAfterBeat = await readFile(pathToFile, { encoding: "utf8" })
expect(fileContentsAfterBeat).not.toBe(text) expect(fileContentsAfterBeat).not.toBe(text)
@ -56,15 +53,11 @@ describe("Heart", () => {
}) })
it("should log a warning when given an invalid file path", async () => { 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`, mockIsActive(false))
heart.beat() await heart.beat()
// HACK@jsjoeio - beat has some async logic but is not an async method
// Therefore, we have to create an artificial wait in order to make sure
// all async code has completed before asserting
await new Promise((r) => setTimeout(r, 100))
expect(logger.warn).toHaveBeenCalled() expect(logger.warn).toHaveBeenCalled()
}) })
it("should be active after calling beat", () => { it("should be active after calling beat", async () => {
heart.beat() await heart.beat()
const isAlive = heart.alive() const isAlive = heart.alive()
expect(isAlive).toBe(true) expect(isAlive).toBe(true)
@ -75,6 +68,17 @@ describe("Heart", () => {
const isAlive = heart.alive() const isAlive = heart.alive()
expect(isAlive).toBe(false) expect(isAlive).toBe(false)
}) })
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))
await heart.beat()
// we need to speed up clocks, timeouts
// call heartbeat again (and it won't be alive I think)
// then assert no warnings were called
jest.runAllTimers()
expect(logger.warn).not.toHaveBeenCalled()
})
}) })
describe("heartbeatTimer", () => { describe("heartbeatTimer", () => {