workflow: pin thenable-return semantics as documented async-JS behavior
Codex code-review round 4 flagged the return channel: an async IIFE Promise-assimilates a returned thenable, so its then() runs past the sync slice and the RESOLUTION replaces the raw object. Verified against the real engine and judged behavior, not defect: - Assimilation is standard JavaScript (an async function's returned thenable resolves before the caller sees it) and is load-bearing ergonomics: an un-awaited 'return agent(...)' / 'return parallel(...)' resolves to the intended value precisely because of it. Rejecting callable-then returns would break that; intercepting pre-assimilation is spec-impossible (the Get(v,'then') and job enqueue are internal to promise resolution). - The realm-boundary guard applies to the RESOLUTION (a thenable resolving to non-JSON is still RESULT_UNSERIALIZABLE), so nothing crosses unmaterialized. - A spin inside a returned thenable's then() is the same accepted class as any post-slice spin (it runs on the microtask queue, past the vm timeout's reach); the docs previously said 'after the first await', which was too narrow — reworded to 'past the initial synchronous slice (an await continuation, or a thenable's then invoked by promise resolution)'. Pinned with an engine test (un-awaited return agent(); custom thenable resolution as the return value; thenable resolving to non-JSON rejects), and the limitation wording updated in the module doc, README, and RFC.
This commit is contained in:
@@ -217,6 +217,19 @@ describe('dsh-workflow-vm', () => {
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.value).toBeNull()
|
||||
})
|
||||
|
||||
it('a returned promise/thenable resolves per async-JS semantics before materialization', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
// Load-bearing ergonomics: forgetting await on the final hook call works.
|
||||
expect((await run(ctx, parent, script("return agent('x')"))).value).toBe('stub reply')
|
||||
// A hand-built thenable is assimilated by the async return — the
|
||||
// RESOLUTION is the script's return value (standard JavaScript), and the
|
||||
// realm-boundary guard applies to that resolution, not the thenable.
|
||||
expect((await run(ctx, parent, script('return { value: 1, then(resolve) { resolve({ ok: true }) } }'))).value).toEqual({ ok: true })
|
||||
const nonJson = await run(ctx, parent, script('return { then(resolve) { resolve({ bad: new Date(0) }) } }'))
|
||||
expect(nonJson.stopReason).toBe('error')
|
||||
expect(nonJson.error).toContain('not plain JSON data')
|
||||
})
|
||||
})
|
||||
|
||||
describe('combinator semantics', () => {
|
||||
|
||||
Reference in New Issue
Block a user