@pylonsync/functions 0.4.17 → 0.4.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/define.d.ts CHANGED
@@ -41,18 +41,28 @@ interface CommonDef<TSchema extends ValidatorSchema | undefined = ValidatorSchem
41
41
  */
42
42
  internal?: boolean;
43
43
  /**
44
- * Max wall-clock SECONDS this function may run before the runtime
45
- * recycles its worker as wedged. Defaults to `PYLON_FN_CALL_TIMEOUT`
46
- * (30s). Raise it for legitimately long-running work — heavy renders,
47
- * big batch jobs, slow external calls — so the call isn't force-killed
48
- * mid-flight. This also lifts the runtime's wedge backstop for the
49
- * worker while such a call is in flight, so a busy-but-progressing
50
- * worker (e.g. one doing synchronous canvas/image work that blocks the
51
- * event loop) isn't respawned out from under the work.
44
+ * IDLE-timeout SECONDS for this function: how long it may go without
45
+ * producing any activity (a stream chunk, a `ctx.db` op, an LLM
46
+ * event) before the host cancels the call. Defaults to
47
+ * `PYLON_FN_CALL_TIMEOUT` (30s). Activity restarts the budget, so a
48
+ * streaming agent run stays alive as long as it keeps producing; a
49
+ * silent hang is cancelled at the budget. Total lifetime is capped at
50
+ * 10× this value however chatty the call is.
52
51
  *
53
- * Keep it as small as the work honestly needs: a genuinely stuck call
54
- * still ties up its worker until this deadline. Prefer offloading very
55
- * heavy CPU work to a dedicated service over setting a huge timeout.
52
+ * Cancellation is per-call: the handler's next `ctx.*` call throws
53
+ * `CALL_CANCELLED` and `ctx.signal` aborts — other in-flight calls on
54
+ * the same worker are untouched. CAVEAT: the database transaction
55
+ * rolls back on cancel, but non-ctx work already in flight (a `fetch`,
56
+ * a payment SDK call) runs to completion unless you pass
57
+ * `{ signal: ctx.signal }` — a cancelled call can otherwise leave an
58
+ * external effect committed with its DB writes rolled back. Thread
59
+ * ctx.signal into every external call that must not outlive the
60
+ * handler. Raise the timeout for legitimately long
61
+ * SILENT work (a single slow external call, synchronous CPU work);
62
+ * this also lifts the runtime's wedge backstop for the worker while
63
+ * such a call is in flight, so a busy-but-progressing worker (e.g.
64
+ * one doing synchronous canvas/image work that blocks the event loop)
65
+ * isn't respawned out from under the work.
56
66
  */
57
67
  timeout?: number;
58
68
  }
package/dist/index.d.ts CHANGED
@@ -19,7 +19,9 @@
19
19
  */
20
20
  export { query, mutation, action } from "./define";
21
21
  export { v } from "./validators";
22
+ export { workflow } from "./workflows";
23
+ export type { WorkflowDefinition, WorkflowRun, WorkflowRunRequest, WorkflowRunnerResponse, WorkflowStepResult, } from "./workflows";
22
24
  export { resetDb, installTestIsolation } from "./testing";
23
25
  export { slugifyName, availableSlug } from "./slugify";
24
26
  export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
25
- export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, MemberRow, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, } from "./types";
27
+ export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, MemberRow, Workflows, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, } from "./types";
package/dist/runtime.d.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * each ctx.db / ctx.scheduler / ctx.runMutation call), so the map never
15
15
  * needs to queue multiple RPCs per call_id.
16
16
  */
17
- import type { DbReader, DbWriter, Llm, Rooms } from "./types";
17
+ import type { DbReader, DbWriter, Llm, Rooms, Workflows } from "./types";
18
18
  export declare function buildDbReader(callId: string, ssrRead?: boolean): DbReader;
19
19
  export declare function buildSsrFnCaller(callId: string): (name: string, args?: Record<string, unknown>) => Promise<unknown>;
20
20
  export declare function buildDbWriter(callId: string): DbWriter;
@@ -39,3 +39,11 @@ export declare function buildLlm(callId: string): Llm;
39
39
  * on subscribers identically.
