| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { vi } from "vite-plus/test"; | 2 | import { vi } from "vite-plus/test"; |
| 3 | import * as Deferred from "effect/Deferred"; | 3 | import * as Deferred from "effect/Deferred"; |
| | 4 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 5 | import * as Fiber from "effect/Fiber"; | 6 | import * as Fiber from "effect/Fiber"; |
| 6 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| ... | @@ -8,6 +9,7 @@ import * as Option from "effect/Option"; | ... | @@ -8,6 +9,7 @@ import * as Option from "effect/Option"; |
| 8 | import * as PlatformError from "effect/PlatformError"; | 9 | import * as PlatformError from "effect/PlatformError"; |
| 9 | import * as Sink from "effect/Sink"; | 10 | import * as Sink from "effect/Sink"; |
| 10 | import * as Stream from "effect/Stream"; | 11 | import * as Stream from "effect/Stream"; |
| | 12 | import * as TestClock from "effect/testing/TestClock"; |
| 11 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 13 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 12 | import * as RelayClient from "@t3tools/shared/relayClient"; | 14 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 13 | | 15 | |
| ... | @@ -283,6 +285,126 @@ describe("CloudManagedEndpointRuntime", () => { | ... | @@ -283,6 +285,126 @@ describe("CloudManagedEndpointRuntime", () => { |
| 283 | }), | 285 | }), |
| 284 | ); | 286 | ); |
| 285 | | 287 | |
| | 288 | const makeCrashLoopSpawner = (basePid: number, slots: number) => |
| | 289 | Effect.gen(function* () { |
| | 290 | const spawned: Array<number> = []; |
| | 291 | const exits: Array<Deferred.Deferred<ChildProcessSpawner.ExitCode>> = []; |
| | 292 | const spawnSignals: Array<Deferred.Deferred<void>> = []; |
| | 293 | for (let index = 0; index < slots; index += 1) { |
| | 294 | exits.push(yield* Deferred.make<ChildProcessSpawner.ExitCode>()); |
| | 295 | spawnSignals.push(yield* Deferred.make<void>()); |
| | 296 | } |
| | 297 | const spawner = ChildProcessSpawner.make(() => |
| | 298 | Effect.gen(function* () { |
| | 299 | const index = spawned.length; |
| | 300 | spawned.push(basePid + index); |
| | 301 | yield* Deferred.succeed(spawnSignals[index]!, undefined); |
| | 302 | const handle = makeHandle({ |
| | 303 | pid: basePid + index, |
| | 304 | exitCode: Deferred.await(exits[index]!), |
| | 305 | onKill: () => {}, |
| | 306 | }); |
| | 307 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); |
| | 308 | return handle; |
| | 309 | }), |
| | 310 | ); |
| | 311 | return { spawner, spawned, exits, spawnSignals }; |
| | 312 | }); |
| | 313 | |
| | 314 | it.effect("backs off restarts while the connector crash-loops", () => |
| | 315 | Effect.gen(function* () { |
| | 316 | const { spawner, spawned, exits, spawnSignals } = yield* makeCrashLoopSpawner(600, 4); |
| | 317 | const runtime = yield* buildCloudManagedEndpointRuntime(spawner); |
| | 318 | |
| | 319 | yield* runtime.applyConfig({ |
| | 320 | providerKind: "cloudflare_tunnel", |
| | 321 | connectorToken: "token", |
| | 322 | tunnelId: "tunnel-1", |
| | 323 | }); |
| | 324 | expect(spawned).toEqual([600]); |
| | 325 | |
| | 326 | // The first crash restarts immediately. |
| | 327 | yield* Deferred.succeed(exits[0]!, ChildProcessSpawner.ExitCode(1)); |
| | 328 | yield* Deferred.await(spawnSignals[1]!); |
| | 329 | expect(spawned).toEqual([600, 601]); |
| | 330 | |
| | 331 | // The second rapid crash waits out the base delay before restarting. |
| | 332 | yield* Deferred.succeed(exits[1]!, ChildProcessSpawner.ExitCode(1)); |
| | 333 | yield* TestClock.adjust(Duration.millis(999)); |
| | 334 | expect(spawned).toEqual([600, 601]); |
| | 335 | yield* TestClock.adjust(Duration.millis(1)); |
| | 336 | yield* Deferred.await(spawnSignals[2]!); |
| | 337 | expect(spawned).toEqual([600, 601, 602]); |
| | 338 | |
| | 339 | // The third rapid crash doubles the delay. |
| | 340 | yield* Deferred.succeed(exits[2]!, ChildProcessSpawner.ExitCode(1)); |
| | 341 | yield* TestClock.adjust(Duration.millis(1999)); |
| | 342 | expect(spawned).toEqual([600, 601, 602]); |
| | 343 | yield* TestClock.adjust(Duration.millis(1)); |
| | 344 | yield* Deferred.await(spawnSignals[3]!); |
| | 345 | expect(spawned).toEqual([600, 601, 602, 603]); |
| | 346 | }).pipe(Effect.provide(TestClock.layer())), |
| | 347 | ); |
| | 348 | |
| | 349 | it.effect("resets the backoff after the connector runs stably", () => |
| | 350 | Effect.gen(function* () { |
| | 351 | const { spawner, spawned, exits, spawnSignals } = yield* makeCrashLoopSpawner(700, 5); |
| | 352 | const runtime = yield* buildCloudManagedEndpointRuntime(spawner); |
| | 353 | |
| | 354 | yield* runtime.applyConfig({ |
| | 355 | providerKind: "cloudflare_tunnel", |
| | 356 | connectorToken: "token", |
| | 357 | }); |
| | 358 | |
| | 359 | // One rapid crash arms the backoff. |
| | 360 | yield* Deferred.succeed(exits[0]!, ChildProcessSpawner.ExitCode(1)); |
| | 361 | yield* Deferred.await(spawnSignals[1]!); |
| | 362 | |
| | 363 | // The replacement stays up past the stable-uptime window, so its exit |
| | 364 | // restarts immediately and the backoff starts over. |
| | 365 | yield* TestClock.adjust(Duration.millis(30_000)); |
| | 366 | yield* Deferred.succeed(exits[1]!, ChildProcessSpawner.ExitCode(1)); |
| | 367 | yield* Deferred.await(spawnSignals[2]!); |
| | 368 | yield* Deferred.succeed(exits[2]!, ChildProcessSpawner.ExitCode(1)); |
| | 369 | yield* Deferred.await(spawnSignals[3]!); |
| | 370 | |
| | 371 | // The next rapid crash waits the base delay again, not a doubled one. |
| | 372 | yield* Deferred.succeed(exits[3]!, ChildProcessSpawner.ExitCode(1)); |
| | 373 | yield* TestClock.adjust(Duration.millis(999)); |
| | 374 | expect(spawned).toEqual([700, 701, 702, 703]); |
| | 375 | yield* TestClock.adjust(Duration.millis(1)); |
| | 376 | yield* Deferred.await(spawnSignals[4]!); |
| | 377 | expect(spawned).toEqual([700, 701, 702, 703, 704]); |
| | 378 | }).pipe(Effect.provide(TestClock.layer())), |
| | 379 | ); |
| | 380 | |
| | 381 | it.effect("an explicit config change clears the backoff and preempts a delayed restart", () => |
| | 382 | Effect.gen(function* () { |
| | 383 | const { spawner, spawned, exits, spawnSignals } = yield* makeCrashLoopSpawner(800, 3); |
| | 384 | const runtime = yield* buildCloudManagedEndpointRuntime(spawner); |
| | 385 | |
| | 386 | yield* runtime.applyConfig({ |
| | 387 | providerKind: "cloudflare_tunnel", |
| | 388 | connectorToken: "token-1", |
| | 389 | }); |
| | 390 | yield* Deferred.succeed(exits[0]!, ChildProcessSpawner.ExitCode(1)); |
| | 391 | yield* Deferred.await(spawnSignals[1]!); |
| | 392 | |
| | 393 | // Leave the supervisor sleeping on the base delay, then change config. |
| | 394 | yield* Deferred.succeed(exits[1]!, ChildProcessSpawner.ExitCode(1)); |
| | 395 | const status = yield* runtime.applyConfig({ |
| | 396 | providerKind: "cloudflare_tunnel", |
| | 397 | connectorToken: "token-2", |
| | 398 | }); |
| | 399 | expect(status).toMatchObject({ status: "running", pid: 802 }); |
| | 400 | expect(spawned).toEqual([800, 801, 802]); |
| | 401 | |
| | 402 | // The preempted supervisor wakes later and must not spawn a duplicate. |
| | 403 | yield* TestClock.adjust(Duration.millis(60_000)); |
| | 404 | expect(spawned).toEqual([800, 801, 802]); |
| | 405 | }).pipe(Effect.provide(TestClock.layer())), |
| | 406 | ); |
| | 407 | |
| 286 | it.effect("serializes concurrent connector config changes", () => | 408 | it.effect("serializes concurrent connector config changes", () => |
| 287 | Effect.gen(function* () { | 409 | Effect.gen(function* () { |
| 288 | const spawned: Array<number> = []; | 410 | const spawned: Array<number> = []; |