diff --git a/backend/internal/daemon/supervisor/listen_windows.go b/backend/internal/daemon/supervisor/listen_windows.go index 52fa22624..e4ffa29c5 100644 --- a/backend/internal/daemon/supervisor/listen_windows.go +++ b/backend/internal/daemon/supervisor/listen_windows.go @@ -4,20 +4,37 @@ package supervisor import ( "net" + "path/filepath" + "regexp" "github.com/Microsoft/go-winio" ) -const pipeName = `\\.\pipe\ao-supervise` +var unsafePipeChars = regexp.MustCompile(`[^a-zA-Z0-9\-]`) + +// pipeNameFromRunFile derives a per-instance named-pipe path from the +// run-file's parent directory, mirroring the Unix supervise.sock placement. +// ~/.ao/running.json → \\.\pipe\ao-supervise (default, backward-compatible) +// ~/.ao/dev/running.json → \\.\pipe\ao-supervise-dev (dev isolation) +func pipeNameFromRunFile(runFilePath string) string { + if runFilePath == "" { + return `\\.\pipe\ao-supervise` + } + dir := filepath.Base(filepath.Dir(runFilePath)) + if dir == ".ao" || dir == "." || dir == "" { + return `\\.\pipe\ao-supervise` + } + return `\\.\pipe\ao-supervise-` + unsafePipeChars.ReplaceAllString(dir, "-") +} // Listen creates a Windows named pipe listener for the supervisor watchdog. -// runFilePath is ignored on Windows: named pipes are global and identified -// by name only. -// ponytail: global pipe name; add a per-instance suffix if multiple daemons must coexist on one machine. -func Listen(_ string) (net.Listener, string, error) { - ln, err := winio.ListenPipe(pipeName, nil) +// The pipe name is derived from runFilePath so dev and installed-app instances +// use separate pipes and cannot collide. +func Listen(runFilePath string) (net.Listener, string, error) { + name := pipeNameFromRunFile(runFilePath) + ln, err := winio.ListenPipe(name, nil) if err != nil { return nil, "", err } - return ln, pipeName, nil + return ln, name, nil } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index bec40303d..cff9c5088 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -133,6 +133,13 @@ const IMPORT_SCAN_SKIP_DIRS = new Set([ const isDev = !app.isPackaged; +// Dev mode uses a separate port and state subdirectory so it never collides with +// a concurrently running installed-app daemon. The subdir also isolates supervise.sock +// on Unix (backend derives it as dir(RunFilePath)/supervise.sock) and the named pipe +// on Windows (supervisorPipeFromRunFile derives it from the same dir basename). +const DEV_DAEMON_PORT = 3002; +const DEV_STATE_SUBDIR = "dev"; // ~/.ao/dev/ + const RENDERER_SCHEME = "app"; const RENDERER_HOST = "renderer"; const RENDERER_ORIGIN = `${RENDERER_SCHEME}://${RENDERER_HOST}`; @@ -292,6 +299,7 @@ const DAEMON_PROBE_TIMEOUT_MS = 2_000; function runFilePath(): string | null { if (process.env.AO_RUN_FILE) return process.env.AO_RUN_FILE; + if (isDev) return path.join(os.homedir(), ".ao", DEV_STATE_SUBDIR, "running.json"); return defaultRunFilePath(process.platform, process.env, os.homedir()); } @@ -376,11 +384,19 @@ function daemonEnv(): NodeJS.ProcessEnv { // supervisor on attach (headless `ao start` daemons get no AO_OWNER and stay // unlinked, preserving their persistence across app quit). const ownerTag = { AO_OWNER: "app" }; + // In dev mode, inject isolation defaults so the dev daemon never collides with + // the installed app. User-set env vars take priority (checked first). + const devExtras: Record = {}; + if (isDev) { + if (!process.env.AO_PORT) devExtras.AO_PORT = String(DEV_DAEMON_PORT); + if (!process.env.AO_RUN_FILE) devExtras.AO_RUN_FILE = runFilePath() ?? ""; + if (!process.env.AO_DATA_DIR) devExtras.AO_DATA_DIR = path.join(os.homedir(), ".ao", DEV_STATE_SUBDIR, "data"); + } // Windows keeps the old behavior exactly: no shell probe, no unix PATH floor. if (process.platform === "win32") { - return { ...process.env, ...telemetryOverrides(), ...ownerTag }; + return { ...process.env, ...devExtras, ...telemetryOverrides(), ...ownerTag }; } - return buildDaemonEnv(process.env, cachedShellEnv, { ...telemetryOverrides(), ...ownerTag }); + return buildDaemonEnv(process.env, cachedShellEnv, { ...devExtras, ...telemetryOverrides(), ...ownerTag }); } function pathKey(value: string): string { @@ -458,11 +474,18 @@ function daemonIdentityError(launch: DaemonLaunchSpec, probe: DaemonProbe): stri * headless `ao start` daemons stay unlinked so they remain persistent after * app quit. */ +function supervisorPipeFromRunFile(rfp: string | null): string { + if (!rfp) return "\\\\.\\pipe\\ao-supervise"; + const dir = path.basename(path.dirname(rfp)); + if (dir === ".ao" || dir === "." || dir === "") return "\\\\.\\pipe\\ao-supervise"; + return "\\\\.\\pipe\\ao-supervise-" + dir.replace(/[^a-zA-Z0-9-]/g, "-"); +} + function establishSupervisorLink(): void { const rfp = runFilePath(); const addr = process.platform === "win32" - ? "\\\\.\\pipe\\ao-supervise" + ? supervisorPipeFromRunFile(rfp) : rfp ? path.join(path.dirname(rfp), "supervise.sock") : null; @@ -541,6 +564,13 @@ async function startDaemon(): Promise { return daemonStartPromise; } +// The port this Electron instance expects the daemon to bind. In dev mode a +// separate port isolates the dev daemon from the installed-app daemon. +// AO_PORT always wins if set explicitly. +function resolvedDaemonPort(): number { + return isDev && !process.env.AO_PORT ? DEV_DAEMON_PORT : expectedDaemonPort(process.env); +} + async function startDaemonInner(startEpoch: number): Promise { if (daemonProcess) { return daemonStatus; @@ -591,7 +621,7 @@ async function startDaemonInner(startEpoch: number): Promise { // port the Go child would bind and collide on — probing a hardcoded 3001 would // miss an AO_PORT override. const directDaemon = await resolveDaemonFromPort({ - expectedPort: expectedDaemonPort(process.env), + expectedPort: resolvedDaemonPort(), probe: readDaemonProbe, identityError: (probe) => daemonIdentityError(launch, probe), }); @@ -631,7 +661,7 @@ async function startDaemonInner(startEpoch: number): Promise { // killed here), and a foreign non-AO process holding the port with a dead // run-file PID is not replaced (out of scope). When no holder is detectable, // skip straight to spawn. - const orphanProbe = await readDaemonProbe(expectedDaemonPort(process.env), "healthz"); + const orphanProbe = await readDaemonProbe(resolvedDaemonPort(), "healthz"); const runFilePath_ = runFilePath(); let runFilePid: number | null = null; if (runFilePath_) { @@ -671,7 +701,7 @@ async function startDaemonInner(startEpoch: number): Promise { const TAKEOVER_POLL_MS = 200; const deadline = Date.now() + TAKEOVER_TIMEOUT_MS; while (Date.now() < deadline) { - const still = await readDaemonProbe(expectedDaemonPort(process.env), "healthz"); + const still = await readDaemonProbe(resolvedDaemonPort(), "healthz"); if (!still) break; await new Promise((r) => setTimeout(r, TAKEOVER_POLL_MS)); } @@ -781,7 +811,7 @@ async function startDaemonInner(startEpoch: number): Promise { stopDiscovery(); setDaemonStatus({ state: "ready", - port: process.env.AO_PORT ? Number(process.env.AO_PORT) : undefined, + port: resolvedDaemonPort(), message: "Daemon port not confirmed from logs or running.json; assuming the configured port.", code: "port_unconfirmed", });