@roj-ai/sdk 0.1.21 → 0.1.23

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.
@@ -1,13 +1,20 @@
1
1
  import { afterEach, describe, expect, it } from 'bun:test'
2
+ import { readFile, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
2
5
  import { MemoryEventStore } from '~/core/events/memory.js'
3
6
  import { MockLLMProvider } from '~/core/llm/mock.js'
4
7
  import { selectPluginState } from '~/core/sessions/reducer.js'
8
+ import { SessionId } from '~/core/sessions/schema.js'
5
9
  import { ToolCallId } from '~/core/tools/schema.js'
10
+ import { silentLogger } from '~/lib/logger/logger.js'
11
+ import { createNodePlatform } from '~/testing/node-platform.js'
6
12
  import { createTestPreset, TestHarness } from '~/testing/index.js'
7
13
  import { serviceEvents, servicePlugin } from './plugin.js'
8
14
  import type { ServiceAgentConfig, ServicePluginConfig } from './plugin.js'
9
15
  import { PortPool } from './port-pool.js'
10
- import type { ServiceConfig, ServiceEntry } from './schema.js'
16
+ import type { ServiceConfig, ServiceEntry, ServiceStatus } from './schema.js'
17
+ import { ServiceExecutor } from './service.js'
11
18
 
12
19
  // ============================================================================
13
20
  // Test Service Configs
@@ -592,4 +599,87 @@ describe('services plugin', () => {
592
599
  expect(stoppedEvent).toBeDefined()
593
600
  })
594
601
  })
602
+
603
+ // =========================================================================
604
+ // Port conflict recovery + concurrent start (ServiceExecutor unit-level)
605
+ // =========================================================================
606
+
607
+ describe('port conflict recovery and concurrent start', () => {
608
+ const waitUntil = async (predicate: () => boolean, timeoutMs = 8000): Promise<void> => {
609
+ const deadline = Date.now() + timeoutMs
610
+ while (Date.now() < deadline) {
611
+ if (predicate()) return
612
+ await new Promise((r) => setTimeout(r, 20))
613
+ }
614
+ }
615
+
616
+ it('collapses concurrent start() calls onto a single process', async () => {
617
+ const platform = createNodePlatform()
618
+ const executor = new ServiceExecutor(silentLogger, new PortPool(), { fs: platform.fs, process: platform.process })
619
+ const marker = join(tmpdir(), `roj-svc-mutex-${Date.now()}-${Math.floor(Math.random() * 1e6)}`)
620
+ const config: ServiceConfig = {
621
+ type: 'concurrent',
622
+ description: 'appends one line per spawned process',
623
+ command: `echo spawned >> ${marker} && sleep 30`,
624
+ }
625
+
626
+ try {
627
+ const [r1, r2] = await Promise.all([
628
+ executor.start(config, SessionId('s-concurrent')),
629
+ executor.start(config, SessionId('s-concurrent')),
630
+ ])
631
+ expect(r1.ok).toBe(true)
632
+ expect(r2.ok).toBe(true)
633
+
634
+ // Let the shell flush its append.
635
+ await new Promise((r) => setTimeout(r, 400))
636
+ const lines = (await readFile(marker, 'utf-8')).split('\n').filter((l) => l.length > 0)
637
+ // Without the in-flight lock both starts spawn and the file gets two lines.
638
+ expect(lines.length).toBe(1)
639
+ } finally {
640
+ await executor.shutdown()
641
+ await rm(marker, { force: true })
642
+ }
643
+ })
644
+
645
+ it('recovers on a fresh port when the chosen port is in use (EADDRINUSE)', async () => {
646
+ const platform = createNodePlatform()
647
+ const executor = new ServiceExecutor(silentLogger, new PortPool(), { fs: platform.fs, process: platform.process })
648
+ const marker = join(tmpdir(), `roj-svc-eaddr-${Date.now()}-${Math.floor(Math.random() * 1e6)}`)
649
+ // First spawn fails with an EADDRINUSE-style message; the marker makes the
650
+ // retry (a fresh process) succeed and match the ready pattern.
651
+ const config: ServiceConfig = {
652
+ type: 'flaky-port',
653
+ description: 'EADDRINUSE on first attempt, ready on retry',
654
+ command:
655
+ `if [ -f ${marker} ]; then echo "listening READY"; sleep 30; else touch ${marker}; echo "error: listen EADDRINUSE: address already in use" 1>&2; exit 1; fi`,
656
+ readyPattern: 'READY',
657
+ startupTimeoutMs: 5000,
658
+ }
659
+
660
+ const observed: Array<{ status: ServiceStatus; port?: number }> = []
661
+ executor.onStatusChanged = (_sessionId, _serviceType, status, port) => {
662
+ observed.push({ status, port })
663
+ }
664
+
665
+ try {
666
+ const result = await executor.start(config, SessionId('s-flaky'))
667
+ expect(result.ok).toBe(true)
668
+
669
+ await waitUntil(() => executor.getStatus('flaky-port') === 'ready')
670
+ expect(executor.getStatus('flaky-port')).toBe('ready')
671
+
672
+ // Two 'starting' events: the conflicted port, then a fresh, different one.
673
+ const startingPorts = observed.filter((e) => e.status === 'starting').map((e) => e.port)
674
+ expect(startingPorts.length).toBe(2)
675
+ expect(startingPorts[0]).not.toBe(startingPorts[1])
676
+
677
+ // The transient EADDRINUSE must NOT surface as a terminal failure.
678
+ expect(observed.some((e) => e.status === 'failed')).toBe(false)
679
+ } finally {
680
+ await executor.shutdown()
681
+ await rm(marker, { force: true })
682
+ }
683
+ })
684
+ })
595
685
  })