| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import { vi } from "vite-plus/test"; |
| 3 | 3 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Duration from "effect/Duration"; |
| 4 | 5 | import * as Effect from "effect/Effect"; |
| 5 | 6 | import * as Fiber from "effect/Fiber"; |
| 6 | 7 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -8,6 +9,7 @@ import * as Option from "effect/Option"; |
| 8 | 9 | import * as PlatformError from "effect/PlatformError"; |
| 9 | 10 | import * as Sink from "effect/Sink"; |
| 10 | 11 | import * as Stream from "effect/Stream"; |
| 12 | import * as TestClock from "effect/testing/TestClock"; |
| 11 | 13 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 12 | 14 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 13 | 15 | |
| ... | ... | @@ -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 | 408 | it.effect("serializes concurrent connector config changes", () => |
| 287 | 409 | Effect.gen(function* () { |
| 288 | 410 | const spawned: Array<number> = []; |