40
40
  */
41
41
  export declare function buildRooms(callId: string): Rooms;
42
+ /**
43
+ * Build `ctx.workflows` — start a durable workflow or deliver an event
44
+ * to a waiting one, from mutation/action code. Round-trips a
45
+ * `workflow_op` frame to the host's WorkflowEngine; the engine's driver
46
+ * takes it from there, so `start` returns the instance id immediately.
47
+ * Uses the queued call_id-keyed `rpc` (the reply carries no op_id).
48
+ */
49
+ export declare function buildWorkflows(callId: string): Workflows;
package/dist/types.d.ts CHANGED
@@ -392,6 +392,29 @@ export interface Rooms {
392
392
  delivered: boolean;
393
393
  }>;
394
394
  }
395
+ /**
396
+ * Durable workflows, driven from app code (`ctx.workflows` on mutations
397
+ * and actions — not queries, whose reactive re-runs would re-start).
398
+ * Workflows are declared in the app's `workflows/` directory; see the
399
+ * `workflow()` export.
400
+ */
401
+ export interface Workflows {
402
+ /**
403
+ * Start a workflow instance by name. Returns immediately with the
404
+ * instance id — the engine's background driver executes the steps.
405
+ */
406
+ start(name: string, input?: unknown): Promise<{
407
+ id: string;
408
+ }>;
409
+ /**
410
+ * Deliver an event to an instance paused on
411
+ * `wf.waitForEvent(event)`. Rejects when the instance isn't waiting
412
+ * for that event.
413
+ */
414
+ sendEvent(workflowId: string, event: string, data?: unknown): Promise<{
415
+ delivered: boolean;
416
+ }>;
417
+ }
395
418
  export interface LlmMessage {
396
419
  role: "user" | "assistant" | "system" | "tool";
397
420
  content: string | LlmContentBlock[];
@@ -607,6 +630,14 @@ export interface QueryCtx<R extends AuthRequirement = "optional"> {
607
630
  requireMember: RequireMember;
608
631
  /** Signed file-download URLs — see {@link Files}. */
609
632
  files: Files;
633
+ /**
634
+ * Fires when the host cancels this call (idle timeout exceeded).
635
+ * Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
636
+ * a cancelled call stops its outbound work too — the runtime already
637
+ * makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
638
+ * because older hosts don't send cancel frames.
639
+ */
640
+ signal?: AbortSignal;
610
641
  }
611
642
  /** Context for mutation handlers (read + write, transactional). */
612
643
  export interface MutationCtx<R extends AuthRequirement = "optional"> {
@@ -622,12 +653,22 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
622
653
  rooms: Rooms;
623
654
  /** Per-user OAuth connection registry. */
624
655
  connections: Connections;
656
+ /** Durable workflows: start / deliver events — see {@link Workflows}. */
657
+ workflows: Workflows;
625
658
  /** Signed file-download URLs — see {@link Files}. */
626
659
  files: Files;
627
660
  /** Create a typed error that triggers rollback. */
628
661
  error(code: string, message: string): Error;
629
662
  /** Assert org membership (optionally a role) — see {@link RequireMember}. */
630
663
  requireMember: RequireMember;
664
+ /**
665
+ * Fires when the host cancels this call (idle timeout exceeded).
666
+ * Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
667
+ * a cancelled call stops its outbound work too — the runtime already
668
+ * makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
669
+ * because older hosts don't send cancel frames.
670
+ */
671
+ signal?: AbortSignal;
631
672
  }
632
673
  /** Context for action handlers (external I/O, non-transactional). */
633
674
  export interface ActionCtx<R extends AuthRequirement = "optional"> {
@@ -642,6 +683,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
642
683
  rooms: Rooms;
643
684
  /** Per-user OAuth connection registry. */
644
685
  connections: Connections;
686
+ /** Durable workflows: start / deliver events — see {@link Workflows}. */
687
+ workflows: Workflows;
645
688
  /** Environment variables / secrets. */
646
689
  env: Record<string, string>;
647
690
  /** Signed file-download URLs — see {@link Files}. */
@@ -654,6 +697,14 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
654
697
  error(code: string, message: string): Error;
655
698
  /** Assert org membership (optionally a role) — see {@link RequireMember}. */
656
699
  requireMember: RequireMember;
700
+ /**
701
+ * Fires when the host cancels this call (idle timeout exceeded).
702
+ * Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
703
+ * a cancelled call stops its outbound work too — the runtime already
704
+ * makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
705
+ * because older hosts don't send cancel frames.
706
+ */
707
+ signal?: AbortSignal;
657
708
  /**
658
709
  * HTTP request metadata — present only when the action was invoked via
659
710
  * a `defineRoute` HTTP binding. Missing when the action is called from
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Durable workflows: authoring surface + the per-slice executor.
3
+ *
4
+ * A workflow is a TS function that composes `step` / `sleep` /
5
+ * `waitForEvent` calls. The Rust WorkflowEngine owns the durable state
6
+ * (instance, step results, wake timers — persisted in SQLite); this
7
+ * module executes ONE slice at a time by REPLAY: on every advance the
8
+ * whole workflow function re-runs, already-completed steps return their
9
+ * recorded outputs without executing, and the first not-yet-done
10
+ * primitive either executes (a `step` at the current index) or pauses
11
+ * the run (`sleep` / `waitForEvent`).
12
+ *
13
+ * Determinism contract for authors: the sequence of step/sleep/
14
+ * waitForEvent calls must be identical on every replay for the same
15
+ * input + recorded outputs. Branch on `input` and on step OUTPUTS all
16
+ * you like — never on wall-clock time, randomness, or external state
17
+ * read outside a step.
18
+ *
19
+ * Files live in the app's `workflows/` directory (sibling of
20
+ * `functions/`), one default-exported `workflow(...)` per file. The
21
+ * runtime reports them in its ready handshake; the host registers them
22
+ * and drives execution back through the function pool as an internal
23
+ * `__pylon_workflow_run` action call — so step code runs with a full
24
+ * ActionCtx (ctx.db, ctx.llm, ctx.scheduler, the idle timeout, and
25
+ * cancellation) rather than in a bespoke side-channel process.
26
+ */
27
+ import type { ActionCtx } from "./types";
28
+ /** Wire shape of one recorded step — mirrors Rust `StepResult` exactly. */
29
+ export interface WorkflowStepResult {
30
+ step_id: string;
31
+ name: string;
32
+ status: "pending" | "running" | "completed" | "failed" | "skipped";
33
+ output?: unknown;
34
+ error?: string | null;
35
+ started_at?: string | null;
36
+ completed_at?: string | null;
37
+ duration_ms?: number | null;
38
+ retry_count?: number;
39
+ }
40
+ /** The advance request the Rust engine sends for one slice. */
41
+ export interface WorkflowRunRequest {
42
+ workflow_id: string;
43
+ workflow_name: string;
44
+ input: unknown;
45
+ current_step: number;
46
+ completed_steps: WorkflowStepResult[];
47
+ }
48
+ /** The verdict of one slice — mirrors Rust `apply_response`'s actions. */
49
+ export type WorkflowRunnerResponse = {
50
+ action: "step_complete";
51
+ step_name: string;
52
+ output: unknown;
53
+ duration_ms: number;
54
+ } | {
55
+ action: "sleep";
56
+ duration: string;
57
+ } | {
58
+ action: "wait_event";
59
+ event: string;
60
+ } | {
61
+ action: "complete";
62
+ output: unknown;
63
+ } | {
64
+ action: "fail";
65
+ error: string;
66
+ step_name?: string;
67
+ };
68
+ /** What a workflow function receives, besides the per-slice ActionCtx. */
69
+ export interface WorkflowRun<TInput = unknown> {
70
+ /** The workflow instance id (stable across slices + restarts). */
71
+ id: string;
72
+ /** The input `start()` was called with. */
73
+ input: TInput;
74
+ /**
75
+ * Run a named step exactly once. On replay a completed step returns
76
+ * its recorded output without executing. Step names must be unique
77
+ * within one workflow run — the replay cache is name-keyed.
78
+ */
79
+ step<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
80
+ /** Pause the workflow for a duration ("30s", "5m", "24h", "7d"). */
81
+ sleep(duration: string): Promise<void>;
82
+ /**
83
+ * Pause until `POST /api/workflows/<id>/event` delivers this event.
84
+ * Resolves with the event's data payload.
85
+ */
86
+ waitForEvent<T = unknown>(eventName: string): Promise<T>;
87
+ }
88
+ export interface WorkflowDefinition<TInput = unknown> {
89
+ readonly __pylonWorkflow: true;
90
+ name: string;
91
+ description?: string;
92
+ /** Max retries per step before the workflow fails (engine default: 3). */
93
+ maxRetries?: number;
94
+ fn: (wf: WorkflowRun<TInput>, ctx: ActionCtx) => Promise<unknown>;
95
+ }
96
+ /**
97
+ * Declare a workflow. Default-export the result from a file in the
98
+ * app's `workflows/` directory:
99
+ *
100
+ * ```ts
101
+ * // workflows/onboarding.ts
102
+ * import { workflow } from "@pylonsync/functions";
103
+ *
104
+ * export default workflow("onboarding", async (wf, ctx) => {
105
+ * const user = await wf.step("load-user", () =>
106
+ * ctx.runQuery("getUser", { id: wf.input.userId }),
107
+ * );
108
+ * await wf.step("send-welcome", () =>
109
+ * ctx.email.send({ to: user.email, subject: "Welcome!", text: "..." }),
110
+ * );
111
+ * await wf.sleep("24h");
112
+ * const confirmed = await wf.waitForEvent("email_confirmed");
113
+ * return { done: true, confirmed };
114
+ * });
115
+ * ```
116
+ */
117
+ export declare function workflow<TInput = unknown>(name: string, fn: (wf: WorkflowRun<TInput>, ctx: ActionCtx) => Promise<unknown>, opts?: {
118
+ description?: string;
119
+ maxRetries?: number;
120
+ }): WorkflowDefinition<TInput>;
121
+ /** Runtime shape check for a `workflows/` file's default export. */
122
+ export declare function isWorkflowDefinition(v: unknown): v is WorkflowDefinition;
123
+ /**
124
+ * Execute one slice of `def` for `request`, returning the engine verdict.
125
+ * Never throws for handler errors — a step failure becomes
126
+ * `{action: "fail"}` so the engine's retry accounting runs.
127
+ */
128
+ export declare function executeWorkflowSlice(def: WorkflowDefinition, request: WorkflowRunRequest, ctx: ActionCtx): Promise<WorkflowRunnerResponse>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.4.17",
3
+ "version": "0.4.19",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/define.ts CHANGED
@@ -56,18 +56,28 @@ interface CommonDef<
56
56
  */
57
57
  internal?: boolean;
58
58
  /**
59
- * Max wall-clock SECONDS this function may run before the runtime
60
- * recycles its worker as wedged. Defaults to `PYLON_FN_CALL_TIMEOUT`
61
- * (30s). Raise it for legitimately long-running work — heavy renders,
62
- * big batch jobs, slow external calls — so the call isn't force-killed
63
- * mid-flight. This also lifts the runtime's wedge backstop for the
64
- * worker while such a call is in flight, so a busy-but-progressing
65
- * worker (e.g. one doing synchronous canvas/image work that blocks the
66
- * event loop) isn't respawned out from under the work.
59
+ * IDLE-timeout SECONDS for this function: how long it may go without
60
+ * producing any activity (a stream chunk, a `ctx.db` op, an LLM
61
+ * event) before the host cancels the call. Defaults to
62
+ * `PYLON_FN_CALL_TIMEOUT` (30s). Activity restarts the budget, so a
63
+ * streaming agent run stays alive as long as it keeps producing; a
64
+ * silent hang is cancelled at the budget. Total lifetime is capped at
65
+ * 10× this value however chatty the call is.
67
66
  *
68
- * Keep it as small as the work honestly needs: a genuinely stuck call
69
- * still ties up its worker until this deadline. Prefer offloading very
70
- * heavy CPU work to a dedicated service over setting a huge timeout.
67
+ * Cancellation is per-call: the handler's next `ctx.*` call throws
68
+ * `CALL_CANCELLED` and `ctx.signal` aborts — other in-flight calls on
69
+ * the same worker are untouched. CAVEAT: the database transaction
70
+ * rolls back on cancel, but non-ctx work already in flight (a `fetch`,
71
+ * a payment SDK call) runs to completion unless you pass
72
+ * `{ signal: ctx.signal }` — a cancelled call can otherwise leave an
73
+ * external effect committed with its DB writes rolled back. Thread
74
+ * ctx.signal into every external call that must not outlive the
75
+ * handler. Raise the timeout for legitimately long
76
+ * SILENT work (a single slow external call, synchronous CPU work);
77
+ * this also lifts the runtime's wedge backstop for the worker while
78
+ * such a call is in flight, so a busy-but-progressing worker (e.g.
79
+ * one doing synchronous canvas/image work that blocks the event loop)
80
+ * isn't respawned out from under the work.
71
81
  */
72
82
  timeout?: number;
73
83
  }
package/src/index.ts CHANGED
@@ -20,6 +20,14 @@
20
20
 
21
21
  export { query, mutation, action } from "./define";
22
22
  export { v } from "./validators";
23
+ export { workflow } from "./workflows";
24
+ export type {
25
+ WorkflowDefinition,
26
+ WorkflowRun,
27
+ WorkflowRunRequest,
28
+ WorkflowRunnerResponse,
29
+ WorkflowStepResult,
30
+ } from "./workflows";
23
31
  export { resetDb, installTestIsolation } from "./testing";
24
32
  export { slugifyName, availableSlug } from "./slugify";
25
33
  // SSR page response controller — pages/layouts receive `response` in
@@ -54,6 +62,7 @@ export type {
54
62
  RequireMember,
55
63
  RequireMemberOptions,
56
64
  MemberRow,
65
+ Workflows,
57
66
  // LLM + realtime surfaces. A handler writing an agent tool loop
58
67
  // builds its own message array and branches on stream events, so
59
68
  // these have to be nameable from app code.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Tests for host-initiated call cancellation (runtime.ts `cancel` frames).
3
+ *
4
+ * The contract under test:
5
+ *
6
+ * 1. A `cancel` frame for a call REJECTS that call's in-flight RPC
7
+ * promises with code CALL_CANCELLED (the handler's `await ctx.db.*`
8
+ * unwinds instead of hanging on a reply that will never come).
9
+ * 2. Any LATER ctx RPC from the cancelled call throws CALL_CANCELLED —
10
+ * this is what actually stops a typical handler mid-loop.
11
+ * 3. A different call on the same runner is untouched: its RPCs still
12
+ * round-trip normally. This is the whole point of the change — the
13
+ * host used to kill the entire child on one call's timeout.
14
+ *
15
+ * Same child-process harness as runtime-llm-stream.test.ts: runtime.ts
16
+ * runs main() on import, so we drive the REAL dispatcher over NDJSON on
17
+ * stdin/stdout exactly as the Rust host would.
18
+ */
19
+ import { expect, test } from "bun:test";
20
+ import { mkdtempSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ const RUNTIME = join(import.meta.dir, "runtime.ts");
25
+
26
+ const SCRIPT = `
27
+ import { buildDbReader } from ${JSON.stringify(RUNTIME)};
28
+
29
+ const victim = buildDbReader("c_1");
30
+ const bystander = buildDbReader("c_2");
31
+
32
+ const out = {};
33
+
34
+ // In-flight RPC on the call the host will cancel.
35
+ const victimFirst = victim.get("User", "u1").then(
36
+ () => ({ outcome: "resolved" }),
37
+ (e) => ({ outcome: "rejected", code: e.code }),
38
+ );
39
+ // Concurrent RPC on an UNRELATED call — must complete normally.
40
+ const bystanderGet = bystander.get("User", "u2").then(
41
+ (data) => ({ outcome: "resolved", data }),
42
+ (e) => ({ outcome: "rejected", code: e.code }),
43
+ );
44
+
45
+ out.victimFirst = await victimFirst;
46
+ out.bystander = await bystanderGet;
47
+
48
+ // A LATER RPC from the cancelled call must throw synchronously.
49
+ try {
50
+ await victim.get("User", "u3");
51
+ out.victimSecond = { outcome: "resolved" };
52
+ } catch (e) {
53
+ out.victimSecond = { outcome: "rejected", code: e.code };
54
+ }
55
+
56
+ console.error("RESULT " + JSON.stringify(out));
57
+ process.exit(0);
58
+ `;
59
+
60
+ function parseFrames(text: string): Record<string, unknown>[] {
61
+ return text
62
+ .split("\n")
63
+ .filter((l) => l.trim().startsWith("{"))
64
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
65
+ }
66
+
67
+ test("cancel rejects the call's RPCs, poisons later ones, and spares co-tenant calls", async () => {
68
+ const dir = mkdtempSync(join(tmpdir(), "pylon-fn-cancel-"));
69
+ const scriptPath = join(dir, "probe.ts");
70
+ writeFileSync(scriptPath, SCRIPT);
71
+
72
+ const proc = Bun.spawn([process.execPath, scriptPath], {
73
+ stdin: "pipe",
74
+ stdout: "pipe",
75
+ stderr: "pipe",
76
+ });
77
+
78
+ // Wait for both db frames (c_1's and c_2's) so we know their op_ids.
79
+ const reader = proc.stdout.getReader();
80
+ const decoder = new TextDecoder();
81
+ let buffered = "";
82
+ let requests: Record<string, unknown>[] = [];
83
+ const wanted = (fs: Record<string, unknown>[]) =>
84
+ fs.filter((f) => f.type === "db").length >= 2;
85
+ while (!wanted(requests)) {
86
+ const { done, value } = await reader.read();
87
+ if (done) break;
88
+ buffered += decoder.decode(value, { stream: true });
89
+ requests = parseFrames(buffered);
90
+ }
91
+
92
+ const dbFrames = requests.filter((f) => f.type === "db");
93
+ const victimOp = dbFrames.find((f) => f.call_id === "c_1")?.op_id as string;
94
+ const bystanderOp = dbFrames.find((f) => f.call_id === "c_2")
95
+ ?.op_id as string;
96
+ expect(victimOp).toBeTruthy();
97
+ expect(bystanderOp).toBeTruthy();
98
+
99
+ const send = (msg: Record<string, unknown>) =>
100
+ proc.stdin.write(JSON.stringify(msg) + "\n");
101
+
102
+ // Cancel c_1 while its RPC is in flight; answer c_2 normally.
103
+ send({ type: "cancel", call_id: "c_1", reason: "idle timeout 30s exceeded" });
104
+ send({
105
+ type: "result",
106
+ call_id: "c_2",
107
+ op_id: bystanderOp,
108
+ data: { id: "u2", name: "Bystander" },
109
+ });
110
+ await proc.stdin.flush();
111
+
112
+ const stderr = await new Response(proc.stderr).text();
113
+ await proc.exited;
114
+
115
+ const line = stderr.split("\n").find((l) => l.includes("RESULT "));
116
+ expect(line).toBeDefined();
117
+ const out = JSON.parse(line!.slice(line!.indexOf("RESULT ") + 7));
118
+
119
+ // 1. The in-flight RPC rejected with the cancellation code — no hang,
120
+ // no generic RPC-timeout 60s later.
121
+ expect(out.victimFirst).toEqual({
122
+ outcome: "rejected",
123
+ code: "CALL_CANCELLED",
124
+ });
125
+
126
+ // 2. The later RPC threw immediately with the same code.
127
+ expect(out.victimSecond).toEqual({
128
+ outcome: "rejected",
129
+ code: "CALL_CANCELLED",
130
+ });
131
+
132
+ // 3. The co-tenant call was untouched.
133
+ expect(out.bystander).toEqual({
134
+ outcome: "resolved",
135
+ data: { id: "u2", name: "Bystander" },
136
+ });
137
+ });