portok 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -197,6 +197,7 @@ portok <command> [options]
197
197
  Management Commands:
198
198
  init Initialize portok directories (/etc/portok, /var/lib/portok)
199
199
  add <name> Create a new service instance
200
+ remove <name> Remove a service instance (stops, disables, deletes config/state)
200
201
  list List all configured instances and their status
201
202
 
202
203
  Service Control Commands:
@@ -226,6 +227,10 @@ Options for 'add' command:
226
227
  --health <path> Health check path (default: /health)
227
228
  --force Overwrite existing config
228
229
 
230
+ Options for 'remove' command:
231
+ --force Skip confirmation prompt
232
+ --keep-state Keep state file (/var/lib/portok/<name>.json)
233
+
229
234
  Options for 'logs' command:
230
235
  --follow, -f Follow log output
231
236
  --lines, -n Number of lines to show (default: 50)
@@ -275,6 +280,10 @@ sudo portok restart api
275
280
  sudo portok enable api # Enable at boot
276
281
  sudo portok disable api # Disable at boot
277
282
 
283
+ # Remove a service (stops, disables, removes config and state)
284
+ sudo portok remove api --force
285
+ sudo portok remove api --force --keep-state # Keep state file
286
+
278
287
  # View logs
279
288
  portok logs api
280
289
  portok logs api --follow # Follow log output
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  const shortDuration = Math.max(2, Math.floor(duration / 2));
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  // Setup
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Benchmark Helpers
3
+ * Shared utilities for all benchmark files
4
+ */
5
+
6
+ const { spawn } = require('node:child_process');
7
+ const http = require('node:http');
8
+ const path = require('node:path');
9
+
10
+ const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'bench-token-12345';
11
+
12
+ function formatNumber(n) {
13
+ return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
14
+ }
15
+
16
+ async function getFreePort() {
17
+ return new Promise((resolve, reject) => {
18
+ const server = http.createServer();
19
+ server.listen(0, '127.0.0.1', () => {
20
+ const port = server.address().port;
21
+ server.close(() => resolve(port));
22
+ });
23
+ server.on('error', reject);
24
+ });
25
+ }
26
+
27
+ async function waitFor(condition, timeout = 10000) {
28
+ const start = Date.now();
29
+ while (Date.now() - start < timeout) {
30
+ if (await condition()) return true;
31
+ await new Promise(r => setTimeout(r, 100));
32
+ }
33
+ throw new Error('Timeout waiting for condition');
34
+ }
35
+
36
+ // =============================================================================
37
+ // Mock Server
38
+ // =============================================================================
39
+
40
+ async function createMockServer(options = {}) {
41
+ const { responseDelay = 0, responseSize = 13 } = options;
42
+ const port = await getFreePort();
43
+
44
+ const responseBody = responseSize <= 13
45
+ ? 'Hello, World!'
46
+ : 'x'.repeat(responseSize);
47
+
48
+ const server = http.createServer((req, res) => {
49
+ if (req.url === '/health') {
50
+ res.writeHead(200, { 'Content-Type': 'application/json' });
51
+ res.end('{"status":"ok"}');
52
+ return;
53
+ }
54
+
55
+ if (responseDelay > 0) {
56
+ setTimeout(() => {
57
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
58
+ res.end(responseBody);
59
+ }, responseDelay);
60
+ } else {
61
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
62
+ res.end(responseBody);
63
+ }
64
+ });
65
+
66
+ return new Promise((resolve, reject) => {
67
+ server.listen(port, '127.0.0.1', () => {
68
+ resolve({
69
+ port,
70
+ close: () => new Promise(r => server.close(r)),
71
+ });
72
+ });
73
+ server.on('error', reject);
74
+ });
75
+ }
76
+
77
+ // =============================================================================
78
+ // Daemon Starter
79
+ // =============================================================================
80
+
81
+ async function startDaemon(proxyPort, targetPort, extraEnv = {}) {
82
+ const daemonPath = path.join(__dirname, '..', 'portokd.js');
83
+
84
+ const env = {
85
+ ...process.env,
86
+ LISTEN_PORT: String(proxyPort),
87
+ INITIAL_TARGET_PORT: String(targetPort),
88
+ ADMIN_TOKEN,
89
+ DRAIN_MS: '100',
90
+ ROLLBACK_WINDOW_MS: '1000',
91
+ ROLLBACK_CHECK_EVERY_MS: '100',
92
+ ROLLBACK_FAIL_THRESHOLD: '2',
93
+ ...extraEnv,
94
+ };
95
+
96
+ const daemon = spawn('node', [daemonPath], {
97
+ env,
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ });
100
+
101
+ // Wait for daemon to be ready
102
+ await waitFor(async () => {
103
+ try {
104
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
105
+ headers: { 'x-admin-token': ADMIN_TOKEN },
106
+ });
107
+ return res.ok;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }, 10000);
112
+
113
+ return daemon;
114
+ }
115
+
116
+ module.exports = {
117
+ createMockServer,
118
+ startDaemon,
119
+ getFreePort,
120
+ waitFor,
121
+ formatNumber,
122
+ ADMIN_TOKEN,
123
+ };
124
+
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  // Setup
package/bench/run.js CHANGED
@@ -5,9 +5,6 @@
5
5
  * Orchestrates all benchmarks and outputs formatted results
