fix: auto-start code-server when opening file via VS Code (#368)

fix: correctly open file with vscode if vscode is not started yet
This commit is contained in:
Carlos Florêncio
2026-03-05 00:25:34 +00:00
committed by GitHub
parent c73fa7471b
commit 3a292098ca
7 changed files with 476 additions and 13 deletions
@@ -5,8 +5,11 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/agentctl/types"
)
@@ -128,7 +131,12 @@ func TestHandleVscodeOpenFile_InvalidBody(t *testing.T) {
}
}
func TestHandleVscodeOpenFile_NotRunning(t *testing.T) {
func TestHandleVscodeOpenFile_NotRunning_AutoStartAttempted(t *testing.T) {
// Use isolated TMPDIR so the IPC socket search finds no sockets.
t.Setenv("TMPDIR", t.TempDir())
// Isolate HOME so ResolveBinary won't find a real code-server install.
t.Setenv("HOME", t.TempDir())
s := newTestServer(t)
body, _ := json.Marshal(types.VscodeOpenFileRequest{Path: "main.go", Line: 10, Col: 5})
@@ -138,9 +146,21 @@ func TestHandleVscodeOpenFile_NotRunning(t *testing.T) {
s.router.ServeHTTP(w, req)
// Code-server is not running, should get 500
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
// Auto-start is attempted but fails (no code-server binary in isolated HOME).
// The key assertion is that auto-start was attempted: status != stopped.
info := s.procMgr.VscodeInfo()
if info.Status == "stopped" {
t.Error("expected vscode status to change from stopped (auto-start should have been attempted)")
}
var resp types.VscodeOpenFileResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
// The error should NOT be the old "is not running" message.
if !resp.Success && strings.Contains(resp.Error, "is not running") {
t.Errorf("expected auto-start to be attempted, not 'is not running'; got: %s", resp.Error)
}
}
@@ -168,3 +188,54 @@ func TestHandleVscodeStatus_AfterStart(t *testing.T) {
t.Errorf("expected installing/error/starting, got %q", resp.Status)
}
}
// TestVscodeOpenFile_WaitsForRunning_E2E verifies the end-to-end flow where
// open-file is called while vscode is still starting up. The handler should
// wait for vscode to become ready before attempting to open the file.
// This uses the real HTTP handler chain: HTTP request → Gin handler →
// procMgr.VscodeOpenFile (auto-start + WaitForRunning) → VscodeManager.OpenFile.
func TestVscodeOpenFile_WaitsForRunning_E2E(t *testing.T) {
s := newTestServer(t)
ts := httptest.NewServer(s.router)
defer ts.Close()
// Inject a VscodeManager that transitions from "installing" to "running" after 200ms.
s.procMgr.SetVscodeTransitionForTest(process.VscodeStatusInstalling, 0, 200*time.Millisecond)
body, _ := json.Marshal(types.VscodeOpenFileRequest{Path: "main.go", Line: 1})
// open-file should block waiting for vscode to become running.
// It will reach "running" status, but then OpenFile will fail because
// there's no real code-server binary/IPC socket. That's expected —
// the important assertion is that WaitForRunning succeeded (no "not running" error).
resp, err := http.Post(ts.URL+"/api/v1/vscode/open-file", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close() //nolint:errcheck
var result types.VscodeOpenFileResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// The request should fail because there's no real code-server binary,
// but the error should be about the binary/IPC socket, NOT about
// "code-server is not running" — proving WaitForRunning worked.
if result.Success {
t.Error("expected success=false (no real code-server binary)")
}
if result.Error == "" {
t.Error("expected an error message")
}
// Crucially, the error must NOT be the old "code-server is not running" message.
if strings.Contains(result.Error, "is not running") {
t.Errorf("expected error about binary/socket, not 'is not running'; got: %s", result.Error)
}
// The error should be about the binary path or IPC socket not being found.
if !strings.Contains(result.Error, "binary") && !strings.Contains(result.Error, "remote CLI") &&
!strings.Contains(result.Error, "IPC") && !strings.Contains(result.Error, "not resolved") {
t.Logf("unexpected error (test may need updating): %s", result.Error)
}
}
@@ -1146,13 +1146,36 @@ func (m *Manager) VscodeInfo() VscodeInfo {
}
// VscodeOpenFile opens a file in the running VS Code instance via the Remote CLI.
// If code-server is not running, it auto-starts it and waits for readiness.
//
// Note: there is a benign race between the needsStart check and WaitForRunning —
// another goroutine could stop vscode in between. WaitForRunning handles this
// correctly by returning an error for stopped/error states.
func (m *Manager) VscodeOpenFile(ctx context.Context, path string, line, col int) error {
m.vscodeMu.Lock()
needsStart := m.vscode == nil
if !needsStart {
info := m.vscode.Info()
needsStart = info.Status == VscodeStatusStopped || info.Status == VscodeStatusError
}
m.vscodeMu.Unlock()
if needsStart {
m.logger.Info("auto-starting code-server for open-file request")
m.StartVscode(ctx, "")
}
m.vscodeMu.Lock()
vscode := m.vscode
m.vscodeMu.Unlock()
if vscode == nil {
return fmt.Errorf("code-server is not running")
return fmt.Errorf("failed to initialize code-server")
}
if err := vscode.WaitForRunning(ctx); err != nil {
return fmt.Errorf("code-server not ready: %w", err)
}
return vscode.OpenFile(ctx, path, line, col)
}
@@ -1,5 +1,7 @@
package process
import "time"
// SetVscodeForTest injects a VscodeManager with the given status and port.
// Intended for use in tests where starting a real code-server is not feasible.
func (m *Manager) SetVscodeForTest(status VscodeStatus, port int) {
@@ -10,3 +12,22 @@ func (m *Manager) SetVscodeForTest(status VscodeStatus, port int) {
port: port,
}
}
// SetVscodeTransitionForTest injects a VscodeManager that starts in the given
// status and transitions to VscodeStatusRunning after the specified delay.
// Useful for testing code that calls WaitForRunning.
func (m *Manager) SetVscodeTransitionForTest(initialStatus VscodeStatus, port int, delay time.Duration) {
m.vscodeMu.Lock()
defer m.vscodeMu.Unlock()
vm := &VscodeManager{
status: initialStatus,
port: port,
}
m.vscode = vm
go func() {
time.Sleep(delay)
vm.mu.Lock()
vm.status = VscodeStatusRunning
vm.mu.Unlock()
}()
}
@@ -378,6 +378,35 @@ func (v *VscodeManager) Stop(ctx context.Context) error {
return nil
}
// WaitForRunning blocks until the code-server reaches "running" status.
// Returns immediately if already running. Returns an error if the status
// transitions to "error" or "stopped", or if the context is cancelled.
func (v *VscodeManager) WaitForRunning(ctx context.Context) error {
for {
v.mu.Lock()
s := v.status
errMsg := v.err
v.mu.Unlock()
switch s {
case VscodeStatusRunning:
return nil
case VscodeStatusError:
return fmt.Errorf("code-server failed to start: %s", errMsg)
case VscodeStatusStopped:
return fmt.Errorf("code-server is stopped")
case VscodeStatusInstalling, VscodeStatusStarting:
// still booting — wait and retry
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
}
// Info returns the current VS Code server state.
func (v *VscodeManager) Info() VscodeInfo {
v.mu.Lock()
@@ -480,7 +509,9 @@ func (v *VscodeManager) OpenFile(ctx context.Context, path string, line, col int
}
// Find the IPC socket for the running VS Code instance.
ipcSocket, err := findVscodeIPCSocket()
// The IPC socket may not appear immediately after code-server's HTTP port
// is ready, so poll for it with a timeout.
ipcSocket, err := waitForVscodeIPCSocket(ctx, 15*time.Second)
if err != nil {
return fmt.Errorf("failed to find VS Code IPC socket: %w", err)
}
@@ -527,6 +558,8 @@ func resolveRemoteCLI(binaryPath string) string {
}
// findVscodeIPCSocket searches /tmp for the most recent vscode-ipc-*.sock file.
// It validates each candidate by attempting a Unix socket connection to skip
// stale sockets left behind by crashed VS Code instances.
func findVscodeIPCSocket() (string, error) {
tmpDir := os.TempDir()
entries, err := os.ReadDir(tmpDir)
@@ -563,7 +596,42 @@ func findVscodeIPCSocket() (string, error) {
return socks[i].modTime.After(socks[j].modTime)
})
return socks[0].path, nil
// Try each socket, most recent first, and return the first one that accepts
// a connection. This skips stale sockets from crashed processes.
for _, s := range socks {
conn, dialErr := net.DialTimeout("unix", s.path, 500*time.Millisecond)
if dialErr != nil {
continue
}
_ = conn.Close()
return s.path, nil
}
return "", fmt.Errorf("no live vscode-ipc-*.sock found in %s (%d stale sockets skipped)", tmpDir, len(socks))
}
// waitForVscodeIPCSocket polls for a live VS Code IPC socket until one is found
// or the timeout/context expires. Code-server may take several seconds after its
// HTTP port is ready before creating the IPC socket.
func waitForVscodeIPCSocket(ctx context.Context, timeout time.Duration) (string, error) {
deadline := time.After(timeout)
var lastErr error
for {
sock, err := findVscodeIPCSocket()
if err == nil {
return sock, nil
}
lastErr = err
select {
case <-ctx.Done():
return "", fmt.Errorf("%w (last attempt: %v)", ctx.Err(), lastErr)
case <-deadline:
return "", lastErr
case <-time.After(1 * time.Second):
}
}
}
// setStatus updates the status under lock.
@@ -3,6 +3,7 @@ package process
import (
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"runtime"
@@ -80,19 +81,23 @@ func TestFindVscodeIPCSocket_NoSockets(t *testing.T) {
}
func TestFindVscodeIPCSocket_ReturnsMostRecent(t *testing.T) {
tmpDir := t.TempDir()
// Use /tmp directly to avoid macOS Unix socket path length limits.
tmpDir, err := os.MkdirTemp("/tmp", "vscode-ipc-test-")
require.NoError(t, err)
defer os.RemoveAll(tmpDir) //nolint:errcheck
t.Setenv("TMPDIR", tmpDir)
// Create two sock files with different mod times
// Create a stale socket file (no listener — should be skipped)
sock1 := filepath.Join(tmpDir, "vscode-ipc-old.sock")
sock2 := filepath.Join(tmpDir, "vscode-ipc-new.sock")
require.NoError(t, os.WriteFile(sock1, nil, 0o600))
// Set old mod time
oldTime := time.Now().Add(-1 * time.Hour)
require.NoError(t, os.Chtimes(sock1, oldTime, oldTime))
require.NoError(t, os.WriteFile(sock2, nil, 0o600))
// Create a live socket with a real Unix listener
sock2 := filepath.Join(tmpDir, "vscode-ipc-new.sock")
ln, err := net.Listen("unix", sock2)
require.NoError(t, err)
defer ln.Close() //nolint:errcheck
result, err := findVscodeIPCSocket()
require.NoError(t, err)
@@ -112,6 +117,48 @@ func TestFindVscodeIPCSocket_IgnoresNonSockFiles(t *testing.T) {
assert.Contains(t, err.Error(), "no vscode-ipc-")
}
func TestWaitForVscodeIPCSocket_SocketAppearsAfterDelay(t *testing.T) {
tmpDir, err := os.MkdirTemp("/tmp", "vscode-ipc-test-")
require.NoError(t, err)
defer os.RemoveAll(tmpDir) //nolint:errcheck
t.Setenv("TMPDIR", tmpDir)
// Create the socket after a short delay, simulating code-server startup.
sockPath := filepath.Join(tmpDir, "vscode-ipc-delayed.sock")
lnCh := make(chan net.Listener, 1)
go func() {
time.Sleep(500 * time.Millisecond)
ln, listenErr := net.Listen("unix", sockPath)
if listenErr != nil {
return
}
lnCh <- ln
}()
t.Cleanup(func() {
select {
case ln := <-lnCh:
ln.Close() //nolint:errcheck
default:
}
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := waitForVscodeIPCSocket(ctx, 5*time.Second)
require.NoError(t, err)
assert.Equal(t, sockPath, result)
}
func TestWaitForVscodeIPCSocket_Timeout(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("TMPDIR", tmpDir)
ctx := context.Background()
_, err := waitForVscodeIPCSocket(ctx, 2*time.Second)
assert.Error(t, err)
}
func TestWriteThemeSettings_DarkTheme(t *testing.T) {
tmpDir := t.TempDir()
log := newTestLogger(t)
@@ -279,3 +326,77 @@ func TestVscodeInfo_JSON_OmitsEmpty(t *testing.T) {
assert.NotContains(t, raw, "error")
assert.NotContains(t, raw, "message")
}
func TestVscodeManager_WaitForRunning_AlreadyRunning(t *testing.T) {
log := newTestLogger(t)
v := NewVscodeManager("code-server", "/workspace", "dark", nil, log)
v.mu.Lock()
v.status = VscodeStatusRunning
v.mu.Unlock()
err := v.WaitForRunning(context.Background())
assert.NoError(t, err)
}
func TestVscodeManager_WaitForRunning_ErrorStatus(t *testing.T) {
log := newTestLogger(t)
v := NewVscodeManager("code-server", "/workspace", "dark", nil, log)
v.mu.Lock()
v.status = VscodeStatusError
v.err = "install failed"
v.mu.Unlock()
err := v.WaitForRunning(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "install failed")
}
func TestVscodeManager_WaitForRunning_StoppedStatus(t *testing.T) {
log := newTestLogger(t)
v := NewVscodeManager("code-server", "/workspace", "dark", nil, log)
err := v.WaitForRunning(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "stopped")
}
func TestVscodeManager_WaitForRunning_ContextCancelled(t *testing.T) {
log := newTestLogger(t)
v := NewVscodeManager("code-server", "/workspace", "dark", nil, log)
v.mu.Lock()
v.status = VscodeStatusInstalling
v.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := v.WaitForRunning(ctx)
assert.Error(t, err)
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
func TestVscodeManager_WaitForRunning_TransitionsToRunning(t *testing.T) {
log := newTestLogger(t)
v := NewVscodeManager("code-server", "/workspace", "dark", nil, log)
v.mu.Lock()
v.status = VscodeStatusInstalling
v.mu.Unlock()
// Simulate async startup completing after a short delay
go func() {
time.Sleep(200 * time.Millisecond)
v.mu.Lock()
v.status = VscodeStatusRunning
v.mu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := v.WaitForRunning(ctx)
assert.NoError(t, err)
}
+10
View File
@@ -289,4 +289,14 @@ export class SessionPage {
await expect(this.files).toBeVisible({ timeout: 10_000 });
await expect(this.sidebar).toBeVisible();
}
/** Locator for the VS Code dockview tab. */
vscodeTab(): Locator {
return this.page.locator(".dv-default-tab:has-text('VS Code')");
}
/** Locator for the VS Code code-server iframe. */
vscodeIframe(): Locator {
return this.page.locator('iframe[title="VS Code"]');
}
}
+149
View File
@@ -0,0 +1,149 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { type Page } from "@playwright/test";
import { test, expect } from "../fixtures/test-base";
import type { SeedData } from "../fixtures/test-base";
import type { ApiClient } from "../helpers/api-client";
import { SessionPage } from "../pages/session-page";
/**
* Resolve the code-server install directory. Checks the default kandev install
* path at ~/.kandev/tools/code-server/ (real HOME, not e2e temp HOME).
*/
function findCodeServerInstall(): string | null {
const home = os.homedir();
const installDir = path.join(home, ".kandev", "tools", "code-server");
if (!fs.existsSync(installDir)) return null;
const entries = fs.readdirSync(installDir);
for (const entry of entries) {
const binPath = path.join(installDir, entry, "bin", "code-server");
if (fs.existsSync(binPath)) return installDir;
}
return null;
}
/**
* Seed a task + session via the API and navigate directly to the session page.
* Waits for the mock agent to complete its turn (idle input visible).
*/
async function seedTaskWithSession(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<{ session: SessionPage; sessionId: string }> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await testPage.goto(`/s/${task.session_id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
return { session, sessionId: task.session_id };
}
test.describe("VS Code open file", () => {
test.describe.configure({ retries: 1 });
const codeServerDir = findCodeServerInstall();
test.beforeEach(async ({ backend }) => {
if (!codeServerDir) {
test.skip(true, "code-server not installed — skipping VS Code e2e tests");
return;
}
// Symlink the real code-server install into the e2e backend's HOME so
// ResolveBinary finds the pre-installed binary without re-downloading.
const targetDir = path.join(backend.tmpDir, ".kandev", "tools", "code-server");
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
fs.symlinkSync(codeServerDir, targetDir);
}
});
/**
* Full UI-driven flow:
* 1. Create a file in the workspace
* 2. Click the file in the file tree → opens in built-in editor
* 3. Click "Open with..." → "VS Code (Embedded)" in the editor toolbar
* 4. Assert VS Code panel opens, code-server starts, and iframe loads
*/
test("opens file in VS Code via Open With menu", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
// Create a file in the workspace repo BEFORE navigating so the file tree
// picks it up on its initial load.
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const filePath = path.join(repoDir, "hello.txt");
fs.writeFileSync(filePath, "Hello from e2e test!\n");
const { session } = await seedTaskWithSession(
testPage,
apiClient,
seedData,
"VSCode Open File Test",
);
// Click the "Files" tab to ensure the file tree is visible
await session.clickTab("Files");
await expect(session.files).toBeVisible({ timeout: 5_000 });
// Wait for the file tree to load and show our file
const fileRow = session.files.getByText("hello.txt");
await expect(fileRow).toBeVisible({ timeout: 10_000 });
// Click the file → opens in the built-in Monaco editor panel
await fileRow.click();
// Wait for the file editor tab to appear in dockview
const editorTab = testPage.locator(".dv-default-tab:has-text('hello.txt')");
await expect(editorTab).toBeVisible({ timeout: 10_000 });
// Click the "Open with..." button (IconExternalLink) in the Monaco editor toolbar.
// Tabler icons render with class "tabler-icon tabler-icon-external-link".
// Find the button that wraps this icon (the FileActionsDropdown trigger).
const openWithBtn = testPage.locator("button:has(.tabler-icon-external-link)").first();
await openWithBtn.waitFor({ state: "visible", timeout: 10_000 });
await openWithBtn.click();
// Click "VS Code (Embedded)" in the dropdown menu
const vsCodeMenuItem = testPage.getByRole("menuitem", { name: /VS Code/ }).first();
await expect(vsCodeMenuItem).toBeVisible({ timeout: 5_000 });
await vsCodeMenuItem.click();
// Assert: VS Code tab appears in dockview
await expect(session.vscodeTab()).toBeVisible({ timeout: 10_000 });
// Assert: "Starting VS Code Server" progress text is visible while booting
await expect(testPage.getByText("Starting VS Code Server")).toBeVisible({ timeout: 30_000 });
// Assert: code-server iframe loads (code-server is running)
await expect(session.vscodeIframe()).toBeVisible({ timeout: 90_000 });
// Assert: hello.txt is opened inside code-server (visible as a tab or in the editor).
// The iframe is same-origin (served via kandev proxy) so frameLocator works.
const vscodeFrame = testPage.frameLocator('iframe[title="VS Code"]');
await expect(vscodeFrame.getByRole("tab", { name: /hello\.txt/ })).toBeVisible({
timeout: 30_000,
});
});
});