From 558db4f54078e0f7dbf4dbe9c51d29689c273481 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 08:28:51 +0000 Subject: [PATCH] fix: serialize custom status eagerly to prevent executor crash setCustomStatus() now serializes the value immediately via JSON.stringify() instead of deferring serialization to getCustomStatus(). This ensures that non-serializable values (circular references, BigInt, etc.) throw inside the orchestrator's try-catch, properly failing the orchestration instead of crashing the executor and losing the orchestration result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../worker/runtime-orchestration-context.ts | 27 +++-- .../orchestration_context_methods.spec.ts | 100 ++++++++++++++++++ 2 files changed, 121 insertions(+), 6 deletions(-) diff --git a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts index 7d77dd3..157e036 100644 --- a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts +++ b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts @@ -48,7 +48,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { _pendingEvents: Record[]>; _newInput?: any; _saveEvents: boolean; - _customStatus?: any; + _customStatus?: string; _entityFeature: RuntimeOrchestrationEntityFeature; constructor(instanceId: string) { @@ -402,20 +402,35 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { /** * Sets a custom status value for the current orchestration instance. + * + * The value is serialized eagerly via JSON.stringify so that serialization + * errors surface inside the orchestrator execution (where they are caught + * by the executor's try-catch) rather than after execution completes. */ setCustomStatus(customStatus: any): void { - this._customStatus = customStatus; + if (customStatus === undefined || customStatus === null) { + this._customStatus = undefined; + return; + } + + try { + this._customStatus = JSON.stringify(customStatus); + } catch (e) { + throw new Error( + `Custom status value is not JSON-serializable: ${e instanceof Error ? e.message : String(e)}`, + { cause: e }, + ); + } } /** * Gets the encoded custom status value for the current orchestration instance. * This is used internally when building the orchestrator response. + * + * Returns the pre-serialized JSON string set by setCustomStatus(). */ getCustomStatus(): string | undefined { - if (this._customStatus === undefined || this._customStatus === null) { - return undefined; - } - return JSON.stringify(this._customStatus); + return this._customStatus; } /** diff --git a/packages/durabletask-js/test/orchestration_context_methods.spec.ts b/packages/durabletask-js/test/orchestration_context_methods.spec.ts index 9eea1f5..e878e38 100644 --- a/packages/durabletask-js/test/orchestration_context_methods.spec.ts +++ b/packages/durabletask-js/test/orchestration_context_methods.spec.ts @@ -151,6 +151,106 @@ describe("OrchestrationContext.setCustomStatus", () => { // The last set value should be returned expect(result.customStatus).toEqual('"step3"'); }); + + it("should fail the orchestration when custom status contains a circular reference", async () => { + const circular: Record = { key: "value" }; + circular.self = circular; + + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext) => { + ctx.setCustomStatus(circular); + return "done"; + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const newEvents = [ + newOrchestratorStartedEvent(), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + // The orchestration should fail gracefully instead of crashing the executor + const completeAction = result.actions.find((a) => a.getCompleteorchestration()); + expect(completeAction).toBeDefined(); + expect(completeAction?.getCompleteorchestration()?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + ); + const failureDetails = completeAction?.getCompleteorchestration()?.getFailuredetails(); + expect(failureDetails?.getErrormessage()).toContain("not JSON-serializable"); + }); + + it("should fail the orchestration when custom status contains a BigInt", async () => { + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext) => { + ctx.setCustomStatus({ count: BigInt(42) }); + return "done"; + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const newEvents = [ + newOrchestratorStartedEvent(), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + // The orchestration should fail gracefully instead of crashing the executor + const completeAction = result.actions.find((a) => a.getCompleteorchestration()); + expect(completeAction).toBeDefined(); + expect(completeAction?.getCompleteorchestration()?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + ); + const failureDetails = completeAction?.getCompleteorchestration()?.getFailuredetails(); + expect(failureDetails?.getErrormessage()).toContain("not JSON-serializable"); + }); + + it("should allow clearing custom status by setting it to null", async () => { + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext) => { + ctx.setCustomStatus("initial"); + ctx.setCustomStatus(null); + return "done"; + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const newEvents = [ + newOrchestratorStartedEvent(), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + expect(result.customStatus).toBeUndefined(); + }); + + it("should preserve orchestration result when non-serializable status is set after a valid one", async () => { + const circular: Record = { key: "value" }; + circular.self = circular; + + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext) => { + ctx.setCustomStatus("good status"); + ctx.setCustomStatus(circular); // This should throw + return "done"; + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const newEvents = [ + newOrchestratorStartedEvent(), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + // The orchestration should fail (not crash) — the serialization error + // is thrown inside orchestrator code and caught by the executor + const completeAction = result.actions.find((a) => a.getCompleteorchestration()); + expect(completeAction).toBeDefined(); + expect(completeAction?.getCompleteorchestration()?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + ); + }); }); describe("OrchestrationContext.sendEvent", () => {