From c2084231b277cecb840e4f9957cc4fc2e5b0b7a2 Mon Sep 17 00:00:00 2001 From: jxom Date: Tue, 4 Jun 2024 09:33:29 +1000 Subject: [PATCH] feat: restart --- .changeset/slimy-dolphins-sip.md | 6 ++ biome.json | 1 + src/instance.test.ts | 32 ++++++++ src/instance.ts | 34 ++++++++- src/pool.test.ts | 41 ++++++++++ src/pool.ts | 124 ++++++++++++++++--------------- src/server.test.ts | 119 ++++++++++++++++++++++++----- src/server.ts | 76 ++++++++++--------- 8 files changed, 320 insertions(+), 113 deletions(-) create mode 100644 .changeset/slimy-dolphins-sip.md diff --git a/.changeset/slimy-dolphins-sip.md b/.changeset/slimy-dolphins-sip.md new file mode 100644 index 0000000..2fc9ed2 --- /dev/null +++ b/.changeset/slimy-dolphins-sip.md @@ -0,0 +1,6 @@ +--- +"prool": patch +--- + +Added `/restart` endpoint to the Pool Server. +Added `restart` method to pool instances. diff --git a/biome.json b/biome.json index 6daebfe..f0293ad 100644 --- a/biome.json +++ b/biome.json @@ -28,6 +28,7 @@ "noNonNullAssertion": "off" }, "suspicious": { + "noAssignInExpressions": "off", "noExplicitAny": "off" } } diff --git a/src/instance.test.ts b/src/instance.test.ts index 5ec3028..d1afd13 100644 --- a/src/instance.test.ts +++ b/src/instance.test.ts @@ -173,6 +173,38 @@ test('behavior: stop (error)', async () => { expect(instance.status).toEqual('started') }) +test('behavior: restart', async () => { + let count = 0 + const foo = defineInstance(() => { + return { + name: 'foo', + host: 'localhost', + port: 3000, + async start() { + count++ + }, + async stop() {}, + } + }) + + const instance = foo() + await instance.start() + + expect(instance.status).toEqual('started') + const promise_1 = instance.restart() + expect(instance.status).toEqual('restarting') + const promise_2 = instance.restart() + expect(instance.status).toEqual('restarting') + + expect(promise_1).toStrictEqual(promise_2) + + await promise_1 + await promise_2 + + expect(instance.status).toEqual('started') + expect(count).toEqual(2) +}) + test('behavior: events', async () => { const foo = defineInstance(() => { let count = 0 diff --git a/src/instance.ts b/src/instance.ts index 4257d31..520bc36 100644 --- a/src/instance.ts +++ b/src/instance.ts @@ -75,16 +75,25 @@ export type Instance< * @example ["Listening on http://127.0.0.1", "Started successfully."] */ messages: { clear(): void; get(): string[] } + /** + * Retarts the instance. + */ + restart(): Promise /** * Status of the instance. * * @default "idle" */ - status: 'idle' | 'stopped' | 'starting' | 'started' | 'stopping' + status: + | 'idle' + | 'restarting' + | 'stopped' + | 'starting' + | 'started' + | 'stopping' /** * Starts the instance. * - * @param options - Options for starting the instance. * @returns A function to stop the instance. */ start(): Promise<() => void> @@ -147,8 +156,9 @@ export function defineInstance< ...fn(parameters), ...createParameters, } - const { messageBuffer = 20, timeout = 10_000 } = options + const { messageBuffer = 20, timeout } = options + let restartResolver = Promise.withResolvers() let startResolver = Promise.withResolvers<() => void>() let stopResolver = Promise.withResolvers() @@ -156,6 +166,7 @@ export function defineInstance< let messages: string[] = [] let status: Instance['status'] = 'idle' + let restarting = false function onExit() { status = 'stopped' @@ -182,6 +193,7 @@ export function defineInstance< name, port, get status() { + if (restarting) return 'restarting' return status }, async start() { @@ -262,6 +274,22 @@ export function defineInstance< return stopResolver.promise }, + async restart() { + if (restarting) return restartResolver.promise + + restarting = true + + this.stop() + .then(() => this.start.bind(this)()) + .then(() => restartResolver.resolve()) + .catch(restartResolver.reject) + .finally(() => { + restartResolver = Promise.withResolvers() + restarting = false + }) + + return restartResolver.promise + }, addListener: emitter.addListener.bind(emitter), off: emitter.off.bind(emitter), diff --git a/src/pool.test.ts b/src/pool.test.ts index 58bed56..5c0ab91 100644 --- a/src/pool.test.ts +++ b/src/pool.test.ts @@ -82,6 +82,26 @@ describe.each([{ instance: anvil() }])( expect(pool.size).toEqual(0) }) + test('restart', async () => { + pool = definePool({ + instance, + }) + + const instance_1 = await pool.start(1) + const instance_2 = await pool.start(2) + const instance_3 = await pool.start(3) + + expect(instance_1.status).toBe('started') + expect(instance_2.status).toBe('started') + expect(instance_3.status).toBe('started') + expect(pool.size).toEqual(3) + + const promise_1 = pool.restart(1) + expect(instance_1.status).toBe('restarting') + await promise_1 + expect(instance_1.status).toBe('started') + }) + test('start > stop > start', async () => { pool = definePool({ instance, @@ -160,6 +180,27 @@ describe.each([{ instance: anvil() }])( await promise_2 }) + test('behavior: restart more than once', async () => { + pool = definePool({ + instance, + }) + + const instance_1 = await pool.start(1) + expect(instance_1.status).toBe('started') + + const promise_1 = pool.restart(1) + expect(instance_1.status).toBe('restarting') + const promise_2 = pool.restart(1) + expect(instance_1.status).toBe('restarting') + + expect(promise_1).toStrictEqual(promise_2) + + await promise_1 + expect(instance_1.status).toBe('started') + await promise_2 + expect(instance_1.status).toBe('started') + }) + test('behavior: stop more than once', async () => { pool = definePool({ instance, diff --git a/src/pool.ts b/src/pool.ts index c5d20cd..9c7d4ef 100644 --- a/src/pool.ts +++ b/src/pool.ts @@ -13,6 +13,7 @@ export type Pool = Pick< } destroy(key: key): Promise destroyAll(): Promise + restart(key: key): Promise start(key: key, options?: { port?: number }): Promise stop(key: key): Promise stopAll(): Promise @@ -56,6 +57,7 @@ export function definePool( const promises = { destroy: new Map>(), destroyAll: undefined as Promise | undefined, + restart: new Map>(), start: new Map>(), stop: new Map>(), stopAll: undefined as Promise | undefined, @@ -71,16 +73,14 @@ export function definePool( const resolver = Promise.withResolvers() - try { - promises.destroy.set(key, resolver.promise) + promises.destroy.set(key, resolver.promise) - await this.stop(key) - instances.delete(key) - - resolver.resolve() - } catch (error) { - resolver.reject(error) - } + this.stop(key) + .then(() => { + instances.delete(key) + resolver.resolve() + }) + .catch(resolver.reject) return resolver.promise }, @@ -89,17 +89,35 @@ export function definePool( const resolver = Promise.withResolvers() - try { - promises.destroyAll = resolver.promise + promises.destroyAll = resolver.promise - await Promise.all([...instances.keys()].map((key) => this.destroy(key))) + Promise.all([...instances.keys()].map((key) => this.destroy(key))) + .then(() => { + promises.destroyAll = undefined + resolver.resolve() + }) + .catch(resolver.reject) - promises.destroyAll = undefined + return resolver.promise + }, + async restart(key) { + const restartPromise = promises.restart.get(key) + if (restartPromise) return restartPromise - resolver.resolve() - } catch (error) { - resolver.reject(error) - } + const resolver = Promise.withResolvers() + + const instance_ = instances.get(key) + if (!instance_) return + + promises.restart.set(key, resolver.promise) + + instance_ + .restart() + .then(resolver.resolve) + .catch(resolver.reject) + .finally(() => promises.restart.delete(key)) + + return resolver.promise }, async start(key, options = {}) { const startPromise = promises.start.get(key) @@ -107,24 +125,21 @@ export function definePool( const resolver = Promise.withResolvers() - try { - promises.start.set(key, resolver.promise) + if (limit && instances.size >= limit) + throw new Error(`Instance limit of ${limit} reached.`) - if (limit && instances.size >= limit) - throw new Error(`Instance limit of ${limit} reached.`) + promises.start.set(key, resolver.promise) - const { port = await getPort() } = options - - const instance_ = instances.get(key) || instance.create({ port }) - await instance_.start() - - instances.set(key, instance_) - resolver.resolve(instance_) - } catch (error) { - resolver.reject(error) - } finally { - promises.start.delete(key) - } + const { port = await getPort() } = options + const instance_ = instances.get(key) || instance.create({ port }) + instance_ + .start() + .then(() => { + instances.set(key, instance_) + resolver.resolve(instance_) + }) + .catch(resolver.reject) + .finally(() => promises.start.delete(key)) return resolver.promise }, @@ -132,42 +147,35 @@ export function definePool( const stopPromise = promises.stop.get(key) if (stopPromise) return stopPromise - const resolver = Promise.withResolvers() + const instance_ = instances.get(key) + if (!instance_) return - try { - promises.stop.set(key, resolver.promise) - - const instance_ = instances.get(key) - if (!instance_) { - resolver.resolve() - return - } + const resolver = Promise.withResolvers() - await instance_.stop() + promises.stop.set(key, resolver.promise) + instance_ + .stop() + .then(resolver.resolve) + .catch(resolver.reject) + .finally(() => promises.stop.delete(key)) - resolver.resolve() - } catch (error) { - resolver.reject(error) - } finally { - promises.stop.delete(key) - } + return resolver.promise }, async stopAll() { if (promises.stopAll) return promises.stopAll const resolver = Promise.withResolvers() - try { - promises.stopAll = resolver.promise - - await Promise.all([...instances.keys()].map((key) => this.stop(key))) + promises.stopAll = resolver.promise - promises.stopAll = undefined + Promise.all([...instances.keys()].map((key) => this.stop(key))) + .then(() => { + promises.stopAll = undefined + resolver.resolve() + }) + .catch(resolver.reject) - resolver.resolve() - } catch (error) { - resolver.reject(error) - } + return resolver.promise }, get size() { diff --git a/src/server.test.ts b/src/server.test.ts index 6221b90..aab268c 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -7,49 +7,49 @@ describe.each([{ instance: anvil() }])( 'instance: $instance.name', ({ instance }) => { test('default', async () => { - const pool = createServer({ + const server = createServer({ instance, }) - expect(pool).toBeDefined() + expect(server).toBeDefined() - await pool.start() - expect(pool.address()).toBeDefined() + await server.start() + expect(server.address()).toBeDefined() // Stop via instance method. - await pool.stop() - expect(pool.address()).toBeNull() + await server.stop() + expect(server.address()).toBeNull() - const stop = await pool.start() - expect(pool.address()).toBeDefined() + const stop = await server.start() + expect(server.address()).toBeDefined() // Stop via return value. await stop() - expect(pool.address()).toBeNull() + expect(server.address()).toBeNull() }) test('args: port', async () => { - const pool = createServer({ + const server = createServer({ instance, port: 3000, }) - expect(pool).toBeDefined() + expect(server).toBeDefined() - const stop = await pool.start() - expect(pool.address()?.port).toBe(3000) + const stop = await server.start() + expect(server.address()?.port).toBe(3000) await stop() }) test('args: host', async () => { - const pool = createServer({ + const server = createServer({ instance, host: 'localhost', port: 3000, }) - expect(pool).toBeDefined() + expect(server).toBeDefined() - const stop = await pool.start() - expect(pool.address()?.address).toBe('::1') - expect(pool.address()?.port).toBe(3000) + const stop = await server.start() + expect(server.address()?.address).toBe('::1') + expect(server.address()?.port).toBe(3000) await stop() }) @@ -95,6 +95,19 @@ describe.each([{ instance: anvil() }])( await stop() }) + test('request: /restart', async () => { + const server = createServer({ + instance, + }) + + const stop = await server.start() + const { port } = server.address()! + const response = await fetch(`http://localhost:${port}/1/restart`) + expect(response.status).toBe(200) + + await stop() + }) + test('ws', async () => { const server = createServer({ instance, @@ -224,6 +237,76 @@ describe("instance: 'anvil'", () => { await stop() }) + test('request: /restart', async () => { + const server = createServer({ + instance: anvil(), + }) + + const stop = await server.start() + const { port } = server.address()! + + // Mine block number + await fetch(`http://localhost:${port}/1`, { + body: JSON.stringify({ + method: 'anvil_mine', + params: ['0x69', '0x0'], + id: 0, + jsonrpc: '2.0', + }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }) + + // Check block numbers + expect( + await fetch(`http://localhost:${port}/1`, { + body: JSON.stringify({ + method: 'eth_blockNumber', + id: 0, + jsonrpc: '2.0', + }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }).then((x) => x.json()), + ).toMatchInlineSnapshot(` + { + "id": 0, + "jsonrpc": "2.0", + "result": "0x69", + } + `) + + // Restart + await fetch(`http://localhost:${port}/1/restart`) + + // Check block numbers + expect( + await fetch(`http://localhost:${port}/1`, { + body: JSON.stringify({ + method: 'eth_blockNumber', + id: 0, + jsonrpc: '2.0', + }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }).then((x) => x.json()), + ).toMatchInlineSnapshot(` + { + "id": 0, + "jsonrpc": "2.0", + "result": "0x0", + } + `) + + await stop() + }) + test('request: /messages', async () => { const server = createServer({ instance: anvil(), diff --git a/src/server.ts b/src/server.ts index 2ef4d49..ab9be99 100644 --- a/src/server.ts +++ b/src/server.ts @@ -56,44 +56,46 @@ export function createServer( const { id, path } = extractPath(url) - if (typeof id === 'number' && path === '/') { - const { host, port } = pool.get(id) || (await pool.start(id)) - return proxy.web(request, response, { - target: `http://${host}:${port}`, - }) - } - if (typeof id === 'number' && path === '/start') { - const { host, port } = await pool.start(id) - response - .writeHead(200, { 'Content-Type': 'application/json' }) - .end(JSON.stringify({ host, port })) - return - } - if (typeof id === 'number' && path === '/stop') { - await pool.stop(id) - response.writeHead(200, { 'Content-Type': 'application/json' }).end() - return - } - if (typeof id === 'number' && path === '/messages') { - const messages = pool.get(id)?.messages.get() || [] - response - .writeHead(200, { 'Content-Type': 'application/json' }) - .end(JSON.stringify(messages)) - return + if (typeof id === 'number') { + if (path === '/') { + const { host, port } = pool.get(id) || (await pool.start(id)) + return proxy.web(request, response, { + target: `http://${host}:${port}`, + }) + } + if (path === '/start') { + const { host, port } = await pool.start(id) + return done(response, 200, { host, port }) + } + if (path === '/stop') { + await pool.stop(id) + return done(response, 200) + } + if (path === '/restart') { + await pool.restart(id) + return done(response, 200) + } + if (path === '/messages') { + const messages = pool.get(id)?.messages.get() || [] + return done(response, 200, messages) + } } - if (path === '/healthcheck') { - response.writeHead(200, { 'Content-Type': 'application/json' }).end() - return - } + if (path === '/healthcheck') return done(response, 200) - response.writeHead(404, { 'Content-Type': 'application/json' }).end() - return + return done(response, 404) } catch (error) { - response - .writeHead(400, { 'Content-Type': 'application/json' }) - .end(JSON.stringify({ message: (error as Error).message })) - return + return done(response, 400, { message: (error as Error).message }) + } + }) + + proxy.on('proxyReq', (proxyReq, req) => { + ;(req as any)._proxyReq = proxyReq + }) + + proxy.on('error', (err, req) => { + if (req.socket.destroyed && (err as any).code === 'ECONNRESET') { + ;(req as any)._proxyReq.abort() } }) @@ -135,3 +137,9 @@ export function createServer( }, }) } + +function done(res: ServerResponse, statusCode: number, json?: unknown) { + return res + .writeHead(statusCode, { 'Content-Type': 'application/json' }) + .end(json ? JSON.stringify(json) : undefined) +}