|
| 1 | +import { |
| 2 | + ClusterSchema, |
| 3 | + Entity, |
| 4 | + MessageStorage, |
| 5 | + RunnerAddress, |
| 6 | + RunnerHealth, |
| 7 | + RunnerStorage, |
| 8 | + ShardingConfig, |
| 9 | + SocketRunner |
| 10 | +} from "@effect/cluster" |
| 11 | +import { NodeClusterSocket } from "@effect/platform-node" |
| 12 | +import { Rpc, RpcSerialization } from "@effect/rpc" |
| 13 | +import { describe, it } from "@effect/vitest" |
| 14 | +import { BigDecimal, Effect, Layer, Logger, LogLevel, Option, PrimaryKey, Schema } from "effect" |
| 15 | + |
| 16 | +class TestPayload extends Schema.Class<TestPayload>("TestPayload")({ |
| 17 | + id: Schema.String, |
| 18 | + amount: Schema.BigDecimal |
| 19 | +}) { |
| 20 | + [PrimaryKey.symbol]() { |
| 21 | + return this.id |
| 22 | + } |
| 23 | +} |
| 24 | + |
| 25 | +const TestEntity = Entity |
| 26 | + .make("TestEntity", [ |
| 27 | + Rpc.make("Process", { |
| 28 | + payload: TestPayload, |
| 29 | + success: Schema.Void |
| 30 | + }) |
| 31 | + ]) |
| 32 | + .annotateRpcs(ClusterSchema.Persisted, true) |
| 33 | + .annotateRpcs(ClusterSchema.Uninterruptible, true) |
| 34 | + |
| 35 | +const TestEntityLayer = TestEntity.toLayer( |
| 36 | + Effect.succeed({ |
| 37 | + Process: () => Effect.void |
| 38 | + }) |
| 39 | +) |
| 40 | + |
| 41 | +const RUNNER_PORT = 50_123 |
| 42 | +// Build shared storage instances once, so runner and client see the same state. |
| 43 | +// MessageStorage.layerMemory requires ShardingConfig, so we provide a minimal one. |
| 44 | +const SharedStorage = Layer.mergeAll( |
| 45 | + RunnerStorage.layerMemory, |
| 46 | + MessageStorage.layerMemory |
| 47 | +).pipe( |
| 48 | + Layer.provide(ShardingConfig.layerDefaults) |
| 49 | +) |
| 50 | + |
| 51 | +const makeRunnerLayer = (port: number) => |
| 52 | + TestEntityLayer.pipe( |
| 53 | + Layer.provideMerge(SocketRunner.layer), |
| 54 | + Layer.provide(RunnerHealth.layerNoop), |
| 55 | + Layer.provide(NodeClusterSocket.layerSocketServer), |
| 56 | + Layer.provide(NodeClusterSocket.layerClientProtocol), |
| 57 | + Layer.provide(ShardingConfig.layer({ |
| 58 | + runnerAddress: Option.some(RunnerAddress.make("localhost", port)), |
| 59 | + entityTerminationTimeout: 0, |
| 60 | + entityMessagePollInterval: 5000, |
| 61 | + sendRetryInterval: 100 |
| 62 | + })), |
| 63 | + Layer.provide(RpcSerialization.layerMsgPack) |
| 64 | + ) |
| 65 | + |
| 66 | +const makeClientLayer = (port: number) => |
| 67 | + SocketRunner.layerClientOnly.pipe( |
| 68 | + Layer.provide(NodeClusterSocket.layerClientProtocol), |
| 69 | + Layer.provide(ShardingConfig.layer({ |
| 70 | + runnerAddress: Option.some(RunnerAddress.make("localhost", port)), |
| 71 | + runnerListenAddress: Option.some(RunnerAddress.make("localhost", port)), |
| 72 | + entityTerminationTimeout: 0, |
| 73 | + entityMessagePollInterval: 5000, |
| 74 | + sendRetryInterval: 100 |
| 75 | + })), |
| 76 | + Layer.provide(RpcSerialization.layerMsgPack) |
| 77 | + ) |
| 78 | + |
| 79 | +// BigDecimal.normalize creates a circular `normalized` self-reference. |
| 80 | +// When a persisted message is sent with discard: true, the notify path in Runners.makeRpc |
| 81 | +// passes the raw envelope (with circular BigDecimal payload) to the runner via msgpack, |
| 82 | +// causing RangeError: Maximum call stack size exceeded. |
| 83 | +describe("SocketRunner", () => { |
| 84 | + it.scopedLive( |
| 85 | + "entity call with BigDecimal and discard should not stack overflow", |
| 86 | + () => |
| 87 | + Effect.gen(function*() { |
| 88 | + // Start the runner (with socket server and entity handler) |
| 89 | + yield* Layer.launch(makeRunnerLayer(RUNNER_PORT)).pipe(Effect.forkScoped) |
| 90 | + |
| 91 | + // Give the runner time to start and acquire shards |
| 92 | + yield* Effect.sleep("2 seconds") |
| 93 | + yield* Effect.log("Before starting the client") |
| 94 | + |
| 95 | + // Send a message from the client with discard: true. |
| 96 | + // The BigDecimal is normalized to trigger the circular `normalized` self-reference. |
| 97 | + yield* Effect.gen(function*() { |
| 98 | + yield* Effect.log("Starting the client") |
| 99 | + yield* Effect.sleep("2 seconds") |
| 100 | + const makeClient = yield* TestEntity.client |
| 101 | + // Give the client time to discover the runner |
| 102 | + yield* Effect.sleep("3 seconds") |
| 103 | + const client = makeClient("entity-1") |
| 104 | + |
| 105 | + const amount = BigDecimal.unsafeFromString("123.45") |
| 106 | + |
| 107 | + yield* client.Process( |
| 108 | + TestPayload.make({ id: "req-1", amount }), |
| 109 | + { discard: true } |
| 110 | + ) |
| 111 | + }).pipe( |
| 112 | + Effect.provide(makeClientLayer(RUNNER_PORT)), |
| 113 | + Effect.scoped |
| 114 | + ) |
| 115 | + }).pipe(Effect.provide( |
| 116 | + SharedStorage.pipe(Layer.provideMerge( |
| 117 | + Logger.minimumLogLevel(LogLevel.None) |
| 118 | + )) |
| 119 | + )), |
| 120 | + 30_000 |
| 121 | + ) |
| 122 | +}) |
0 commit comments