Skip to content

select/race flattens activity errors into the winner's value instead of surfacing a failure #9

Description

@affandar

Summary

When an activity participates in ctx.race() / ctx.raceTyped() and fails, the select resolves successfully with the branch's raw error string as its value instead of surfacing a failure. The orchestration receives { index: 0, value: "<error message>" } and has no way to distinguish a failed activity from a successful activity that legitimately returned that same string.

This is inconsistent with the SDK's own semantics elsewhere:

  • A directly yielded activity that fails is delivered via driveStepWithErrorgen.throw(...) — the orchestration sees a thrown error (handlers.rs builds the step payload with isError, lib/duroxide.js calls gen.throw when isError is set).
  • ctx.all() / ctx.allTyped() (join) deliberately preserves the distinction: make_join_future wraps each branch as {ok: v} / {err: e} ("Unlike make_select_future, this preserves the ok/err distinction so JS can tell success from failure").

Only select flattens. The comment in src/handlers.rs documents it as intentional:

/// Convert a ScheduledTask into a type-erased future returning a raw string for use in select.
/// Activity/sub-orch errors are flattened (Ok and Err both become the raw string value).
fn make_select_future(...) -> ... {
    match task {
        ScheduledTask::Activity { .. } => Box::pin(async move {
            ...
            match future.await {
                Ok(v) => v,
                Err(e) => e,   // <-- error becomes the winning *value*
            }
        }),
        ...

The same flattening applies to ActivityWithRetry, SubOrchestration*, and GetValueFromInstance branches.

Why this is a footgun

The failure is silent. Orchestration code like:

const winner = yield ctx.race(
    ctx.scheduleActivity("CallModel", input),   // may fail
    ctx.dequeueEvent("cancel"),
);
if (winner.index === 0) {
    const result = JSON.parse(winner.value);    // error string flows in here
    ...
}

happily treats the error message as the activity's result. Any error-handling try/catch around the yield — which works correctly for the direct-yield form — never fires. If the activity's legitimate return type is a plain string, the two cases are indistinguishable even in principle.

We hit this in PilotSwarm while converting a directly-yielded long-running activity into race(activityTask, dequeueEvent(stopQueue)) for a stop-button feature: the conversion silently disabled the entire activity retry path until we noticed the flattening in the bridge source and added a shape-sniffing workaround (parse the value; if it isn't the expected JSON payload, re-throw it as an error). That workaround only works because our activity returns structured JSON, not strings.

Repro

runtime.registerActivity("Boom", async () => { throw new Error("kaboom"); });

runtime.registerOrchestration("RaceBoom", function* (ctx) {
    try {
        const winner = yield ctx.race(
            ctx.scheduleActivity("Boom", null),
            ctx.scheduleTimer(60_000),
        );
        // Reached with winner = { index: 0, value: "kaboom" } — no throw.
        return `unexpected success: ${JSON.stringify(winner)}`;
    } catch (err) {
        return `caught: ${err.message}`;   // never reached
    }
});

Expected (to match direct-yield semantics): the catch fires, or the winner carries an explicit error marker.
Actual: the orchestration completes with unexpected success: {"index":0,"value":"kaboom"}.

Possible fixes

  1. Throw into the generator when the select winner is a failed branch — consistent with the direct-yield contract. (Losing branches are already cancel-requested; only the winner's disposition changes.)
  2. Preserve the marker like join does: resolve select with { index, ok } / { index, err } (or { index, value, isError }). Breaking change for existing callers, but the current shape is unreliable anyway.
  3. At minimum, document the flattening prominently on race() / raceTyped() in the README and JSDoc — today it's only visible in a Rust source comment.

Option 1 seems most consistent; option 2 is more expressive if you'd rather races never throw.

Environment

  • duroxide-node 0.1.27 (npm), also verified against current main sources (src/handlers.rs make_select_future / ScheduledTask::Select handling)
  • macOS arm64, Node 20+

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions