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:
imccyu
2026-07-09 18:13:34 +08:00
parent 773ecf03f5
commit 4a15c8a479
2 changed files with 41 additions and 6 deletions

View File

@@ -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)
})