Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-servers-reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'prool': patch
---

Added lazy Vitest server setup with worker-scoped controls and fixed lifecycle teardown races.
43 changes: 39 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,9 @@ const instance_3 = await pool.start(3)

### Vitest

`Pool.setup` starts one instance per Vitest Node worker and runs `setup` with the instances and Vitest project. `Pool.get` selects the current worker's value from a provided array. Configure the global setup on each project that uses the instances.
`Server.setup` starts one lazy keyed proxy for a Vitest project. Each worker can
use `Server.get` to address, reset, or restart its own instance. `Pool.setup`
eagerly starts one direct instance per worker instead.

#### Config

Expand All @@ -320,11 +322,46 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globalSetup: './test/setup.global.ts',
setupFiles: './test/setup.ts',
},
})
```

#### Global setup
#### Lazy Server

```ts
import { Instance } from 'prool'
import { Server } from 'prool/vitest'
import type { TestProject } from 'vitest/node'

declare module 'vitest' {
export interface ProvidedContext {
anvil: Server.Context
}
}

export default Server.setup({
instance: Instance.anvil(),
setup(server, project: TestProject) {
project.provide('anvil', server)
},
})
```

```ts
import { Server } from 'prool/vitest'
import { inject } from 'vitest'

const anvil = Server.get(inject('anvil'))
await anvil.reset({ signal: AbortSignal.timeout(30_000) })

export const rpcUrl = anvil.url
```

`reset` destroys only that worker's instance, and its next request starts a
fresh one. `restart` retains the pooled instance and endpoint.

#### Eager Pool

```ts
import { Instance } from 'prool'
Expand All @@ -348,8 +385,6 @@ export default Pool.setup({
})
```

#### Worker

```ts
import { Pool } from 'prool/vitest'
import { inject } from 'vitest'
Expand Down
172 changes: 172 additions & 0 deletions src/Pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,178 @@
await limitedPool.destroyAll()
})

test('destroys an instance while it is starting', async () => {
const starting = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
let stops = 0
const instance = Instance.define(() => ({
host: 'localhost',
name: 'foo',
port: 3000,
async start() {
starting.resolve()
await release.promise
},
async stop() {
stops++
},
}))()
const pool = Pool.define({ instance })

const start = pool.start(1)
await starting.promise
const destroy = pool.destroy(1)
release.resolve()
await Promise.all([start, destroy])

expect(stops).toBe(1)
expect(pool.size).toBe(0)
})

test('destroys pending starts and rejects new ones in destroyAll', async () => {
const starting = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
let stops = 0
const instance = Instance.define(() => ({
host: 'localhost',
name: 'foo',
port: 3000,
async start() {
starting.resolve()
await release.promise
},
async stop() {
stops++
},
}))()
const pool = Pool.define({ instance })

const start = pool.start(1)
await starting.promise
const destroy = pool.destroyAll()
await expect(pool.start(2)).rejects.toThrowError(
'Cannot start an instance while destroying the pool.',
)
release.resolve()
await Promise.all([start, destroy])

expect(stops).toBe(1)
expect(pool.size).toBe(0)
})

test('starts a fresh instance after an in-progress destroy', async () => {
const stopping = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
let starts = 0
let firstStop = true
const instance = Instance.define(() => ({
host: 'localhost',
name: 'foo',
port: 3000,
async start() {
starts++
},
async stop() {
if (!firstStop) return
firstStop = false
stopping.resolve()
await release.promise
},
}))()
const pool = Pool.define({ instance })

const first = await pool.start(1)
const destroy = pool.destroy(1)
await stopping.promise
const start = pool.start(1)
release.resolve()
await destroy
const second = await start

expect(second).not.toBe(first)
expect(starts).toBe(2)
expect(pool.size).toBe(1)
await pool.destroyAll()
})

