fix(dev): isolate dev daemon from installed app on port and state dir (#2465)

* fix(dev): isolate dev daemon from installed app on port and state dir

When running npm run dev while an installed AO app is already running,
both shared port 3001 and ~/.ao/running.json. The dev Electron would
attach to the installed daemon and tear it down on quit via the
supervisor link.

Fix: in dev mode (app.isPackaged === false), default to:
  - AO_PORT=3002          (separate port, no collision)
  - AO_RUN_FILE=~/.ao/dev/running.json  (isolates supervise.sock too,
    since the backend derives it as dir(RunFilePath)/supervise.sock)
  - AO_DATA_DIR=~/.ao/dev/data          (separate SQLite DB)

Explicit env var overrides still win, so contributors can further
customise via their shell environment. resolvedDaemonPort() encapsulates
the port lookup so all three probe call-sites stay consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(dev): isolate Windows supervisor pipe per run-file instance

On Windows the supervisor previously used a fixed global named pipe
(\\.\pipe\ao-supervise) on both the Go backend and Electron frontend,
so dev and installed-app daemons could still collide despite the
separate port and state dir. Both sides now derive the pipe name from
the run-file's parent directory, mirroring the Unix supervise.sock
placement: ~/.ao/dev/running.json → \\.\pipe\ao-supervise-dev.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Pulkit Saraf 2026-07-08 22:34:21 +05:30 committed by GitHub
parent 8d405c4234
commit f2da70d3bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 61 additions and 14 deletions

View File

@ -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
}

View File

@ -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<string, string> = {};
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<DaemonStatus> {
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<DaemonStatus> {
if (daemonProcess) {
return daemonStatus;
@ -591,7 +621,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
// 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<DaemonStatus> {
// 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<DaemonStatus> {
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<void>((r) => setTimeout(r, TAKEOVER_POLL_MS));
}
@ -781,7 +811,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
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",
});