From eb149d1b0a5452836a8990ecc9d5a8a6e2663d45 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 16 Jul 2026 13:55:05 +0000 Subject: [PATCH] fix(coding-agent/launch): handle EISDIR from realpath on drive roots fs.realpath throws EISDIR on Windows drive roots (e.g. R:\), but canonicalProjectDir in launch/presence.ts and launch/client.ts only recovered ENOENT, aborting startup. Both now fall back to path.resolve() on EISDIR, matching the existing ENOENT handling. Fixes #5708 --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/src/launch/client.ts | 4 +- packages/coding-agent/src/launch/presence.ts | 4 +- .../test/tools/launch-eisdir-fallback.test.ts | 48 +++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 packages/coding-agent/test/tools/launch-eisdir-fallback.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0e753ced7..a5f500030 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed a startup crash on Windows when running from a drive root (e.g. `R:\`): `fs.realpath` throws `EISDIR` there, but `canonicalProjectDir` in `launch/presence.ts` and `launch/client.ts` only recovered `ENOENT`. It now also falls back to `path.resolve()` on `EISDIR` ([#5708](https://github.com/can1357/oh-my-pi/issues/5708) by [@ve3xone](https://github.com/ve3xone)). + ## [17.0.1] - 2026-07-16 ### Changed diff --git a/packages/coding-agent/src/launch/client.ts b/packages/coding-agent/src/launch/client.ts index 0e06bf06c..c26dfb140 100644 --- a/packages/coding-agent/src/launch/client.ts +++ b/packages/coding-agent/src/launch/client.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import * as net from "node:net"; import * as os from "node:os"; import * as path from "node:path"; -import { isEexist, isEnoent, postmortem } from "@oh-my-pi/pi-utils"; +import { isEexist, isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils"; import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client"; import { daemonBrokerEndpoint, daemonRuntimeDir } from "./paths"; import { @@ -49,7 +49,7 @@ async function canonicalProjectDir(projectDir: string): Promise { try { return await fs.realpath(resolved); } catch (error) { - if (isEnoent(error)) return resolved; + if (isEnoent(error) || isEisdir(error)) return resolved; throw error; } } diff --git a/packages/coding-agent/src/launch/presence.ts b/packages/coding-agent/src/launch/presence.ts index afce0d88c..e63a0e852 100644 --- a/packages/coding-agent/src/launch/presence.ts +++ b/packages/coding-agent/src/launch/presence.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { isEnoent, postmortem } from "@oh-my-pi/pi-utils"; +import { isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils"; import { daemonRuntimeDir } from "./paths"; const CLIENTS_DIR = "clients"; @@ -15,7 +15,7 @@ async function canonicalProjectDir(projectDir: string): Promise { try { return await fs.realpath(resolved); } catch (error) { - if (isEnoent(error)) return resolved; + if (isEnoent(error) || isEisdir(error)) return resolved; throw error; } } diff --git a/packages/coding-agent/test/tools/launch-eisdir-fallback.test.ts b/packages/coding-agent/test/tools/launch-eisdir-fallback.test.ts new file mode 100644 index 000000000..eb4c084df --- /dev/null +++ b/packages/coding-agent/test/tools/launch-eisdir-fallback.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import type { PathLike } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { registerDaemonProjectPresence } from "../../src/launch/presence"; + +describe("daemon presence canonicalProjectDir EISDIR fallback", () => { + const originalRealpath = fs.realpath.bind(fs); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("falls back to the resolved path when realpath throws EISDIR", async () => { + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-eisdir-fallback-")); + const runtimeDir = path.join(projectDir, "runtime"); + const resolvedProjectDir = path.resolve(projectDir); + let realpathCalls = 0; + + vi.spyOn(fs, "realpath").mockImplementation((async (p: PathLike) => { + if (path.resolve(String(p)) === resolvedProjectDir) { + realpathCalls++; + const err = new Error("EISDIR: illegal operation on a directory") as NodeJS.ErrnoException; + err.code = "EISDIR"; + err.errno = -21; + err.syscall = "lstat"; + err.path = `R:${path.sep}`; + throw err; + } + return originalRealpath(p); + }) as typeof fs.realpath); + + try { + const presence = await registerDaemonProjectPresence(projectDir, runtimeDir); + expect(typeof presence.close).toBe("function"); + expect(realpathCalls).toBe(1); + + const clientsDir = path.join(runtimeDir, "clients"); + const entries = await fs.readdir(clientsDir); + expect(entries).toHaveLength(1); + + await presence.close(); + } finally { + await fs.rm(projectDir, { recursive: true, force: true }); + } + }); +});