Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions internal/launcher/getorlaunchforsession_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,116 @@ func TestGetOrLaunchForSession_StdioSessionPoolHit_DifferentSessions(t *testing.
assert.Equal(httpConn, connB)
}

// TestGetOrLaunchForSession_ServerNotFound tests that an unknown server ID returns an error.
func TestGetOrLaunchForSession_ServerNotFound(t *testing.T) {
require := require.New(t)

ctx := context.Background()
cfg := newTestConfig(map[string]*config.ServerConfig{})
l := New(ctx, cfg)
defer l.Close()

conn, err := GetOrLaunchForSession(l, "nonexistent-server", "session-1")
require.Error(err)
require.Nil(conn)
require.ErrorIs(err, ErrServerNotFound)
}

// TestGetOrLaunchForSession_HTTPBackendRecordsStart tests that GetOrLaunchForSession
// for an HTTP backend records the server start time.
func TestGetOrLaunchForSession_HTTPBackendRecordsStart(t *testing.T) {
require := require.New(t)
assert := assert.New(t)

mockServer := newMockHTTPMCPServer(t)
defer mockServer.Close()

ctx := context.Background()
cfg := newTestConfig(map[string]*config.ServerConfig{
"http-backend": {
Type: "http",
URL: mockServer.URL,
},
})

l := New(ctx, cfg)
defer l.Close()

// First call: creates the connection and records start time.
conn1, err := GetOrLaunchForSession(l, "http-backend", "session-x")
require.NoError(err)
require.NotNil(conn1)

// Verify server state was recorded as running.
state := l.GetServerState("http-backend")
assert.Equal("running", state.Status)
assert.False(state.StartedAt.IsZero(), "StartedAt should be set after successful launch")

// Second call for a different session ID should return the same cached connection.
conn2, err := GetOrLaunchForSession(l, "http-backend", "session-y")
require.NoError(err)
require.NotNil(conn2)
assert.Equal(conn1, conn2, "HTTP backends reuse a single stateless connection")
}

// TestGetOrLaunchForSession_DoubleCheckLockPoolHit exercises the double-check locking
// path where another goroutine populates the session pool between the first
// check and the mutex acquisition.
func TestGetOrLaunchForSession_DoubleCheckLockPoolHit(t *testing.T) {
require := require.New(t)
assert := assert.New(t)

mockServer := newMockHTTPMCPServer(t)
defer mockServer.Close()

ctx := context.Background()
cfg := newTestConfig(map[string]*config.ServerConfig{
"http-helper": {
Type: "http",
URL: mockServer.URL,
},
"stdio-backend": {
Type: "stdio",
Command: "docker",
Args: []string{"run", "--rm", "-i", "nonexistent:latest"},
},
})

l := New(ctx, cfg)
defer l.Close()

// Get a real connection to put in the pool.
httpConn, err := GetOrLaunch(l, "http-helper")
require.NoError(err)
require.NotNil(httpConn)

sessionID := "double-check-session"

// Simulate: first Get check returns false (not in pool yet),
// but by the time we acquire the mutex and do the second check,
// another goroutine already populated it.
// We do this by calling Set while holding no external lock, then
// calling GetOrLaunchForSession which will find it on the second check.

// First call: pool is empty — will fail to launch, but we'll use a
// pre-populated pool to hit the "double-check" path directly.
// Pre-populate AFTER the first Get would run by directly calling Set
// and then invoking GetOrLaunchForSession (which will hit the double-check).

// Manually populate the pool so the second check inside GetOrLaunchForSession
// finds the entry.
l.mu.Lock()
l.sessionPool.Set("stdio-backend", sessionID, httpConn)
l.mu.Unlock()

// Now call GetOrLaunchForSession. The first pool.Get (without lock) should find it
// and return immediately.
conn, err := GetOrLaunchForSession(l, "stdio-backend", sessionID)
require.NoError(err)
require.NotNil(conn)
assert.Equal(httpConn, conn)
}

// newMockHTTPMCPServer creates a test HTTP server that responds to MCP initialize requests.
func newMockHTTPMCPServer(t *testing.T) *httptest.Server {
t.Helper()
Expand Down
20 changes: 19 additions & 1 deletion internal/launcher/launcher_runtime_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package launcher

import (
"context"
"testing"

"github.com/github/gh-aw-mcpg/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/github/gh-aw-mcpg/internal/config"
)

func TestIsDirectStdioCommand(t *testing.T) {
Expand All @@ -22,6 +25,7 @@ func TestIsDirectStdioCommand(t *testing.T) {
{name: "direct command", server: &config.ServerConfig{Command: "python", Args: []string{"-m", "server"}}, expected: true},
{name: "direct node command", server: &config.ServerConfig{Command: "node", Args: []string{"server.js"}}, expected: true},
{name: "direct shell script", server: &config.ServerConfig{Command: "/app/start.sh", Args: []string{}}, expected: true},
{name: "nil config", server: nil, expected: true},
}

for _, tt := range tests {
Expand All @@ -30,3 +34,17 @@ func TestIsDirectStdioCommand(t *testing.T) {
})
}
}

// TestNew_RunningInContainer verifies that New correctly detects the container
// environment and sets runningInContainer=true when RUNNING_IN_CONTAINER=true.
func TestNew_RunningInContainer(t *testing.T) {
t.Setenv("RUNNING_IN_CONTAINER", "true")

ctx := context.Background()
cfg := newTestConfig(map[string]*config.ServerConfig{})
l := New(ctx, cfg)
require.NotNil(t, l)
defer l.Close()

assert.True(t, l.runningInContainer, "Launcher should detect container environment")
}