workflow: make the seam's listener containment total
emitWorkflowEvent's catch rendered the thrown value with a bare String(error), which itself throws when the value's toString / Symbol.toPrimitive throws — breaking the documented containment guarantee: such a listener could fail the run mid-emit, starve later listeners, and turn the detached workflow/end settle hook into an unhandled rejection. Render through a local total fallback instead (String in a try, a fixed label when even coercion throws); local because the seam sits below every engine and cannot import an engine's renderer. Regression: a listener throwing a coercion-trap value — the emit does not propagate and later listeners still run.
This commit is contained in:
@@ -102,6 +102,22 @@ describe('dsh-workflow (interface)', () => {
|
||||
expect(String(warn.mock.calls[0]![0])).toContain('workflow/phase listener threw')
|
||||
})
|
||||
|
||||
it('containment is total: a listener throwing a value whose coercion throws neither propagates nor starves later listeners', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubEngine)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger)
|
||||
const reached: string[] = []
|
||||
ctx.on('workflow/phase', () => {
|
||||
throw { toString: () => { throw new Error('coercion trap') } }
|
||||
})
|
||||
ctx.on('workflow/phase', (_info, title) => { reached.push(title) })
|
||||
const engine = ctx.workflows as StubEngine
|
||||
expect(() => { engine.emit('workflow/phase', INFO, 'Scan') }).not.toThrow()
|
||||
expect(reached).toEqual(['Scan'])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(String(warn.mock.calls[0]![0])).toContain('[unrenderable thrown value]')
|
||||
})
|
||||
|
||||
it('has the expected export surface (default = the abstract service class)', () => {
|
||||
expect(WorkflowServiceDefault).toBe(WorkflowService)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user