|
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"; |
7 | 8 | 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> = []; |
|
0 commit comments