fix(node): await Promise returns from async JS hook handlers#61
fix(node): await Promise returns from async JS hook handlers#61dluc wants to merge 1 commit intomicrosoft:mainfrom
Conversation
JsHookHandlerBridge::handle reads the JS callback's return value as String. When the handler is declared async, its return value is a Promise, which napi-rs cannot coerce into String — the ThreadsafeFunction layer raises a FATAL ERROR and the host process crashes. Accept both sync (String) and async (Promise<String>) return types via Either<String, Promise<String>>, awaiting the promise branch. This matches the pattern already used by JsToolBridge::execute in bindings/node/src/tools.rs. The existing tests in __tests__/hooks.test.ts only exercised synchronous handlers, which is why the regression wasn't caught. Added two new tests covering (a) a simple async handler returning Continue and (b) an async handler returning Deny with a subsequent handler that must not run. The JsHookRegistry::register doc comment already advertised async support (`(event, data) => string | Promise<string>`), so this change makes the behavior match the documented contract.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Fixes a crash in the Node.js binding when hook handlers are async by correctly accepting and awaiting Promise<string> return values from JS hook callbacks.
Changes:
- Update the Rust hook bridge to accept
Either<String, Promise<String>>fromcall_asyncand await the promise branch. - Add Jest tests covering async hook handlers (continue and deny short-circuiting).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| bindings/node/src/hooks.rs | Accept and await promise returns from JS hook handlers to prevent fatal conversion crashes. |
| bindings/node/tests/hooks.test.ts | Adds regression tests for async hook handler behavior and pipeline short-circuiting. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // The JS handler may return either a bare string (synchronous) or a | ||
| // Promise<String> (async). `call_async` returns whatever the JS | ||
| // function returned — accept both via `Either`. | ||
| let ret: Either<String, napi::bindgen_prelude::Promise<String>> = | ||
| self.callback | ||
| .call_async((event, data_str)) | ||
| .await |
There was a problem hiding this comment.
This change fixes the common async case, but any handler returning an unexpected type (e.g., an object) can still trigger a process-level fatal error if the underlying ThreadsafeFunction is configured with ErrorStrategy::Fatal (conversion into Either<...> would fail). Consider switching this bridge’s TSFN to a non-fatal strategy (e.g., CalleeHandled) and mapping conversion failures into HookError::HandlerFailed so user-land mistakes don’t hard-crash the host process.
| let result_str: String = match ret { | ||
| Either::A(s) => s, | ||
| Either::B(promise) => promise.await.map_err(|e| HookError::HandlerFailed { | ||
| message: e.to_string(), | ||
| handler_name: None, | ||
| })?, | ||
| }; |
There was a problem hiding this comment.
The HookError::HandlerFailed { message: e.to_string(), handler_name: None } mapping is duplicated (once for call_async(...).await and again for promise.await). To keep error handling consistent and reduce repetition, consider extracting this mapping into a small local closure/helper used in both places.
| handlerCalled = true | ||
| receivedEvent = event | ||
| // Simulate async work (e.g. I/O) | ||
| await new Promise<void>(resolve => setImmediate(() => resolve())) |
There was a problem hiding this comment.
This can be simplified to avoid an extra closure (e.g., pass resolve directly to setImmediate) while keeping the same behavior. It also makes the intent of ‘await one tick’ a bit clearer.
| await new Promise<void>(resolve => setImmediate(() => resolve())) | |
| await new Promise<void>(resolve => setImmediate(resolve)) |
TL;DR
The Node.js binding crashes the host process whenever an async JS hook handler is registered. Sync-only handlers work; any handler declared
async— which is the only way to do I/O — triggers an unrecoverable FATAL ERROR from napi-rs, taking the whole process down. The fix is a 6-line change; the binding has been shipped with this bug since async support was advertised in the public API.Reproduction (30 seconds)
This is a complete repro. Nothing unusual — a handler that awaits before returning:
Actual result:
The process is gone. No exception to catch, no
.catch()hook can save you —FATAL ERRORcallsnode::OnFatalErrorand exits.Why this makes the binding unusable in practice
Every real-world hook needs to await something:
activity-logger— writes to disktoken-tracker— reads a store, updates it, persistsapproval-emitter— awaits a user decision via IPCstatus-context— fetches current session statetodo-reminder— reads a file, injects contextNone of these are exotic — they're the canonical uses of hooks in the kernel's own design (pre/post tool, context injection, approval, logging). You cannot build any of them today without crashing the process. The only hooks that work are pure-compute transforms (count tokens already in memory, rewrite a string), which is a tiny fraction of what the hook system exists to do.
Result: the documented
JsHookRegistryAPI is effectively dead — it works for toy examples and crashes for anything real.Why this wasn't caught
Every handler in
bindings/node/__tests__/hooks.test.tsis synchronous. The JSDoc onregister()explicitly says both forms are supported:/// (event: string, dataJson: string) => string | Promise<string>…but the test matrix never covered the Promise case, and the Rust side never awaited it. The sibling
JsToolBridge::executeinbindings/node/src/tools.rshandles the same pattern correctly — it awaits aPromise<String>from its JS callback. The hook bridge predates that pattern and was never updated. This PR brings the two into alignment.Root cause
JsHookHandlerBridge::handlereads the JS callback's return value asString:asyncfunctions returnPromise, which napi-rs cannot coerce intoString. TheThreadsafeFunction<_, ErrorStrategy::Fatal>treats conversion failure as a fatal error per its contract — hence the process crash rather than a recoverable error.Fix (6 lines of real change)
Accept both sync and async return types via
Either<String, Promise<String>>, awaiting the promise branch:Sync handlers continue to work unchanged — they take the
Either::Apath with no semantic or performance difference. Async handlers now work instead of crashing the process. This is exactly the patternJsToolBridge::executealready uses.Tests
Two new cases in
__tests__/hooks.test.ts, each of which crashes the process on the unpatched binary:supports async handlers returning Promise<string>— async handler returnsContinue, verify no crash and result propagates.async handler returning deny short-circuits pipeline— asyncDenymust prevent subsequent handlers from running (verifies the Deny precedence still holds through the async path).Full suite after patch: 70/70 passing, including the 7 pre-existing hook tests.
Risk
Zero for existing users. Sync handlers take the same path they always did. Async handlers — which cannot work today — now work. This cannot regress any working code.
Severity
High. Any consumer of the Node binding attempting to write a production-grade hook (i.e. one that awaits) will hit this as their first integration test. The binding is advertising an API it can't deliver.