6
6
  */
7
7
 
8
- const { spawn } = require('node:child_process');
9
- const http = require('node:http');
10
-
11
8
  // =============================================================================
12
9
  // Configuration
13
10
  // =============================================================================
@@ -31,86 +28,6 @@ function log(msg) {
31
28
  }
32
29
  }
33
30
 
34
- function formatNumber(n) {
35
- return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
36
- }
37
-
38
- async function getFreePort() {
39
- return new Promise((resolve, reject) => {
40
- const server = http.createServer();
41
- server.listen(0, '127.0.0.1', () => {
42
- const port = server.address().port;
43
- server.close(() => resolve(port));
44
- });
45
- server.on('error', reject);
46
- });
47
- }
48
-
49
- async function waitFor(condition, timeout = 10000) {
50
- const start = Date.now();
51
- while (Date.now() - start < timeout) {
52
- if (await condition()) return true;
53
- await new Promise(r => setTimeout(r, 100));
54
- }
55
- throw new Error('Timeout waiting for condition');
56
- }
57
-
58
- // =============================================================================
59
- // Mock Server
60
- // =============================================================================
61
-
62
- async function createMockServer(port = 0) {
63
- const server = http.createServer((req, res) => {
64
- if (req.url === '/health') {
65
- res.writeHead(200, { 'Content-Type': 'application/json' });
66
- res.end('{"status":"healthy"}');
67
- return;
68
- }
69
- res.writeHead(200, { 'Content-Type': 'text/plain' });
70
- res.end('OK');
71
- });
72
-
73
- await new Promise(resolve => server.listen(port, '127.0.0.1', resolve));
74
- return {
75
- port: server.address().port,
76
- close: () => new Promise(resolve => server.close(resolve)),
77
- };
78
- }
79
-
80
- // =============================================================================
81
- // Start Daemon
82
- // =============================================================================
83
-
84
- async function startDaemon(listenPort, targetPort) {
85
- const proc = spawn('node', ['portokd.js'], {
86
- env: {
87
- ...process.env,
88
- LISTEN_PORT: String(listenPort),
89
- INITIAL_TARGET_PORT: String(targetPort),
90
- ADMIN_TOKEN,
91
- STATE_FILE: `/tmp/portok-bench-${Date.now()}.json`,
92
- DRAIN_MS: '1000',
93
- ROLLBACK_WINDOW_MS: '60000',
94
- ROLLBACK_CHECK_EVERY_MS: '5000',
95
- ROLLBACK_FAIL_THRESHOLD: '3',
96
- },
97
- stdio: 'pipe',
98
- });
99
-
100
- await waitFor(async () => {
101
- try {
102
- const res = await fetch(`http://127.0.0.1:${listenPort}/__status`, {
103
- headers: { 'x-admin-token': ADMIN_TOKEN },
104
- });
105
- return res.ok;
106
- } catch {
107
- return false;
108
- }
109
- });
110
-
111
- return proc;
112
- }
113
-
114
31
  // =============================================================================
