From 8f54ca4cca8749b5116de8aef754ba2f74b8a5a6 Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 7 Jul 2026 14:29:53 -0700 Subject: [PATCH] fix: treat empty entity operation input as no input The entity executor parsed operation input with an inline `inputValue ? JSON.parse(inputValue.getValue()) : undefined` guard. A protobuf StringValue is always a truthy object, so an empty StringValue wrapping "" passed the guard and reached JSON.parse(""), which throws "SyntaxError: Unexpected end of JSON input". The operation then failed and rolled back instead of running with no input. Use the shared parseJsonField() helper (already used by the orchestration and activity executors), which returns undefined for a null/empty StringValue and wraps genuine parse errors with context via `cause`. Adds regression tests covering empty-wrapper input, verifying the operation is dispatched with no input, and confirming a genuine empty-string JSON value ('""') is still delivered as "". Fixes #238 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/worker/entity-executor.ts | 7 +- .../test/entity-executor.spec.ts | 86 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/durabletask-js/src/worker/entity-executor.ts b/packages/durabletask-js/src/worker/entity-executor.ts index 1061c1a..e97a5de 100644 --- a/packages/durabletask-js/src/worker/entity-executor.ts +++ b/packages/durabletask-js/src/worker/entity-executor.ts @@ -11,6 +11,7 @@ import * as pb from "../proto/orchestrator_service_pb"; import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb"; import { Timestamp } from "google-protobuf/google/protobuf/timestamp_pb"; import { randomUUID } from "crypto"; +import { parseJsonField } from "../utils/pb-helper.util"; /** * Internal type representing actions collected during entity execution. @@ -356,7 +357,11 @@ export class TaskEntityShim { try { const inputValue = opRequest.getInput(); - const input = inputValue ? JSON.parse(inputValue.getValue()) : undefined; + // Use the shared parseJsonField helper so an empty StringValue is treated as + // "no input" (returns undefined) instead of crashing on JSON.parse(""). A + // StringValue object is always truthy, so the previous `inputValue ? ...` + // guard did not protect against an empty wrapped string. + const input = parseJsonField(inputValue); // Set operation details this.operationShim.setNameAndInput(opRequest.getOperation(), input); diff --git a/packages/durabletask-js/test/entity-executor.spec.ts b/packages/durabletask-js/test/entity-executor.spec.ts index aa462d2..232417c 100644 --- a/packages/durabletask-js/test/entity-executor.spec.ts +++ b/packages/durabletask-js/test/entity-executor.spec.ts @@ -69,6 +69,17 @@ class CounterEntity extends TaskEntity<{ count: number }> { } } +// Entity used to assert how operation input is delivered to the dispatched method. +class InputCaptureEntity extends TaskEntity<{ calls: number }> { + capture(input?: unknown): { hadInput: boolean; value: unknown } { + return { hadInput: input !== undefined, value: input ?? null }; + } + + protected initializeState(): { calls: number } { + return { calls: 0 }; + } +} + describe("TaskEntityShim", () => { const entityId = new EntityInstanceId("counter", "test"); @@ -614,4 +625,79 @@ describe("TaskEntityShim", () => { expect(JSON.parse(result.getResultsList()[1].getSuccess()!.getResult()!.getValue())).toBe(10); }); }); + + describe("empty operation input", () => { + it("should treat an empty StringValue input as no input instead of throwing SyntaxError", async () => { + const entity = new CounterEntity(); + const shim = new TaskEntityShim(entity, entityId); + + // Manually build a request whose operation input is an empty StringValue. + // A StringValue wrapping "" is a truthy object, so the previous inline + // `inputValue ? JSON.parse(inputValue.getValue()) : undefined` guard used to + // call JSON.parse("") and throw "Unexpected end of JSON input", failing the + // operation and rolling it back instead of treating it as no input. + const request = new pb.EntityBatchRequest(); + request.setInstanceid(entityId.toString()); + + const stateValue = new StringValue(); + stateValue.setValue(JSON.stringify({ count: 7 })); + request.setEntitystate(stateValue); + + const opRequest = new pb.OperationRequest(); + opRequest.setOperation("get"); + opRequest.setInput(new StringValue()); // empty StringValue, getValue() === "" + request.addOperations(opRequest); + + const result = await shim.executeAsync(request); + + const opResult = result.getResultsList()[0]; + expect(opResult.hasFailure()).toBe(false); + expect(opResult.hasSuccess()).toBe(true); + expect(JSON.parse(opResult.getSuccess()!.getResult()!.getValue())).toBe(7); + }); + + it("should dispatch the operation with no input when the StringValue is empty", async () => { + const entity = new InputCaptureEntity(); + const shim = new TaskEntityShim(entity, entityId); + + const request = new pb.EntityBatchRequest(); + request.setInstanceid(entityId.toString()); + + const opRequest = new pb.OperationRequest(); + opRequest.setOperation("capture"); + opRequest.setInput(new StringValue()); // empty StringValue + request.addOperations(opRequest); + + const result = await shim.executeAsync(request); + + const opResult = result.getResultsList()[0]; + expect(opResult.hasSuccess()).toBe(true); + const captured = JSON.parse(opResult.getSuccess()!.getResult()!.getValue()); + expect(captured).toEqual({ hadInput: false, value: null }); + }); + + it("should still deliver a genuine empty-string JSON value when explicitly provided", async () => { + // Sanity check: a StringValue holding the JSON text '""' is a real empty + // string input and must be delivered as "" (not dropped as "no input"). + const entity = new InputCaptureEntity(); + const shim = new TaskEntityShim(entity, entityId); + + const request = new pb.EntityBatchRequest(); + request.setInstanceid(entityId.toString()); + + const opRequest = new pb.OperationRequest(); + opRequest.setOperation("capture"); + const inputValue = new StringValue(); + inputValue.setValue(JSON.stringify("")); // the two-character string: "" + opRequest.setInput(inputValue); + request.addOperations(opRequest); + + const result = await shim.executeAsync(request); + + const opResult = result.getResultsList()[0]; + expect(opResult.hasSuccess()).toBe(true); + const captured = JSON.parse(opResult.getSuccess()!.getResult()!.getValue()); + expect(captured).toEqual({ hadInput: true, value: "" }); + }); + }); });