Skip to content

Commit 3d62102

Browse files
committed
docs(rivetkit): document keepAwake/waitUntil counter-arm race and surface bridge errors
1 parent 0e7355d commit 3d62102

1 file changed

Lines changed: 40 additions & 8 deletions

File tree

  • rivetkit-typescript/packages/rivetkit/src/registry

rivetkit-typescript/packages/rivetkit/src/registry/native.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ import type { AnyDatabaseProvider } from "@/common/database/config";
7171
import { wrapJsNativeDatabase } from "@/common/database/native-database";
7272
import type { Encoding } from "@/common/encoding";
7373
import { decodeWorkflowHistoryTransport } from "@/common/inspector-transport";
74-
import { deconstructError } from "@/common/utils";
74+
import { deconstructError, stringifyError } from "@/common/utils";
7575
import type {
7676
RivetCloseEvent,
7777
RivetEvent,
@@ -2590,13 +2590,32 @@ export class NativeActorContextAdapter {
25902590
}
25912591

25922592
keepAwake<T>(promise: Promise<T>): Promise<T> {
2593-
// Forward to core `keep_awake`, which increments the keep_awake counter
2594-
// for the duration of the promise. This blocks both idle sleep and
2595-
// grace finalize until the promise settles. The promise value is
2596-
// returned unchanged; core only observes the settle signal.
2597-
void callNative(() =>
2593+
// Forward to core `keep_awake`, which holds the keep_awake counter
2594+
// for the duration of the promise (blocks both idle sleep and grace
2595+
// finalize). The promise value is returned unchanged; core only
2596+
// observes the settle signal.
2597+
//
2598+
// Counter-arm race (acceptable): the NAPI `keep_awake` call is async,
2599+
// so the Rust `keep_awake_guard()` increment happens on first poll of
2600+
// the Rust future, not synchronously when JS calls this method. There
2601+
// is a sub-millisecond window where idle-sleep evaluation could
2602+
// observe `keep_awake_count == 0`. In practice the idle timer runs on
2603+
// `sleep_timeout` (default 30s), so the next poll always observes the
2604+
// counter before the timer fires. Same race exists for `waitUntil`.
2605+
// We accept this trade-off in exchange for keeping the JS API
2606+
// fire-and-forget; core stays the single source of truth for sleep
2607+
// gating logic. Logging the rejection avoids unhandled-promise warnings
2608+
// without blocking the caller.
2609+
callNative(() =>
25982610
this.#ctx.keepAwake(Promise.resolve(promise).then(() => null)),
2599-
);
2611+
).catch((error) => {
2612+
if (!isClosedTaskRegistrationError(error)) {
2613+
logger().warn({
2614+
msg: "keepAwake bridge to native runtime failed",
2615+
error: stringifyError(error),
2616+
});
2617+
}
2618+
});
26002619
return promise;
26012620
}
26022621

@@ -2620,7 +2639,20 @@ export class NativeActorContextAdapter {
26202639
}
26212640

26222641
waitUntil(promise: Promise<unknown>): void {
2623-
void callNative(() => this.#ctx.waitUntil(Promise.resolve(promise)));
2642+
// Same counter-arm race as `keepAwake`: increment of the
2643+
// shutdown_counter happens on first poll of the Rust future. Acceptable
2644+
// because the only consumer is the grace-finalize predicate, which
2645+
// debounces through `activity_notify` and re-checks the counter.
2646+
callNative(() => this.#ctx.waitUntil(Promise.resolve(promise))).catch(
2647+
(error) => {
2648+
if (!isClosedTaskRegistrationError(error)) {
2649+
logger().warn({
2650+
msg: "waitUntil bridge to native runtime failed",
2651+
error: stringifyError(error),
2652+
});
2653+
}
2654+
},
2655+
);
26242656
}
26252657

26262658
beginWebSocketCallback(): number {

0 commit comments

Comments
 (0)