Skip to content
Open
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
25 changes: 24 additions & 1 deletion packages/core/src/fiber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,29 @@ export namespace CordisError {

const INACTIVE = '__INACTIVE__'

// invariant: internal/status observers must not own Fiber state or _error
function dispatchFiberStatus(context: Context, fiber: Fiber, oldState: FiberState) {
const args: any[] = ['internal/status', fiber, oldState]
let callbacks: Function[]
try {
callbacks = context.events.dispatch('emit', args)
} catch (error) {
context.logger.error(error)
return
}

for (const callback of callbacks) {
try {
const result = callback(...args)
if (isObject(result) && 'then' in result) {
void Promise.resolve(result).catch(error => context.logger.error(error))
}
} catch (error) {
context.logger.error(error)
}
}
}

export class Fiber {
public uid: number | null
public readonly ctx: Context
Expand Down Expand Up @@ -357,7 +380,7 @@ export class Fiber {
this.state = callback() ?? this._getState()
if (oldState === this.state) return
// FIXME internal/fiber-info
this.context.emit('internal/status', this, oldState)
dispatchFiberStatus(this.context, this, oldState)

// only notify changes between ACTIVE and NON-ACTIVE states
if (oldState !== FiberState.ACTIVE && this.state !== FiberState.ACTIVE) return
Expand Down
85 changes: 85 additions & 0 deletions packages/core/tests/status.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Context, FiberState, Service } from '../src'
import { expect, describe, it } from 'vitest'
import { mock } from 'node:test'
import { sleep } from './utils'

describe('internal/status isolation', () => {
it('observer throw does not fail a healthy plugin', async () => {
const root = new Context()
const error = mock.fn()
;(root.logger as any).error = error
root.on('internal/status', () => {
throw new Error('observer boom')
})

const fiber = root.plugin(() => {})
await fiber
expect(fiber.state).to.equal(FiberState.ACTIVE)
expect(error.mock.calls.length).to.be.greaterThan(0)
})

it('observer throw does not overwrite plugin error', async () => {
const root = new Context()
const error = mock.fn()
;(root.logger as any).error = error
root.on('internal/status', () => {
throw new Error('observer boom')
})

const fiber = root.plugin(() => {
throw new Error('plugin error')
})
await expect(fiber).rejects.toThrow('plugin error')
expect(fiber.state).to.equal(FiberState.FAILED)
})

it('one throwing observer does not skip later observers', async () => {
const root = new Context()
;(root.logger as any).error = mock.fn()
const second = mock.fn()
root.on('internal/status', () => {
throw new Error('observer boom')
})
root.on('internal/status', second)

await root.plugin(() => {})
expect(second.mock.calls.length).to.be.greaterThan(0)
})

it('observer thenable rejection is contained', async () => {
const root = new Context()
const error = mock.fn()
;(root.logger as any).error = error
root.on('internal/status', () => Promise.reject(new Error('observer boom')))

const fiber = root.plugin(() => {})
await fiber
await sleep()
expect(fiber.state).to.equal(FiberState.ACTIVE)
expect(error.mock.calls.length).to.be.greaterThan(0)
})

it('observer throw does not skip service notify', async () => {
const root = new Context()
;(root.logger as any).error = mock.fn()
root.on('internal/status', () => {
throw new Error('observer boom')
})

class Foo extends Service {
constructor(ctx: Context) {
super(ctx, 'foo')
}
}

let applied = false
const provider = root.plugin(Foo)
const consumer = root.inject(['foo'], () => {
applied = true
})
await provider
await consumer
expect(applied).to.equal(true)
expect(consumer.state).to.equal(FiberState.ACTIVE)
})
})