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:
parent
8d405c4234
commit
f2da70d3bb
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue