createHook: a hook created inside a loop iteration is not resumable from outside; getConflict is a non-idempotent commit
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 2.4k
- Forks
- 365
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 169
Description
Two hook behaviours that are invisible at the call site
Filed against workflow 4.6.2 (@workflow/core 4.6.2), Next.js 16.2, deployed
on Vercel (Hobby), Node 22.
Both of these are as much documentation gaps as behaviour bugs, and the docs
fix is the cheaper one. Either behaviour may well be correct and necessary;
what cost us the time was that nothing at the call site, in the type, or in the
reference pages suggests the constraint exists. A note in createHook would
have saved every cycle described below.
1. A hook created inside a loop iteration cannot be resumed from outside
export async function gateWorkflow(legs: string[]) {
"use workflow";
for (const leg of legs) {
if (isGated(leg)) {
const hook = createHook<Approval>({ token: `run:${runId}:${leg}` });
await hook.getConflict();
await Promise.race([hook, sleep("2h")]); // never resumable
}
}
}
resumeHook(token, …) and getHookByToken(token) both answer
HookNotFoundError for the whole time the run is parked. Moving the identical
createHook call to the top level of the workflow body — awaiting it in the
same place, inside the same loop — fixes it completely.
The behaviour that made this expensive to find: the hook is visible from
inside the run. A getHookByToken called from within a "use step" of the
same run reported it live at the moment the gate was announced. So every
diagnostic run from inside agreed the hook was healthy while every external
resume failed.
Isolated with a small control workflow, one variable per run:
| control shape | externally |
|---|---|
hook at top level of the body, step, then Promise.race([hook, sleep]) |
FOUND / RESUMED |
identical code inside a for and an if |
GONE |
top level, but duration from a module-scope process.env read |
FOUND |
| top level, 2 and 5 steps between creation and await | FOUND / RESUMED |
top level, three hooks committed via one Promise.all |
FOUND / RESUMED |
| hooks built in a conditional expression rather than as statements | FOUND / RESUMED |
Only the loop changes the outcome.
Why it is easy to violate: "use step" announces itself with a directive.
A for loop announces nothing. Nothing in the signature, the return type, or
the createHook reference page marks a loop iteration as a scoping boundary
for hook registration.
Suggested fix, in order of cost: a sentence in the createHook reference
("create hooks at the top level of the workflow body; a hook created inside a
loop iteration is not resumable from outside the run"); or a build-time warning
from the compiler, which already walks the body and can see the call site.
2. getConflict() commits rather than reads, and a second call destroys the registration
The reference says registration is committed when the workflow suspends, and
that getConflict() suspends in order to commit it. What is not said is that it
is not idempotent: calling it a second time on the same hook takes the token
from findable to absent.
Observed directly. With one getConflict() per hook, a parked run's three
tokens all resolved through getHookByToken. Adding a second call at the point
the gate parked, changing nothing else, made the same run's token unresolvable.
Why it is easy to violate: the name reads as an inspection. getConflict,
returning { runId } | null, is shaped exactly like a query — and the docs
describe it as a way to "detect token conflicts early", which reinforces that
reading. Code that checks a condition twice is not normally suspicious.
Suggested fix: name the non-idempotence in the reference, and say plainly
that it is a commit with a return value rather than a check.
Reproduction
A twelve-line control workflow reproducing case 1, with the working and broken
shapes side by side, is available on request; it needs no application code, no
database and no external services — createHook, getConflict, sleep, one
no-op step, and a route that calls getHookByToken / resumeHook.
Open observation, not part of the report
On functionally identical deployments (differing only in comments) the same
gate once showed all three tokens FOUND and, on every later run, GONE — eleven
consecutive samples. We could not reduce this to a rule and are not claiming
it as a defect; it may simply be case 1 with the loop still present. Recorded
here only so it is not mistaken for something we verified.
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 with the createHook reference page and the control workflow described in the report, then compare top-level and loop-created hooks using getHookByToken and resumeHook. Check the getConflict documentation and reproduce the single-call and repeated-call cases. Done means the supported constraints and non-idempotent behavior are documented, or the relevant behavior is corrected and covered by a regression test.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- backend, distributed-systems
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100