115
32
  // Run Individual Benchmarks
116
33
  // =============================================================================
@@ -206,6 +123,3 @@ main().catch(err => {
206
123
  console.error('Benchmark failed:', err);
207
124
  process.exit(1);
208
125
  });
209
-
210
- module.exports = { createMockServer, startDaemon, getFreePort, waitFor, formatNumber, ADMIN_TOKEN };
211
-
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort, formatNumber, ADMIN_TOKEN } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort, formatNumber, ADMIN_TOKEN } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  // Setup two servers
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  // Setup
@@ -56,7 +56,7 @@ services:
56
56
  - ROLLBACK_FAIL_THRESHOLD=3
57
57
  ports:
58
58
  - "3000:3000"
59
- command: ["node", "portokd.mjs"]
59
+ command: ["node", "portokd.js"]
60
60
  tmpfs:
61
61
  - /tmp
62
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portok",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Zero-downtime deployment proxy - routes traffic through a stable port to internal app instances with health-gated switching",
5
5
  "main": "portokd.js",
6
6
  "bin": {
package/portok.js CHANGED
@@ -314,6 +314,8 @@ async function cmdSwitch(baseUrl, token, port, options) {
314
314
  async function cmdInit(options) {
315
315
  const configDir = ENV_FILE_DIR;
316
316
  const stateDir = '/var/lib/portok';
317
+ const systemdDir = '/etc/systemd/system';
318
+ const serviceFileName = 'portok@.service';
317
319
 
318
320
  console.log(`${colors.bold}Initializing Portok...${colors.reset}\n`);
319
321
 
@@ -351,6 +353,51 @@ async function cmdInit(options) {
351
353
  results.push({ path: stateDir, status: 'error', error: err.message });
352
354
  }
353
355
 
356
+ // Install systemd template unit
357
+ const systemdDest = path.join(systemdDir, serviceFileName);
358
+ try {
359
+ // Find the service file relative to this script
360
+ const scriptDir = path.dirname(process.argv[1]);
361
+ const possiblePaths = [
362
+ path.join(scriptDir, serviceFileName),
363
+ path.join(scriptDir, '..', serviceFileName),
364
+ '/opt/portok/' + serviceFileName,
365
+ ];
366
+
367
+ let sourceFile = null;
368
+ for (const p of possiblePaths) {
369
+ if (fs.existsSync(p)) {
370
+ sourceFile = p;
371
+ break;
372
+ }
373
+ }
374
+
375
+ if (sourceFile) {
376
+ const content = fs.readFileSync(sourceFile, 'utf-8');
377
+ const existingContent = fs.existsSync(systemdDest) ? fs.readFileSync(systemdDest, 'utf-8') : null;
378
+
379
+ if (existingContent === content) {
380
+ results.push({ path: systemdDest, status: 'exists' });
381
+ } else {
382
+ fs.writeFileSync(systemdDest, content, { mode: 0o644 });
383
+ results.push({ path: systemdDest, status: 'created' });
384
+
385
+ // Reload systemd daemon
386
+ try {
387
+ const { execSync } = require('child_process');
388
+ execSync('systemctl daemon-reload', { stdio: 'pipe' });
389
+ results.push({ path: 'systemctl daemon-reload', status: 'created' });
390
+ } catch (e) {
391
+ // Ignore if systemctl not available (e.g., in Docker)
392
+ }
393
+ }
394
+ } else {
395
+ results.push({ path: systemdDest, status: 'skipped', error: 'Template file not found' });
396
+ }
397
+ } catch (err) {
398
+ results.push({ path: systemdDest, status: 'error', error: err.message });
399
+ }
400
+
354
401
  if (options.json) {
355
402
  console.log(JSON.stringify({ success: true, results }, null, 2));
356
403
  return 0;
@@ -360,9 +407,11 @@ async function cmdInit(options) {
360
407
  for (const r of results) {
361
408
  const icon = r.status === 'created' ? `${colors.green}✓${colors.reset}` :
362
409
  r.status === 'exists' ? `${colors.dim}○${colors.reset}` :
410
+ r.status === 'skipped' ? `${colors.yellow}○${colors.reset}` :
363
411
  `${colors.red}✗${colors.reset}`;
364
412
  const status = r.status === 'created' ? 'Created' :
365
413
  r.status === 'exists' ? 'Already exists' :
414
+ r.status === 'skipped' ? `Skipped: ${r.error}` :
366
415
  `Error: ${r.error}`;
367
416
  console.log(` ${icon} ${r.path} - ${status}`);
368
417
  }
@@ -377,7 +426,8 @@ async function cmdInit(options) {
377
426
  console.log(`\n${colors.green}Portok initialized successfully!${colors.reset}`);
378
427
  console.log(`\nNext steps:`);
379
428
  console.log(` 1. Create a service: ${colors.cyan}portok add <name>${colors.reset}`);
380
- console.log(` 2. Or manually create: ${colors.dim}${configDir}/<name>.env${colors.reset}`);
429
+ console.log(` 2. Start service: ${colors.cyan}portok start <name>${colors.reset}`);
430
+ console.log(` 3. Enable at boot: ${colors.cyan}portok enable <name>${colors.reset}`);
381
431
 
382
432
  return 0;
383
433
  }
@@ -495,6 +545,130 @@ ROLLBACK_FAIL_THRESHOLD=3
495
545
  return 0;
496
546
  }
497
547
 
548
+ async function cmdRemove(name, options) {
549
+ if (!name) {
550
+ console.error(`${colors.red}Error:${colors.reset} Service name is required`);
551
+ console.error('Usage: portok remove <name> [--force] [--keep-state]');
552
+ return 1;
553
+ }
554
+
555
+ const envFilePath = path.join(ENV_FILE_DIR, `${name}.env`);
556
+ const stateFilePath = `/var/lib/portok/${name}.json`;
557
+
558
+ // Check if service exists
559
+ if (!fs.existsSync(envFilePath)) {
560
+ console.error(`${colors.red}Error:${colors.reset} Service '${name}' not found at ${envFilePath}`);
561
+ return 1;
562
+ }
563
+
564
+ // Confirmation (unless --force)
565
+ if (!options.force && !options.json) {
566
+ console.log(`${colors.yellow}Warning:${colors.reset} This will remove service '${name}'`);
567
+ console.log(` Config file: ${envFilePath}`);
568
+ if (fs.existsSync(stateFilePath) && !options['keep-state']) {
569
+ console.log(` State file: ${stateFilePath}`);
570
+ }
571
+ console.log(`\nUse ${colors.cyan}--force${colors.reset} to confirm removal.`);
572
+ return 1;
573
+ }
574
+
575
+ const results = [];
576
+ const { execSync } = require('child_process');
577
+
578
+ // 1. Stop the service if running
579
+ try {
580
+ execSync(`systemctl is-active portok@${name}`, { stdio: 'pipe' });
581
+ // Service is running, stop it
582
+ try {
583
+ execSync(`systemctl stop portok@${name}`, { stdio: 'pipe' });
584
+ results.push({ action: 'stop', status: 'success' });
585
+ } catch (e) {
586
+ results.push({ action: 'stop', status: 'error', error: e.message });
587
+ }
588
+ } catch {
589
+ // Service not running, skip
590
+ results.push({ action: 'stop', status: 'skipped' });
591
+ }
592
+
593
+ // 2. Disable the service
594
+ try {
595
+ execSync(`systemctl is-enabled portok@${name}`, { stdio: 'pipe' });
596
+ // Service is enabled, disable it
597
+ try {
598
+ execSync(`systemctl disable portok@${name}`, { stdio: 'pipe' });
599
+ results.push({ action: 'disable', status: 'success' });
600
+ } catch (e) {
601
+ results.push({ action: 'disable', status: 'error', error: e.message });
602
+ }
603
+ } catch {
604
+ // Service not enabled, skip
605
+ results.push({ action: 'disable', status: 'skipped' });
606
+ }
607
+
608
+ // 3. Remove config file
609
+ try {
610
+ fs.unlinkSync(envFilePath);
611
+ results.push({ action: 'remove-config', status: 'success', path: envFilePath });
612
+ } catch (err) {
613
+ results.push({ action: 'remove-config', status: 'error', error: err.message });
614
+ }
615
+
616
+ // 4. Remove state file (unless --keep-state)
617
+ if (!options['keep-state']) {
618
+ if (fs.existsSync(stateFilePath)) {
619
+ try {
620
+ fs.unlinkSync(stateFilePath);
621
+ results.push({ action: 'remove-state', status: 'success', path: stateFilePath });
622
+ } catch (err) {
623
+ results.push({ action: 'remove-state', status: 'error', error: err.message });
624
+ }
625
+ } else {
626
+ results.push({ action: 'remove-state', status: 'skipped' });
627
+ }
628
+ } else {
629
+ results.push({ action: 'remove-state', status: 'kept' });
630
+ }
631
+
632
+ const hasError = results.some(r => r.status === 'error');
633
+
634
+ if (options.json) {
635
+ console.log(JSON.stringify({ success: !hasError, name, results }, null, 2));
636
+ return hasError ? 1 : 0;
637
+ }
638
+
639
+ // Display results
640
+ console.log(`\n${colors.bold}Removing service '${name}'...${colors.reset}\n`);
641
+
642
+ for (const r of results) {
643
+ const icon = r.status === 'success' ? `${colors.green}✓${colors.reset}` :
644
+ r.status === 'skipped' ? `${colors.dim}○${colors.reset}` :
645
+ r.status === 'kept' ? `${colors.dim}○${colors.reset}` :
646
+ `${colors.red}✗${colors.reset}`;
647
+
648
+ const actionLabel = {
649
+ 'stop': 'Stop service',
650
+ 'disable': 'Disable service',
651
+ 'remove-config': 'Remove config',
652
+ 'remove-state': 'Remove state',
653
+ }[r.action];
654
+
655
+ const statusLabel = r.status === 'success' ? 'Done' :
656
+ r.status === 'skipped' ? 'Skipped (not running/enabled)' :
657
+ r.status === 'kept' ? 'Kept (--keep-state)' :
658
+ `Error: ${r.error}`;
659
+
660
+ console.log(` ${icon} ${actionLabel.padEnd(16)} ${statusLabel}`);
661
+ }
662
+
663
+ if (hasError) {
664
+ console.log(`\n${colors.yellow}Warning:${colors.reset} Some operations failed. Check errors above.`);
665
+ return 1;
666
+ }
667
+
668
+ console.log(`\n${colors.green}✓ Service '${name}' removed successfully!${colors.reset}`);
669
+ return 0;
670
+ }
671
+
498
672
  async function cmdList(options) {
499
673
  const configDir = ENV_FILE_DIR;
500
674
 
@@ -728,6 +902,7 @@ ${colors.bold}COMMANDS${colors.reset}
728
902
  ${colors.cyan}Management:${colors.reset}
729
903
  init Initialize portok directories (/etc/portok, /var/lib/portok)
730
904
  add <name> Create a new service instance
905
+ remove <name> Remove a service instance (config + state)
731
906
  list List all configured instances and their status
732
907
 
733
908
  ${colors.cyan}Service Control:${colors.reset}
@@ -757,6 +932,10 @@ ${colors.bold}OPTIONS${colors.reset}
757
932
  --health <path> Health check path (default: /health)
758
933
  --force Overwrite existing config
759
934
 
935
+ ${colors.dim}For 'remove' command:${colors.reset}
936
+ --force Skip confirmation prompt
937
+ --keep-state Keep state file (/var/lib/portok/<name>.json)
938
+
760
939
  ${colors.dim}For 'logs' command:${colors.reset}
761
940
  --follow, -f Follow log output
762
941
  --lines, -n Number of lines to show (default: 50)
@@ -779,6 +958,9 @@ ${colors.bold}EXAMPLES${colors.reset}
779
958
  sudo portok restart api
780
959
  portok logs api --follow
781
960
 
961
+ ${colors.dim}# Remove a service${colors.reset}
962
+ sudo portok remove api --force
963
+
782
964
  ${colors.dim}# Check status of an instance${colors.reset}
783
965
  portok status --instance api
784
966
 
@@ -813,7 +995,7 @@ async function main() {
813
995
  }
814
996
 
815
997
  // Management commands (don't require daemon connection)
816
- const managementCommands = ['init', 'add', 'list', 'start', 'stop', 'restart', 'enable', 'disable', 'logs'];
998
+ const managementCommands = ['init', 'add', 'remove', 'list', 'start', 'stop', 'restart', 'enable', 'disable', 'logs'];
817
999
 
818
1000
  if (managementCommands.includes(args.command)) {
819
1001
  let exitCode = 1;
@@ -824,6 +1006,9 @@ async function main() {
824
1006
  case 'add':
825
1007
  exitCode = await cmdAdd(args.positional[0], args.options);
826
1008
  break;
1009
+ case 'remove':
1010
+ exitCode = await cmdRemove(args.positional[0], args.options);
1011
+ break;
827
1012
  case 'list':
828
1013
  exitCode = await cmdList(args.options);
829
1014
  break;
package/portok@.service CHANGED
@@ -29,7 +29,7 @@ Environment=INSTANCE_NAME=%i
29
29
  WorkingDirectory=/opt/portok
30
30
 
31
31
  # Start the daemon
32
- ExecStart=/usr/bin/node /opt/portok/portokd.mjs
32
+ ExecStart=/usr/bin/node /opt/portok/portokd.js
33
33
 
34
34
  # Restart policy
35
35
  Restart=always
@@ -210,25 +210,34 @@ describe('Connection Draining', () => {
210
210
 
211
211
  await new Promise(resolve => setTimeout(resolve, 100));
212
212
 
213
- // Start continuous traffic
213
+ // Start continuous traffic using a more reliable approach
214
214
  let requestCount = 0;
215
215
  let errorCount = 0;
216
-
217
- const interval = setInterval(async () => {
218
- try {
219
- const res = await fetch(`http://127.0.0.1:${proxyPort}/`);
220
- if (res.ok) {
221
- requestCount++;
222
- } else {
216
+ let running = true;
217
+
218
+ // Use a loop instead of setInterval for more reliable timing
219
+ const trafficLoop = async () => {
220
+ while (running) {
221
+ try {
222
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/`);
223
+ if (res.ok) {
224
+ requestCount++;
225
+ } else {
226
+ errorCount++;
227
+ }
228
+ } catch {
223
229
  errorCount++;
224
230
  }
225
- } catch {
226
- errorCount++;
231
+ // Small delay between requests
232
+ await new Promise(resolve => setTimeout(resolve, 30));
227
233
  }
228
- }, 50);
234
+ };
235
+
236
+ // Start traffic in background
237
+ const trafficPromise = trafficLoop();
229
238
 
230
239
  // Wait a bit, then switch
231
- await new Promise(resolve => setTimeout(resolve, 200));
240
+ await new Promise(resolve => setTimeout(resolve, 150));
232
241
 
233
242
  await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
234
243
  method: 'POST',
@@ -236,12 +245,15 @@ describe('Connection Draining', () => {
236
245
  });
237
246
 
238
247
  // Continue traffic during switch
239
- await new Promise(resolve => setTimeout(resolve, 500));
248
+ await new Promise(resolve => setTimeout(resolve, 300));
240
249
 
241
- clearInterval(interval);
250
+ // Stop traffic
251
+ running = false;
252
+ await trafficPromise;
242
253
 
243
254
  // Should have processed requests with minimal errors
244
- assert.ok(requestCount > 5, `Expected at least 5 requests, got ${requestCount}`);
255
+ // At least 3 requests is enough to verify no drops during switch
256
+ assert.ok(requestCount >= 3, `Expected at least 3 requests, got ${requestCount}`);
245
257
  assert.strictEqual(errorCount, 0, `Expected 0 errors, got ${errorCount}`);
246
258
  });
247
259
  });