Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions electron/ipc-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const updater = require("./updater.js");
const feedback = require("./feedback.js");
const { discoverSourceControl } = require("./sourceControl.js");
const { applyZoom } = require("./zoom.js");
const { whenPathReady } = require("./pathEnv.js");

/** Version stamped in the embedded package.json; "dev" outside a build. */
function appVersion() {
Expand Down Expand Up @@ -187,6 +188,9 @@ module.exports = {
});
},
"providers:list": async (ctx) => {
// First launch: `which` each CLI only once the login-shell PATH is in (#1475).
const pathWait = whenPathReady({ ifUncached: true });
if (pathWait) await pathWait;
return services.listProvidersForApi(ctx.store);
},
"sourceControl:discover": async (_ctx, input) => {
Expand Down
13 changes: 2 additions & 11 deletions electron/ipc-git.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const fs = require("node:fs");
const services = require("./services.js");
const {
setupWorktree,
listBranches,
listBranchesAsync,
diff,
commit,
revertFile,
Expand Down Expand Up @@ -54,15 +54,6 @@ const { runRetention, resolveThreadRoot } = require("./ipc-shared.js");

/** IPC_HANDLERS rows for git:*, issues:*, mergeQueue:*, vibeKanban:*; ipc.js spreads them in. */
module.exports = {
"git:listBranches": async (ctx, input) => {
const projectId =
input && typeof input === "object" ? input.projectId : input;
const project = ctx.store.getProject(projectId);
if (!project || !project.path) {
throw new Error(`Unknown project: ${projectId}`);
}
return listBranches(project.path);
},
"git:status": async (ctx, projectId) => {
const project = ctx.store.getProject(projectId);
if (!project) {
Expand All @@ -76,7 +67,7 @@ module.exports = {
if (!project || !project.path) {
return { defaultBranch: "main", branches: [] };
}
return listBranches(project.path);
return listBranchesAsync(project.path);
},
"git:setupWorktree": async (ctx, input) => {
if (!ctx.worktreeBase) {
Expand Down
19 changes: 14 additions & 5 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const { startScheduler } = require("./automations.js");
const { startAutoDispatch } = require("./autodispatch.js");
const { startPostMergeScheduler } = require("./postmerge.js");
const { startMemoryConsolidateScheduler } = require("./memory-consolidate.js");
const { enrichProcessPath } = require("./pathEnv.js");
const { primeProcessPath, refreshLoginPath } = require("./pathEnv.js");
const {
parseServeWebArgs,
startWebServer,
Expand Down Expand Up @@ -108,8 +108,13 @@ startLoopLag();
const serveOpts = parseServeWebArgs(process.argv);

// GUI launches get a bare launchd PATH; rebuild the user's real PATH before
// any provider binary resolution (`which`) or agent spawn happens.
enrichProcessPath();
// any provider binary resolution (`which`) or agent spawn happens. The last
// launch's login-shell PATH applies here without a shell; the ~0.7 s rc
// capture re-runs async after first paint (startMemory below) and runner
// start awaits it (#1475).
primeProcessPath({
cacheFile: path.join(app.getPath("userData"), "login-path.json"),
});

const isDev = !app.isPackaged && !process.env.CODER_PROD;

Expand Down Expand Up @@ -544,8 +549,12 @@ app.whenReady().then(async () => {
});
},
// Never block or fail app start on memory supervision.
startMemory: () =>
memorySupervisor ? memorySupervisor.start() : undefined,
// Kicks the async login-shell PATH capture now that the window has
// painted; the supervisor probes kimi/cursor CLIs, so it waits for it.
startMemory: async () => {
await refreshLoginPath();
return memorySupervisor ? memorySupervisor.start() : undefined;
},
onMemoryError: (err) => {
console.warn(
"memory-server: supervisor start error; continuing without memory:",
Expand Down
178 changes: 165 additions & 13 deletions electron/pathEnv.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

const fs = require("node:fs");
const os = require("node:os");
const { execFileSync } = require("node:child_process");
const { execFile, execFileSync } = require("node:child_process");

/**
* GUI apps on macOS launch with a bare launchd PATH (/usr/bin:/bin:...), so
Expand Down Expand Up @@ -110,24 +110,50 @@ function captureLoginPath(env, execFn = execFileSync, platform = process.platfor
// the macOS/launchd kind; spawning SHELL || /bin/zsh would only fail
// closed after SHELL_TIMEOUT_MS. Honest no-op, not a pretend try.
if (platform === "win32") return null;
const shell = env.SHELL || "/bin/zsh";
try {
const out = execFn(
shell,
["-lic", `printf '%s' '${MARK_BEGIN}'"$PATH"'${MARK_END}'`],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: SHELL_TIMEOUT_MS,
env,
},
);
return parseLoginPath(out);
return parseLoginPath(execFn(...loginShellCall(env)));
} catch {
return null;
}
}

/** @param {NodeJS.ProcessEnv} env */
function loginShellCall(env) {
return /** @type {const} */ ([
env.SHELL || "/bin/zsh",
["-lic", `printf '%s' '${MARK_BEGIN}'"$PATH"'${MARK_END}'`],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: SHELL_TIMEOUT_MS,
env,
},
]);
}

/**
* captureLoginPath without blocking the event loop (#1475): the user's rc
* files cost ~0.7 s on a typical oh-my-zsh/nvm setup. Never rejects.
*
* @param {NodeJS.ProcessEnv} env
* @param {typeof execFile} [execFn] - test hook, execFile-shaped
* @param {NodeJS.Platform} [platform]
* @returns {Promise<string[] | null>}
*/
function captureLoginPathAsync(env, execFn = execFile, platform = process.platform) {
if (platform === "win32") return Promise.resolve(null);
return new Promise((resolve) => {
try {
const [file, args, opts] = loginShellCall(env);
execFn(file, args, opts, (err, stdout) => {
resolve(err ? null : parseLoginPath(stdout));
});
} catch {
resolve(null);
}
});
}

/**
* Order-preserving dedupe of path entries across lists, earlier lists win.
* @param {...string[]} lists
Expand Down Expand Up @@ -179,9 +205,135 @@ function enrichProcessPath(opts = {}) {
return { source: login ? "login-shell" : "fallback", entries: merged.length };
}

/**
* Boot-time PATH state (#1475). primeProcessPath applies the login PATH the
* last launch captured, synchronously and without a shell; refreshLoginPath
* re-captures it in the background and rewrites the cache. Spawns that need
* the real PATH await whenPathReady() first, so a fresh install (no cache)
* or a changed rc file still gets the shell's PATH.
*
* @type {null | { env: NodeJS.ProcessEnv, home: string, platform: NodeJS.Platform, launch: string[], cached: boolean, cacheFile: string, execFn?: typeof execFile, existsFn?: (p: string) => boolean }}
*/
let primed = null;
/** @type {Promise<string[] | null> | null} */
let refreshing = null;
let refreshed = false;

/** @param {string} file @returns {string[] | null} */
function readCachedPath(file) {
try {
const entries = JSON.parse(fs.readFileSync(file, "utf8")).path;
if (!Array.isArray(entries)) return null;
const clean = entries.filter((e) => typeof e === "string" && e);
return clean.length > 0 ? clean : null;
} catch {
return null;
}
}

/**
* Same result shape as enrichProcessPath, from the cached login PATH instead
* of a blocking shell. Arms refreshLoginPath / whenPathReady.
*
* @param {object} opts
* @param {string} opts.cacheFile
* @param {NodeJS.ProcessEnv} [opts.env]
* @param {typeof execFile} [opts.execFn]
* @param {(p: string) => boolean} [opts.existsFn]
* @param {string} [opts.home]
* @param {NodeJS.Platform} [opts.platform]
* @returns {{ source: "cache" | "fallback" | "win32", entries: number }}
*/
function primeProcessPath(opts) {
const env = opts.env || process.env;
const platform = opts.platform || process.platform;
primed = null;
refreshing = null;
refreshed = false;
if (platform === "win32") {
const r = enrichProcessPath({ env, platform });
return { source: "win32", entries: r.entries };
}
const home = opts.home || os.homedir();
const launch = String(env.PATH || "").split(":").filter(Boolean);
const cached = readCachedPath(opts.cacheFile);
const merged = mergePathEntries(
cached || [],
launch,
fallbackBinDirs(home, opts.existsFn),
);
env.PATH = merged.join(":");
primed = {
env,
home,
platform,
launch,
cached: Boolean(cached),
cacheFile: opts.cacheFile,
execFn: opts.execFn,
existsFn: opts.existsFn,
};
return { source: cached ? "cache" : "fallback", entries: merged.length };
}

/**
* Start (once) the background login-shell capture. On success PATH becomes
* login + launch PATH + fallback dirs, exactly what enrichProcessPath built,
* and the cache is rewritten. On failure the primed PATH stays. Never rejects.
*
* @returns {Promise<string[] | null>}
*/
function refreshLoginPath() {
if (!primed) return Promise.resolve(null);
if (!refreshing) {
const p = primed;
refreshing = captureLoginPathAsync(p.env, p.execFn, p.platform)
.then((login) => {
if (login) {
p.env.PATH = mergePathEntries(
login,
p.launch,
fallbackBinDirs(p.home, p.existsFn),
).join(":");
try {
fs.writeFileSync(p.cacheFile, JSON.stringify({ path: login }));
} catch {
// next launch just re-captures
}
}
return login;
})
.catch(() => null)
.finally(() => {
refreshed = true;
});
}
return refreshing;
}

/**
* The in-flight (or not yet started) capture to await before a provider
* spawn, or null once it has settled or when nothing was primed (tests,
* win32), so callers skip the await and keep their synchronous prefix.
* ifUncached: only wait on a first run (no cached PATH), for read-only
* probes that should not stall a normal boot.
*
* @param {{ ifUncached?: boolean }} [opts]
* @returns {Promise<unknown> | null}
*/
function whenPathReady(opts = {}) {
if (!primed || refreshed) return null;
if (opts.ifUncached && primed.cached) return null;
return refreshLoginPath();
}

module.exports = {
enrichProcessPath,
primeProcessPath,
refreshLoginPath,
whenPathReady,
captureLoginPath,
captureLoginPathAsync,
parseLoginPath,
fallbackBinDirs,
newestNvmBin,
Expand Down
62 changes: 38 additions & 24 deletions electron/projectIcon.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { attachScm } = require("./scm.js");

const ICON_EXTENSIONS = [
Expand Down Expand Up @@ -111,33 +110,45 @@ function clearIconCache() {
commonDirCache.clear();
}

/**
* The repo's git common dir, read off disk the way git's own discovery does:
* nearest `.git` upward, follow a `gitdir:` file (worktree, submodule), then
* its `commondir`. No git spawn: `projects:list` calls this per project on
* the boot critical path, where execFileSync cost ~10 ms each (#1475).
*
* ponytail: ignores GIT_DIR / GIT_COMMON_DIR and bare repos; both fall back
* to cwd, which only means a worktree project misses the main checkout's
* icon. Shell out to `git rev-parse` (async) if that ever matters.
*
* @param {string} cwd
*/
function defaultGitCommonDir(cwd) {
try {
const out = execFileSync(
"git",
["rev-parse", "--path-format=absolute", "--git-common-dir"],
{
cwd,
encoding: "utf8",
timeout: 2000,
stdio: ["ignore", "pipe", "pipe"],
},
).trim();
if (out) return path.resolve(out);
} catch {
const abs = path.resolve(cwd);
for (let dir = abs; ; dir = path.dirname(dir)) {
const dotGit = path.join(dir, ".git");
let gitDir = null;
try {
const out = execFileSync("git", ["rev-parse", "--git-common-dir"], {
cwd,
encoding: "utf8",
timeout: 2000,
stdio: ["ignore", "pipe", "pipe"],
}).trim();
if (out) return path.resolve(cwd, out);
if (fs.statSync(dotGit).isDirectory()) {
gitDir = dotGit;
} else {
const m = /^gitdir:\s*(.+?)\s*$/m.exec(fs.readFileSync(dotGit, "utf8"));
if (!m) return abs;
gitDir = path.resolve(dir, m[1]);
}
} catch {
// not a git dir, or git missing
// no .git here; keep walking up
}
if (gitDir) {
try {
const common = fs.readFileSync(path.join(gitDir, "commondir"), "utf8").trim();
if (common) return path.resolve(gitDir, common);
} catch {
// a main checkout's .git has no commondir: it is the common dir
}
return gitDir;
}
if (path.dirname(dir) === dir) return abs;
}
return path.resolve(cwd);
}

function gitCommonDir(cwd) {
Expand Down Expand Up @@ -409,7 +420,10 @@ function resolveIconPath(root, iconPath) {
function cacheKey(root, iconPath) {
const override =
typeof iconPath === "string" && iconPath.trim() ? iconPath.trim() : "";
return `${gitCommonDir(root)}\0${override}`;
// Root first: two checkouts of one repo scan different roots, so they
// must not share an entry. The common dir keeps a re-pointed .git from
// serving the old icon.
return `${path.resolve(root)}\0${gitCommonDir(root)}\0${override}`;
}

function overrideFile(root, iconPath) {
Expand Down
Loading
Loading