From 5f548c9d5f984fd99901b4495af2c2ce08825111 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 24 Aug 2025 23:12:56 +0200 Subject: [PATCH 1/5] feat: Enhance ClusterManager with dynamic scaling, restart policies, and improved shutdown handling --- src/cluster/cluster-manager.ts | 207 ++++++++++++++---- src/interfaces/gateway.ts | 8 + test/cluster/cluster-manager.basic.test.ts | 49 +++++ .../cluster-manager.restart-policy.test.ts | 58 +++++ test/cluster/fixtures/worker.ts | 6 + 5 files changed, 286 insertions(+), 42 deletions(-) create mode 100644 test/cluster/cluster-manager.basic.test.ts create mode 100644 test/cluster/cluster-manager.restart-policy.test.ts create mode 100644 test/cluster/fixtures/worker.ts diff --git a/src/cluster/cluster-manager.ts b/src/cluster/cluster-manager.ts index 75e04d8..728416e 100644 --- a/src/cluster/cluster-manager.ts +++ b/src/cluster/cluster-manager.ts @@ -47,6 +47,11 @@ export interface WorkerInfo { lastRestartTime: number /** Flag indicating worker is in shutdown process */ isExiting: boolean + /** + * Timestamps of recent restarts used to enforce respawn threshold within a time window + * Old entries are pruned automatically on access + */ + restartTimestamps: number[] } /** @@ -68,6 +73,12 @@ export class ClusterManager { private isShuttingDown = false /** Path to worker script for spawning processes */ private workerScript: string + /** Guard to prevent double start */ + private started = false + /** Bound signal handlers to allow proper removal */ + private boundSigint?: () => void + private boundSigterm?: () => void + private boundSigusr2?: () => void /** * Initialize cluster manager with configuration and dependencies @@ -106,23 +117,39 @@ export class ClusterManager { return } + if (this.started) { + this.logger?.warn('Cluster already started; ignoring subsequent start()') + return + } + this.started = true + this.logger?.info(`Starting cluster with ${this.config.workers} workers`) // Configure signal handlers for graceful lifecycle management - process.on('SIGINT', this.gracefulShutdown.bind(this)) - process.on('SIGTERM', this.gracefulShutdown.bind(this)) - process.on('SIGUSR2', this.restartAllWorkers.bind(this)) + this.boundSigint = this.gracefulShutdown.bind(this) + this.boundSigterm = this.gracefulShutdown.bind(this) + this.boundSigusr2 = this.restartAllWorkers.bind(this) + process.on('SIGINT', this.boundSigint) + process.on('SIGTERM', this.boundSigterm) + process.on('SIGUSR2', this.boundSigusr2) + + // Calculate number of workers, ensuring at least one + const maxWorkers = Math.max(1, this.config.workers || cpus().length) // Spawn workers - for (let i = 0; i < this.config.workers!; i++) { - await this.spawnWorker() + for (let i = 0; i < maxWorkers; i++) { + try { + await this.spawnWorker() + } catch (error) { + this.logger?.error(`Failed to spawn worker ${i}:`, error as Error) + } } this.logger?.info('Cluster started successfully') } - private async spawnWorker(): Promise { - const workerId = this.nextWorkerId++ + private async spawnWorker(workerId?: number): Promise { + const id = workerId ?? this.nextWorkerId++ try { const worker = spawn({ @@ -130,30 +157,30 @@ export class ClusterManager { env: { ...process.env, CLUSTER_WORKER: 'true', - CLUSTER_WORKER_ID: workerId.toString(), + CLUSTER_WORKER_ID: id.toString(), }, stdio: ['inherit', 'inherit', 'inherit'], }) const workerInfo: WorkerInfo = { - id: workerId, + id, process: worker, restarts: 0, lastRestartTime: 0, isExiting: false, + restartTimestamps: [], } - this.workers.set(workerId, workerInfo) + this.workers.set(id, workerInfo) // Handle worker exit worker.exited.then((exitCode) => { this.handleWorkerExit(workerInfo, exitCode) }) - this.logger?.info(`Worker ${workerId} started (PID: ${worker.pid})`) + this.logger?.info(`Worker ${id} started (PID: ${worker.pid})`) } catch (error) { - this.logger?.error(`Failed to spawn worker ${workerId}:`, error as Error) - throw error + this.logger?.error(`Failed to spawn worker ${id}:`, error as Error) } } @@ -174,16 +201,25 @@ export class ClusterManager { // Check if we should restart the worker if (this.config.restartWorkers && this.shouldRestartWorker(workerInfo)) { this.logger?.info(`Restarting worker ${id}`) + // Track restart metrics + const now = Date.now() workerInfo.restarts++ - workerInfo.lastRestartTime = Date.now() - - // Add restart delay - await new Promise((resolve) => - setTimeout(resolve, this.config.restartDelay), + workerInfo.lastRestartTime = now + workerInfo.restartTimestamps.push(now) + // Apply exponential backoff with jitter based on restarts count + const base = Math.max(0, this.config.restartDelay ?? 1000) + const attempt = Math.max(1, workerInfo.restarts) + const maxDelay = 30000 // cap at 30s to avoid unbounded waits + const backoff = Math.min( + maxDelay, + base * Math.pow(2, Math.min(5, attempt - 1)), ) + const jitter = Math.floor(Math.random() * Math.floor(base / 2)) + const delay = Math.min(maxDelay, backoff + jitter) + await new Promise((resolve) => setTimeout(resolve, delay)) try { - await this.spawnWorker() + await this.spawnWorker(id) } catch (error) { this.logger?.error(`Failed to restart worker ${id}:`, error as Error) } @@ -195,43 +231,48 @@ export class ClusterManager { } private shouldRestartWorker(workerInfo: WorkerInfo): boolean { - const { restarts, lastRestartTime } = workerInfo + const { restarts } = workerInfo const now = Date.now() - // Check max restarts - if (restarts >= this.config.maxRestarts!) { + // Check max restarts (lifetime) + if ( + typeof this.config.maxRestarts === 'number' && + restarts >= (this.config.maxRestarts ?? 10) + ) { return false } - // Check respawn threshold - if (this.config.respawnThreshold && this.config.respawnThresholdTime) { - const timeSinceLastRestart = now - lastRestartTime - if ( - timeSinceLastRestart < this.config.respawnThresholdTime && - restarts >= this.config.respawnThreshold - ) { - return false - } + // Enforce respawn threshold within time window using sliding window timestamps + const threshold = this.config.respawnThreshold ?? 5 + const windowMs = this.config.respawnThresholdTime ?? 60000 + // Prune old timestamps + workerInfo.restartTimestamps = workerInfo.restartTimestamps.filter( + (t) => now - t <= windowMs, + ) + if (workerInfo.restartTimestamps.length >= threshold) { + return false } return true } private async restartAllWorkers(): Promise { - this.logger?.info('Restarting all workers') + this.logger?.info('Rolling restart of all workers initiated') const workerIds = Array.from(this.workers.keys()) for (const workerId of workerIds) { - const workerInfo = this.workers.get(workerId) - if (workerInfo) { - this.logger?.info(`Restarting worker ${workerId}`) - workerInfo.isExiting = true - workerInfo.process.kill('SIGTERM') - - // Wait a bit before starting the next restart - await new Promise((resolve) => setTimeout(resolve, 1000)) - } + const current = this.workers.get(workerId) + if (!current) continue + // Spawn a replacement first (uses a new worker id) to minimize downtime + await this.spawnWorker() + // Give the new worker a brief moment to initialize + await new Promise((resolve) => setTimeout(resolve, 250)) + this.logger?.info(`Stopping old worker ${workerId} (rolling restart)`) + current.isExiting = true + current.process.kill('SIGTERM') + // Small spacing to avoid thundering herd + await new Promise((resolve) => setTimeout(resolve, 250)) } } @@ -291,7 +332,13 @@ export class ClusterManager { await Promise.race([shutdownPromise, timeoutPromise]) this.logger?.info('Cluster shutdown complete') - process.exit(0) + // Remove signal handlers to avoid memory leaks when embedding in long-running processes/tests + if (this.boundSigint) process.off('SIGINT', this.boundSigint) + if (this.boundSigterm) process.off('SIGTERM', this.boundSigterm) + if (this.boundSigusr2) process.off('SIGUSR2', this.boundSigusr2) + if (this.config.exitOnShutdown ?? true) { + process.exit(0) + } } getWorkerCount(): number { @@ -301,4 +348,80 @@ export class ClusterManager { getWorkerInfo(): WorkerInfo[] { return Array.from(this.workers.values()) } + + /** Whether the cluster has been started and not shut down */ + isRunning(): boolean { + return this.started && !this.isShuttingDown + } + + /** + * Dynamically scale the number of workers. + * Increases spawns or gracefully stops excess workers to match target. + */ + async scaleTo(target: number): Promise { + if (!this.config.enabled) return + const desired = Math.max(1, Math.floor(target)) + const current = this.workers.size + if (desired === current) return + if (desired > current) { + const toAdd = desired - current + this.logger?.info(`Scaling up workers: +${toAdd}`) + for (let i = 0; i < toAdd; i++) { + await this.spawnWorker() + } + } else { + const toRemove = current - desired + this.logger?.info(`Scaling down workers: -${toRemove}`) + const ids = Array.from(this.workers.keys()).slice(0, toRemove) + for (const id of ids) { + const info = this.workers.get(id) + if (!info) continue + info.isExiting = true + info.process.kill('SIGTERM') + } + } + this.config.workers = desired + } + + /** Convenience: increase workers by N (default 1) */ + scaleUp(by = 1): Promise { + return this.scaleTo((this.workers.size || 0) + Math.max(1, by)) + } + + /** Convenience: decrease workers by N (default 1) */ + scaleDown(by = 1): Promise { + return this.scaleTo(Math.max(1, this.workers.size - Math.max(1, by))) + } + + /** Broadcast a POSIX signal to all workers (e.g., 'SIGTERM', 'SIGHUP'). */ + broadcastSignal(signal: NodeJS.Signals = 'SIGHUP'): void { + for (const [id, info] of this.workers) { + this.logger?.debug(`Sending ${signal} to worker ${id}`) + try { + info.process.kill(signal) + } catch (err) { + this.logger?.warn( + `Failed sending ${signal} to worker ${id}: ${(err as Error).message}`, + ) + } + } + } + + /** Send a signal to a single worker by id. */ + sendSignalToWorker( + workerId: number, + signal: NodeJS.Signals = 'SIGHUP', + ): boolean { + const info = this.workers.get(workerId) + if (!info) return false + try { + info.process.kill(signal) + return true + } catch (err) { + this.logger?.warn( + `Failed sending ${signal} to worker ${workerId}: ${(err as Error).message}`, + ) + return false + } + } } diff --git a/src/interfaces/gateway.ts b/src/interfaces/gateway.ts index 07557e4..a016a69 100644 --- a/src/interfaces/gateway.ts +++ b/src/interfaces/gateway.ts @@ -63,6 +63,14 @@ export interface ClusterConfig { * @default 60000 (1 minute) */ respawnThresholdTime?: number + + /** + * Exit the master process after graceful shutdown completes. + * Set to false for test environments or embedded runners where exiting the + * process is undesirable. + * @default true + */ + exitOnShutdown?: boolean } /** diff --git a/test/cluster/cluster-manager.basic.test.ts b/test/cluster/cluster-manager.basic.test.ts new file mode 100644 index 0000000..02af3f2 --- /dev/null +++ b/test/cluster/cluster-manager.basic.test.ts @@ -0,0 +1,49 @@ +import { test, expect } from 'bun:test' +import { ClusterManager } from '../../src' +import { BunGateLogger } from '../../src' + +const logger = new BunGateLogger({ + level: 'error', + enableRequestLogging: false, +}) +const workerScript = `${import.meta.dir}/fixtures/worker.ts` + +function makeManager( + overrides: Partial = {}, +) { + return new ClusterManager( + { + enabled: true, + workers: 2, + restartWorkers: false, + shutdownTimeout: 2000, + exitOnShutdown: false, + ...overrides, + }, + logger, + workerScript, + ) +} + +test('ClusterManager > start spawns requested workers and is idempotent', async () => { + const cm = makeManager({ workers: 2 }) + await cm.start() + expect(cm.getWorkerCount()).toBe(2) + await cm.start() // idempotent + expect(cm.getWorkerCount()).toBe(2) + await (cm as any).gracefulShutdown() + expect(cm.getWorkerCount()).toBe(0) +}) + +test('ClusterManager > scale up and down dynamically', async () => { + const cm = makeManager({ workers: 1 }) + await cm.start() + expect(cm.getWorkerCount()).toBe(1) + await cm.scaleUp(2) + expect(cm.getWorkerCount()).toBe(3) + await cm.scaleDown(2) + // allow SIGTERM to process + await new Promise((r) => setTimeout(r, 300)) + expect(cm.getWorkerCount()).toBe(1) + await (cm as any).gracefulShutdown() +}) diff --git a/test/cluster/cluster-manager.restart-policy.test.ts b/test/cluster/cluster-manager.restart-policy.test.ts new file mode 100644 index 0000000..4eab18d --- /dev/null +++ b/test/cluster/cluster-manager.restart-policy.test.ts @@ -0,0 +1,58 @@ +import { test, expect } from 'bun:test' +import { ClusterManager } from '../../src' +import { BunGateLogger } from '../../src' + +const logger = new BunGateLogger({ + level: 'error', + enableRequestLogging: false, +}) +const workerScript = `${import.meta.dir}/fixtures/worker.ts` + +function makeManager( + overrides: Partial = {}, +) { + return new ClusterManager( + { + enabled: true, + workers: 1, + restartWorkers: true, + maxRestarts: 3, + restartDelay: 10, + respawnThreshold: 2, + respawnThresholdTime: 200, + shutdownTimeout: 1000, + exitOnShutdown: false, + ...overrides, + }, + logger, + workerScript, + ) +} + +test('ClusterManager > restart policy with threshold prevents infinite restarts', async () => { + const cm = makeManager() + await cm.start() + expect(cm.getWorkerCount()).toBe(1) + // kill the worker multiple times to trigger threshold + for (let i = 0; i < 3; i++) { + const info = cm.getWorkerInfo()[0] + if (info) info.process.kill('SIGTERM') + await new Promise((r) => setTimeout(r, 30)) + } + await new Promise((r) => setTimeout(r, 500)) + // After threshold, should not exceed 1 worker and may be 0 if restart blocked + expect(cm.getWorkerCount()).toBeLessThanOrEqual(1) + await (cm as any).gracefulShutdown() +}) + +test('ClusterManager > rolling restart spawns replacement before stopping old', async () => { + const cm = makeManager({ workers: 2 }) + await cm.start() + const before = cm.getWorkerCount() + await (cm as any).restartAllWorkers() + // allow roll to complete + await new Promise((r) => setTimeout(r, 600)) + const after = cm.getWorkerCount() + expect(after).toBe(before) // count should remain stable + await (cm as any).gracefulShutdown() +}) diff --git a/test/cluster/fixtures/worker.ts b/test/cluster/fixtures/worker.ts new file mode 100644 index 0000000..4721224 --- /dev/null +++ b/test/cluster/fixtures/worker.ts @@ -0,0 +1,6 @@ +// Minimal worker script for ClusterManager tests +// Exits on SIGTERM, otherwise stays alive indefinitely +process.on('SIGTERM', () => process.exit(0)) + +// Keep alive +setInterval(() => {}, 1 << 30) From 890f8ccecfced110b3cdb81d68f3cb21858e37b1 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Sun, 24 Aug 2025 23:20:45 +0200 Subject: [PATCH 2/5] Update test/cluster/fixtures/worker.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/cluster/fixtures/worker.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/cluster/fixtures/worker.ts b/test/cluster/fixtures/worker.ts index 4721224..dc13b6b 100644 --- a/test/cluster/fixtures/worker.ts +++ b/test/cluster/fixtures/worker.ts @@ -2,5 +2,6 @@ // Exits on SIGTERM, otherwise stays alive indefinitely process.on('SIGTERM', () => process.exit(0)) -// Keep alive -setInterval(() => {}, 1 << 30) +// Keep alive for a very long time (about 1 billion ms ~ 11.5 days) +const KEEP_ALIVE_INTERVAL = 1 << 30; +setInterval(() => {}, KEEP_ALIVE_INTERVAL) From 19992872de21c1a0d7115ae8cb24f0df0b5e61ea Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 24 Aug 2025 23:21:00 +0200 Subject: [PATCH 3/5] feat: Add advanced cluster lifecycle management and dynamic scaling documentation --- README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/README.md b/README.md index 95e8bdf..c8b163c 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,76 @@ await gateway.listen(3000) console.log('Cluster started with 4 workers') ``` +#### Advanced usage: Cluster lifecycle and operations + +Bungate’s cluster manager powers zero-downtime restarts, dynamic scaling, and safe shutdowns in production. You can control it via signals or programmatically. + +- Zero-downtime rolling restart: send `SIGUSR2` to the master process + - The manager spawns a replacement worker first, then gracefully stops the old one +- Graceful shutdown: send `SIGTERM` or `SIGINT` + - Workers receive `SIGTERM` and are given up to `shutdownTimeout` to exit before being force-killed + +Programmatic controls (available when using the `ClusterManager` directly): + +```ts +import { ClusterManager, BunGateLogger } from 'bungate' + +const logger = new BunGateLogger({ level: 'info' }) + +const cluster = new ClusterManager( + { + enabled: true, + workers: 4, + restartWorkers: true, + restartDelay: 1000, // base delay used for exponential backoff with jitter + maxRestarts: 10, // lifetime cap per worker + respawnThreshold: 5, // sliding window cap + respawnThresholdTime: 60_000, // within this time window + shutdownTimeout: 30_000, + // Set to false when embedding in tests to avoid process.exit(0) + exitOnShutdown: true, + }, + logger, + './gateway.ts', // worker entry (executed with Bun) +) + +await cluster.start() + +// Dynamic scaling +await cluster.scaleUp(2) // add 2 workers +await cluster.scaleDown(1) // remove 1 worker +await cluster.scaleTo(6) // set exact worker count + +// Operational visibility +console.log(cluster.getWorkerCount()) +console.log(cluster.getWorkerInfo()) // includes id, restarts, pid, etc. + +// Broadcast a POSIX signal to all workers (e.g., for log-level reloads) +cluster.broadcastSignal('SIGHUP') + +// Target a single worker +cluster.sendSignalToWorker(1, 'SIGHUP') + +// Graceful shutdown (will exit process if exitOnShutdown !== false) +// await (cluster as any).gracefulShutdown() // internal in gateway use; prefer SIGTERM +``` + +Notes: +- Each worker receives `CLUSTER_WORKER=true` and `CLUSTER_WORKER_ID=` environment variables. +- Restart policy uses exponential backoff with jitter and a sliding window threshold to prevent flapping. +- Defaults: `shutdownTimeout` 30s, `respawnThreshold` 5 within 60s, `restartDelay` 1s, `maxRestarts` 10. + +Configuration reference (cluster): +- `enabled` (boolean): enable multi-process mode +- `workers` (number): worker process count (defaults to CPU cores) +- `restartWorkers` (boolean): auto-respawn crashed workers +- `restartDelay` (ms): base delay for backoff +- `maxRestarts` (number): lifetime restarts per worker +- `respawnThreshold` (number): max restarts within time window +- `respawnThresholdTime` (ms): sliding window size +- `shutdownTimeout` (ms): grace period before force-kill +- `exitOnShutdown` (boolean): if true (default), master exits after shutdown; set false in tests/embedded + ### 🔄 **Advanced Load Balancing** Distribute traffic intelligently across multiple backends: From ec3edc64e2985d7f05251e6bca437653c2c3ad4c Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 24 Aug 2025 23:31:53 +0200 Subject: [PATCH 4/5] fix: Clean up formatting in cluster management section of README --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c8b163c..3cae06d 100644 --- a/README.md +++ b/README.md @@ -264,8 +264,8 @@ const cluster = new ClusterManager( workers: 4, restartWorkers: true, restartDelay: 1000, // base delay used for exponential backoff with jitter - maxRestarts: 10, // lifetime cap per worker - respawnThreshold: 5, // sliding window cap + maxRestarts: 10, // lifetime cap per worker + respawnThreshold: 5, // sliding window cap respawnThresholdTime: 60_000, // within this time window shutdownTimeout: 30_000, // Set to false when embedding in tests to avoid process.exit(0) @@ -278,9 +278,9 @@ const cluster = new ClusterManager( await cluster.start() // Dynamic scaling -await cluster.scaleUp(2) // add 2 workers -await cluster.scaleDown(1) // remove 1 worker -await cluster.scaleTo(6) // set exact worker count +await cluster.scaleUp(2) // add 2 workers +await cluster.scaleDown(1) // remove 1 worker +await cluster.scaleTo(6) // set exact worker count // Operational visibility console.log(cluster.getWorkerCount()) @@ -297,11 +297,13 @@ cluster.sendSignalToWorker(1, 'SIGHUP') ``` Notes: + - Each worker receives `CLUSTER_WORKER=true` and `CLUSTER_WORKER_ID=` environment variables. - Restart policy uses exponential backoff with jitter and a sliding window threshold to prevent flapping. - Defaults: `shutdownTimeout` 30s, `respawnThreshold` 5 within 60s, `restartDelay` 1s, `maxRestarts` 10. Configuration reference (cluster): + - `enabled` (boolean): enable multi-process mode - `workers` (number): worker process count (defaults to CPU cores) - `restartWorkers` (boolean): auto-respawn crashed workers From ed193536db04b4df9737cf9deb638b87c183ecbb Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 24 Aug 2025 23:31:57 +0200 Subject: [PATCH 5/5] test: Increase timeout durations in load balancer tests for improved stability --- test/load-balancer/load-balancer.test.ts | 37 ++++++++++++++---------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/test/load-balancer/load-balancer.test.ts b/test/load-balancer/load-balancer.test.ts index 322700d..4ff6c2c 100644 --- a/test/load-balancer/load-balancer.test.ts +++ b/test/load-balancer/load-balancer.test.ts @@ -449,7 +449,7 @@ describe('HttpLoadBalancer', () => { loadBalancer = createLoadBalancer(config) // Wait for health check to run - await new Promise((resolve) => setTimeout(resolve, 150)) + await new Promise((resolve) => setTimeout(resolve, 600)) expect(fetchCallCount).toBeGreaterThan(0) @@ -478,7 +478,7 @@ describe('HttpLoadBalancer', () => { loadBalancer = createLoadBalancer(config) // Wait for health check to run - await new Promise((resolve) => setTimeout(resolve, 150)) + await new Promise((resolve) => setTimeout(resolve, 600)) const targets = loadBalancer.getHealthyTargets() expect(targets.length).toBe(1) @@ -506,7 +506,7 @@ describe('HttpLoadBalancer', () => { loadBalancer = createLoadBalancer(config) // Wait for health check to run and fail - await new Promise((resolve) => setTimeout(resolve, 200)) + await new Promise((resolve) => setTimeout(resolve, 600)) const stats = loadBalancer.getStats() // Health check should have marked target as unhealthy @@ -548,7 +548,7 @@ describe('HttpLoadBalancer', () => { loadBalancer = createLoadBalancer(config) // Wait for health check to timeout - await new Promise((resolve) => setTimeout(resolve, 200)) + await new Promise((resolve) => setTimeout(resolve, 600)) const stats = loadBalancer.getStats() expect(stats.healthyTargets).toBe(0) // Should be marked unhealthy due to timeout @@ -558,9 +558,14 @@ describe('HttpLoadBalancer', () => { test('skips health checks when disabled', async () => { let fetchCalled = false + const uniquePath = '/health-disabled-unique' - const fetchSpy = createFetchSpy(async () => { - fetchCalled = true + const fetchSpy = createFetchSpy(async (url: string | URL | Request) => { + let href = '' + if (typeof url === 'string') href = url + else if (url instanceof URL) href = url.toString() + else if (url instanceof Request) href = url.url + if (href.includes(uniquePath)) fetchCalled = true return new Response('OK', { status: 200 }) }) @@ -571,14 +576,14 @@ describe('HttpLoadBalancer', () => { enabled: false, interval: 100, timeout: 1000, - path: '/health', + path: uniquePath, }, } loadBalancer = createLoadBalancer(config) // Wait to ensure no health checks run - await new Promise((resolve) => setTimeout(resolve, 150)) + await new Promise((resolve) => setTimeout(resolve, 500)) expect(fetchCalled).toBe(false) @@ -651,7 +656,7 @@ describe('HttpLoadBalancer', () => { loadBalancer.selectTarget(request) // Creates session // Wait for session to expire and cleanup to run - await new Promise((resolve) => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 500)) // Sessions should be cleaned up (we can't directly test internal state but ensure no errors) expect(true).toBe(true) @@ -966,7 +971,7 @@ describe('HttpLoadBalancer', () => { loadBalancer.selectTarget(request) // Creates session // Wait for potential session cleanup - await new Promise((resolve) => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 500)) // Should handle session cleanup without errors expect(true).toBe(true) @@ -1261,7 +1266,7 @@ describe('HttpLoadBalancer', () => { expect(target).not.toBeNull() // Wait for session to expire and cleanup to run - await new Promise((resolve) => setTimeout(resolve, 60)) + await new Promise((resolve) => setTimeout(resolve, 300)) // Force another selection to trigger potential cleanup const newTarget = balancer.selectTarget(request) @@ -1297,7 +1302,7 @@ describe('HttpLoadBalancer', () => { expect(target3).not.toBeNull() // Wait longer to ensure sessions expire and cleanup runs - await new Promise((resolve) => setTimeout(resolve, 120)) + await new Promise((resolve) => setTimeout(resolve, 500)) // Trigger more operations that might involve session cleanup const newRequest = createMockRequest('new-agent') @@ -1371,7 +1376,7 @@ describe('HttpLoadBalancer', () => { expect(target).not.toBeNull() // Wait for cleanup to potentially run - await new Promise((resolve) => setTimeout(resolve, 70)) + await new Promise((resolve) => setTimeout(resolve, 300)) balancer.destroy() }) @@ -1394,7 +1399,7 @@ describe('HttpLoadBalancer', () => { const balancer = new HttpLoadBalancer(config) // Let some health checks run and potentially use cache - await new Promise((resolve) => setTimeout(resolve, 70)) + await new Promise((resolve) => setTimeout(resolve, 300)) balancer.destroy() }) @@ -1626,7 +1631,7 @@ describe('HttpLoadBalancer', () => { }) // Trigger cleanup by waiting for interval - await new Promise((resolve) => setTimeout(resolve, 20)) + await new Promise((resolve) => setTimeout(resolve, 200)) balancer.destroy() }) @@ -1678,7 +1683,7 @@ describe('HttpLoadBalancer', () => { // Wait a bit longer to allow the cleanup interval to run at least once // The cleanup runs every 5 minutes (300000ms) in the real implementation // but the expired sessions should be cleaned immediately on the next interval - await new Promise((resolve) => setTimeout(resolve, 30)) + await new Promise((resolve) => setTimeout(resolve, 200)) // Force another operation that might trigger cleanup const request2 = createMockRequest('different-agent')