diff --git a/.changeset/soft-rings-sip.md b/.changeset/soft-rings-sip.md new file mode 100644 index 0000000..a897fd2 --- /dev/null +++ b/.changeset/soft-rings-sip.md @@ -0,0 +1,5 @@ +--- +"prool": patch +--- + +Cleaned up instance start timeout handling so timed-out starts reject catchably and do not leave the instance stuck in a starting state. diff --git a/src/Instance.test.ts b/src/Instance.test.ts index 5156202..5dd6389 100644 --- a/src/Instance.test.ts +++ b/src/Instance.test.ts @@ -358,9 +358,17 @@ test('options: timeout', async () => { }) const instance_1 = foo({ timeout: 100 }) - await expect(() => instance_1.start()).rejects.toThrow( - 'Instance "foo" failed to start in time', - ) + try { + await instance_1.start() + throw new Error('Expected start to time out.') + } catch (error) { + expect(error).toMatchInlineSnapshot( + '[Error: Instance "foo" failed to start in time.]', + ) + } + expect(instance_1.status).toBe('idle') + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(instance_1.status).toBe('idle') const bar = Instance.define(() => { return { diff --git a/src/Instance.ts b/src/Instance.ts index b110976..90e22ef 100644 --- a/src/Instance.ts +++ b/src/Instance.ts @@ -178,10 +178,24 @@ export function define< `Instance "${name}" is not in an idle or stopped state. Status: ${status}`, ) + startResolver = Promise.withResolvers<() => void>() + const resolver = startResolver + let timedOut = false + let timer: NodeJS.Timeout | undefined + const clearTimer = () => { + if (timer) clearTimeout(timer) + timer = undefined + } + if (typeof timeout === 'number') { - const timer = setTimeout(() => { - clearTimeout(timer) - startResolver.reject( + timer = setTimeout(() => { + timedOut = true + status = 'idle' + this.messages.clear() + emitter.off('message', onMessage) + emitter.off('listening', onListening) + emitter.off('exit', onExit) + resolver.reject( new Error(`Instance "${name}" failed to start in time.`), ) }, timeout) @@ -206,29 +220,42 @@ export function define< }, ) .then(() => { + if (timedOut) return + clearTimer() status = 'started' stopResolver = Promise.withResolvers() - startResolver.resolve(this.stop.bind(this)) + resolver.resolve(this.stop.bind(this)) }) .catch((error) => { + if (timedOut) return + clearTimer() status = 'idle' this.messages.clear() emitter.off('message', onMessage) - startResolver.reject(error) + emitter.off('listening', onListening) + emitter.off('exit', onExit) + resolver.reject(error) }) - return startResolver.promise + return resolver.promise }, async stop() { if (status === 'stopping') return stopResolver.promise if (status === 'starting') throw new Error(`Instance "${name}" is starting.`) + stopResolver = Promise.withResolvers() + const resolver = stopResolver + let timer: NodeJS.Timeout | undefined + const clearTimer = () => { + if (timer) clearTimeout(timer) + timer = undefined + } + if (typeof timeout === 'number') { - const timer = setTimeout(() => { - clearTimeout(timer) - stopResolver.reject( + timer = setTimeout(() => { + resolver.reject( new Error(`Instance "${name}" failed to stop in time.`), ) }, timeout) @@ -240,6 +267,7 @@ export function define< status: this.status, }) .then((...args) => { + clearTimer() status = 'stopped' this.messages.clear() @@ -248,14 +276,15 @@ export function define< emitter.off('exit', onExit) startResolver = Promise.withResolvers<() => void>() - stopResolver.resolve(...args) + resolver.resolve(...args) }) .catch((error) => { + clearTimer() status = 'started' - stopResolver.reject(error) + resolver.reject(error) }) - return stopResolver.promise + return resolver.promise }, async restart() { if (restarting) return restartResolver.promise