Hook.then enrols a one-shot FIFO awaiter, so a hook raced against sleep() in a loop delivers its payload to an iteration that already lost
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 2.4k
- Forks
- 365
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 169
Description
Summary
A Hook (from createHook() / createWebhook()) is a thenable, not a Promise. Every .then() call — including the implicit one Promise.race / await makes — pushes a fresh one-shot deferred onto the hook's awaiter list, and hook_received resolves the oldest entry. An awaiter whose race was lost to a sleep() is never removed, so it stays first in line. The next payload resolves that dead promise, is marked consumed, and the iteration that is actually waiting never wakes.
@workflow/core@5.0.0-beta.53, packages/core/src/workflow/hook.ts:
then()→createHookPromise()createHookPromise()pushes a new deferred each callhook_received→promises.shift()
Nothing removes an awaiter when the other side of a Promise.race settles. This is deterministic (awaiter order, not timing) and survives replay.
Reproduction
Run 2026-09-19 on the local world (through eve 0.61.1's bundled workflow 5.0.0-beta.53; the workflow body is a defineWorkflowTool executor, the Hook code is the published package's). log is a "use step" that appends a timestamped line to a file. mode: "reraced" is the bug; mode: "hoisted" is the workaround below.
import { createWebhook, sleep } from "workflow";
export async function waitInLoop(mode: "reraced" | "hoisted", maxIterations = 4) {
"use workflow";
const webhook = createWebhook();
await log(mode, "webhook.url", webhook.url);
const delivered = mode === "hoisted" ? webhook.then(() => "webhook" as const) : undefined;
for (let i = 1; i <= maxIterations; i++) {
await log(mode, `iteration ${i} start (racing 8s sleep)`);
const winner = await Promise.race([
delivered ?? webhook.then(() => "webhook" as const),
sleep("8s").then(() => "sleep" as const),
]);
await log(mode, `iteration ${i} winner=${winner}`);
if (winner === "webhook") return { mode, iterations: i, wokeBy: "webhook" };
}
return { mode, iterations: maxIterations, wokeBy: "none" };
}
- Start the run; wait for
iteration 1 winner=sleep. - ~2 s into iteration 2,
curl -X POST <webhook.url>once. The POST returns202. - Observed (
reraced): iteration 2 waits out its full 8 s and every later iteration does too. One delivery, zero wakes:
04:56:23.330Z reraced iteration 1 start (racing 8s sleep)
04:56:31.368Z reraced iteration 1 winner=sleep
04:56:31.389Z reraced iteration 2 start (racing 8s sleep)
04:56:33.462Z POST …/.well-known/workflow/v1/webhook/QzHWMNAKuimDDpOLfYa5g -> HTTP 202
04:56:39.441Z reraced iteration 2 winner=sleep
04:56:47.538Z reraced iteration 3 winner=sleep
04:56:55.617Z reraced iteration 4 winner=sleep → tool result { iterations: 4, wokeBy: "none" }
- Same run with two POSTs 500 ms apart during iteration 2: the first is swallowed by iteration 1's stale awaiter, the second wakes iteration 2 — waking iteration k takes k deliveries:
04:58:35.090Z reraced iteration 2 start (racing 8s sleep)
04:58:37.375Z POST #1 …/webhook/NprXAPVCz6fEMRslN7gjF -> HTTP 202
04:58:37.899Z POST #2 …/webhook/NprXAPVCz6fEMRslN7gjF -> HTTP 202
04:58:37.921Z reraced iteration 2 winner=webhook → { iterations: 2, wokeBy: "webhook" }
- Control (
hoisted, one awaiter enrolled before the loop): a single POST during iteration 2 wakes it 24 ms later:
04:57:16.709Z hoisted iteration 2 start (racing 8s sleep)
04:57:18.971Z POST …/webhook/bHY6D5LUhv10V_d4dJt_Q -> HTTP 202
04:57:18.995Z hoisted iteration 2 winner=webhook → { iterations: 2, wokeBy: "webhook" }
Not executed here, derived from the source path only: Promise.race([webhook, sleep(...)]) without the explicit .then (Promise.resolve on a thenable calls then, so it should enrol the same way), and createHook() + resumeHook() (same Hook class, same createHookPromise).
Expected
A payload delivered while the workflow is awaiting the hook wakes the await that is actually pending. At minimum the docs should say a hook may only be awaited/raced once per payload.
Workaround
Enrol one awaiter and race that Promise (the hoisted mode above; verified):
const delivered = webhook.then((r) => r); // once, before the loop
for (;;) {
const winner = await Promise.race([delivered, sleep("30s").then(() => "sleep" as const)]);
…
}
Suggested change
Either of these, in order of cost:
- Document it on
createHook()/createWebhook(), and revisit the Timeouts cookbook — "Soft timeout (retry): loop and retry with a freshPromise.race" and "Human approvals … escalate" describe exactly this pattern, and as written they lose the payload for hooks and webhooks. - Reuse the pending deferred: while no payload has been delivered,
then()returns the same unsettled promise instead of enrolling another.for await(sequential awaits) keeps its semantics; only concurrentPromise.all([hook, hook])on one hook would change, which the docs do not describe.
Environment
workflow/@workflow/core5.0.0-beta.53 (observed through eve 0.61.1's bundled copy,eve devlocal world; the awaiter code above is the published package's). Not re-run on a standaloneworkflowproject or on the Vercel world.- Node v25.9.0, pnpm 10.33.2, macOS 26.4.1 (arm64)
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/core/src/workflow/hook.ts, tracing then() through createHookPromise() and hook_received's promises.shift(). Reproduce the loop with Promise.race and sleep from the issue, then add coverage for stale awaiters and verify that a payload wakes the currently pending iteration without breaking sequential awaits or documented behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100