test('rejects a pending start when destroyAll joins its destroy', async () => {
const stopping = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
let firstStop = true
const instance = Instance.define(() => ({
host: 'localhost',
name: 'foo',
port: 3000,
async start() {},
async stop() {
if (!firstStop) return
firstStop = false
stopping.resolve()
await release.promise
},
}))()
const pool = Pool.define({ instance })

await pool.start(1)
const destroy = pool.destroy(1)
await stopping.promise
const start = pool.start(1)
const destroyAll = pool.destroyAll()
release.resolve()

await expect(start).rejects.toThrowError(
'Cannot start an instance while destroying the pool.',
)
await Promise.all([destroy, destroyAll])
expect(pool.size).toBe(0)
})

test('waits for every destroyAll failure before accepting starts', async () => {
const failed = Promise.withResolvers<void>()
const stopping = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const failures = new Set<number>()
const pool = Pool.define({
instance: (key) =>
Instance.define(() => ({
host: 'localhost',
name: 'foo',
port: 3000,
async start() {},
async stop() {
if (failures.has(key)) return
failures.add(key)
if (key === 1) {
failed.resolve()
throw new Error('stop 1 failed')
}
stopping.resolve()
await release.promise
throw new Error('stop 2 failed')
},
}))(),
})

await pool.start(1)
await pool.start(2)
const destroy = pool.destroyAll()
await Promise.all([failed.promise, stopping.promise])
await expect(pool.start(3)).rejects.toThrowError(
'Cannot start an instance while destroying the pool.',
)
release.resolve()

const error = await destroy.catch((error) => error)
expect(error).toBeInstanceOf(AggregateError)
expect(error.errors.map((error: Error) => error.message)).toEqual([
'stop 1 failed',
'stop 2 failed',
])

await pool.destroyAll()
expect(pool.size).toBe(0)
})

describe('create', () => {
function instance(
parameters: {
Expand Down Expand Up @@ -359,7 +531,7 @@
expect(instance_1.status).toBe('started')
})

test('start > stop > start', async () => {

Check failure on line 534 in src/Pool.test.ts

View workflow job for this annotation

GitHub Actions / Verify / Test

src/Pool.test.ts > instance: 'tempo' > start > stop > start

Error: Test timed out in 10000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ src/Pool.test.ts:534:3
pool = Pool.define({
instance,
})
Expand Down
43 changes: 34 additions & 9 deletions src/Pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,14 @@ export function define<

promises.destroy.set(key, resolver.promise)

this.stop(key)
const operation = (async () => {
await Promise.allSettled([
promises.restart.get(key),
promises.start.get(key),
])
await this.stop(key)
})()
operation
.then(() => {
instances.delete(key)
promises.destroy.delete(key)
Expand All @@ -253,19 +260,28 @@ export function define<

promises.destroyAll = resolver.promise

Promise.all([...instances.keys()].map((key) => this.destroy(key)))
.then(() => {
const keys = new Set([...instances.keys(), ...creating])
Promise.allSettled([...keys].map((key) => this.destroy(key))).then(
(results) => {
const errors = results.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : [],
)
promises.destroyAll = undefined
resolver.resolve()
})
.catch((error) => {
promises.destroyAll = undefined
resolver.reject(error)
})
if (errors.length === 0) resolver.resolve()
else if (errors.length === 1) resolver.reject(errors[0])
else
resolver.reject(
new AggregateError(errors, 'Failed to destroy pool.'),
)
},
)

return resolver.promise
},
async restart(key) {
const destroyPromise = promises.destroy.get(key)
if (destroyPromise) await destroyPromise

const restartPromise = promises.restart.get(key)
if (restartPromise) return restartPromise

Expand All @@ -290,6 +306,15 @@ export function define<
return resolver.promise
},
async start(key, options = {}) {
if (promises.destroyAll)
throw new Error('Cannot start an instance while destroying the pool.')

const destroyPromise = promises.destroy.get(key)
if (destroyPromise) await destroyPromise

if (promises.destroyAll)
throw new Error('Cannot start an instance while destroying the pool.')

const startPromise = promises.start.get(key)
if (startPromise) return startPromise

Expand Down
Loading
Loading