mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51d68b51ce | ||
|
|
6b1cf802d0 | ||
|
|
76957e3597 | ||
|
|
64f6214690 |
No files matched your search
@@ -832,93 +832,98 @@ function getWindowsKnownCliPathDirs(env = process.env) {
|
||||
return dirs.filter((dir) => existsSync(dir));
|
||||
}
|
||||
|
||||
// The hives a brand-new shell reads its PATH from, in Windows' own order:
|
||||
// user scope first, then machine scope.
|
||||
// Windows appends the user PATH to the machine PATH. Keep that ordering even
|
||||
// though both registry queries run concurrently.
|
||||
const WINDOWS_PATH_HIVES = [
|
||||
"HKCU\\Environment",
|
||||
"HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
|
||||
"HKCU\\Environment",
|
||||
];
|
||||
|
||||
function collectWindowsRegistryPath(stdout, env, parts) {
|
||||
const raw = parseRegQueryPath(stdout);
|
||||
if (raw) parts.push(expandWindowsEnvRefs(raw, env));
|
||||
}
|
||||
|
||||
async function readWindowsRegistryPath({ exec = execFileAsync, env = process.env } = {}) {
|
||||
const parts = [];
|
||||
for (const hive of WINDOWS_PATH_HIVES) {
|
||||
try {
|
||||
const { stdout } = await exec("reg", ["query", hive, "/v", "Path"], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
});
|
||||
collectWindowsRegistryPath(stdout, env, parts);
|
||||
} catch {
|
||||
// Hive unreadable / value missing — skip and rely on other sources.
|
||||
}
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous twin of readWindowsRegistryPath() for callers that cannot await,
|
||||
* such as local terminal startup, which spawns the PTY inline.
|
||||
*/
|
||||
function readWindowsRegistryPathSync({ exec = execFileSync, env = process.env } = {}) {
|
||||
const parts = [];
|
||||
for (const hive of WINDOWS_PATH_HIVES) {
|
||||
try {
|
||||
const stdout = exec("reg", ["query", hive, "/v", "Path"], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
collectWindowsRegistryPath(stdout, env, parts);
|
||||
} catch {
|
||||
// Hive unreadable / value missing — skip and rely on other sources.
|
||||
}
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows only: put the live registry PATH ahead of an already-captured PATH.
|
||||
*
|
||||
* Long-lived processes keep the PATH they were launched with, so anything the
|
||||
* user installs or upgrades afterwards is invisible to them — including tools
|
||||
* whose installer rewrites a versioned user-PATH entry, where the stale entry
|
||||
* points at a directory the upgrade removed. Merging the registry value (what
|
||||
* a fresh shell inherits) ahead of the captured one resolves those CLIs
|
||||
* without a restart. Returns `basePath` unchanged off Windows, or when the
|
||||
* registry cannot be read.
|
||||
*/
|
||||
function resolveWindowsLivePath({
|
||||
basePath,
|
||||
exec,
|
||||
async function readWindowsRegistryPaths({
|
||||
exec = execFileAsync,
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
knownCliDirs = getWindowsKnownCliPathDirs,
|
||||
timeoutMs = 1000,
|
||||
} = {}) {
|
||||
if (platform !== "win32") return basePath;
|
||||
|
||||
let registryPath = "";
|
||||
try {
|
||||
registryPath = readWindowsRegistryPathSync({ exec, env });
|
||||
} catch {
|
||||
registryPath = "";
|
||||
}
|
||||
|
||||
let knownDirs = "";
|
||||
try {
|
||||
knownDirs = (knownCliDirs(env) || []).join(";");
|
||||
} catch {
|
||||
knownDirs = "";
|
||||
}
|
||||
|
||||
if (!registryPath && !knownDirs) return basePath;
|
||||
return mergeWindowsPath(registryPath, knownDirs, basePath || "");
|
||||
return Promise.all(WINDOWS_PATH_HIVES.map(async (hive) => {
|
||||
let timer;
|
||||
try {
|
||||
// The JS deadline also bounds an unresponsive process callback. The
|
||||
// child-process timeout remains responsible for terminating reg.exe.
|
||||
const result = await Promise.race([
|
||||
exec("reg", ["query", hive, "/v", "Path"], {
|
||||
encoding: "utf8",
|
||||
timeout: timeoutMs,
|
||||
windowsHide: true,
|
||||
}),
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
return result ? expandWindowsEnvRefs(parseRegQueryPath(result.stdout), env) : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async function readWindowsRegistryPath(options) {
|
||||
return mergeWindowsPath(...await readWindowsRegistryPaths(options));
|
||||
}
|
||||
|
||||
// Share a short-lived registry snapshot across simultaneous local terminals.
|
||||
// Cache raw values so each terminal expands references using its own env.
|
||||
function createWindowsLivePathResolver({
|
||||
exec = execFileAsync,
|
||||
now = Date.now,
|
||||
ttlMs = 5000,
|
||||
timeoutMs = 1000,
|
||||
} = {}) {
|
||||
const cachedPaths = [null, null];
|
||||
let refreshedAt = -Infinity;
|
||||
let pending = null;
|
||||
|
||||
return async function resolveWindowsLivePath({
|
||||
basePath,
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
knownCliDirs = getWindowsKnownCliPathDirs,
|
||||
} = {}) {
|
||||
if (platform !== "win32") return basePath;
|
||||
if (!pending && now() - refreshedAt >= ttlMs) {
|
||||
pending = readWindowsRegistryPaths({ exec, env: {}, timeoutMs })
|
||||
.then((paths) => {
|
||||
// A failed hive retains its last successful value; one unavailable
|
||||
// hive must not discard the other half of a cached PATH.
|
||||
paths.forEach((value, index) => {
|
||||
if (value !== null) cachedPaths[index] = value;
|
||||
});
|
||||
refreshedAt = now();
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
}
|
||||
if (pending) await pending;
|
||||
|
||||
let knownDirs = "";
|
||||
try {
|
||||
knownDirs = (knownCliDirs(env) || []).join(";");
|
||||
} catch { /* Best-effort fallback only. */ }
|
||||
|
||||
const registryPath = expandWindowsEnvRefs(mergeWindowsPath(...cachedPaths), env);
|
||||
if (!registryPath && !knownDirs) return basePath;
|
||||
// Registry order reflects a fresh shell; inherited entries remain a
|
||||
// fallback, and guessed package-manager bins must never take precedence.
|
||||
// With no machine snapshot, retain inherited system precedence rather
|
||||
// than accidentally promoting user directories above system binaries.
|
||||
return cachedPaths[0] === null
|
||||
? mergeWindowsPath(basePath || "", registryPath, knownDirs)
|
||||
: mergeWindowsPath(registryPath, basePath || "", knownDirs);
|
||||
};
|
||||
}
|
||||
|
||||
const resolveWindowsLivePath = createWindowsLivePathResolver();
|
||||
|
||||
async function getShellEnv() {
|
||||
if (_cachedShellEnv) return _cachedShellEnv;
|
||||
if (_shellEnvPromise) return _shellEnvPromise;
|
||||
@@ -946,7 +951,7 @@ async function getShellEnv() {
|
||||
const knownDirs = getWindowsKnownCliPathDirs().join(path.delimiter);
|
||||
const nextEnv = {
|
||||
...process.env,
|
||||
PATH: mergeWindowsPath(registryPath, knownDirs, process.env.PATH || ""),
|
||||
PATH: mergeWindowsPath(registryPath, process.env[getPathEnvKey(process.env, "win32")] || "", knownDirs),
|
||||
};
|
||||
if (generation === _shellEnvGeneration) {
|
||||
_cachedShellEnv = nextEnv;
|
||||
@@ -1062,7 +1067,7 @@ module.exports = {
|
||||
expandWindowsEnvRefs,
|
||||
mergeWindowsPath,
|
||||
readWindowsRegistryPath,
|
||||
readWindowsRegistryPathSync,
|
||||
createWindowsLivePathResolver,
|
||||
resolveWindowsLivePath,
|
||||
getShellEnv,
|
||||
invalidateShellEnvCache,
|
||||
|
||||
@@ -21,8 +21,7 @@ const {
|
||||
expandWindowsEnvRefs,
|
||||
mergeWindowsPath,
|
||||
readWindowsRegistryPath,
|
||||
readWindowsRegistryPathSync,
|
||||
resolveWindowsLivePath,
|
||||
createWindowsLivePathResolver,
|
||||
trackSessionIdlePrompt,
|
||||
} = require("./shellUtils.cjs");
|
||||
const fs = require("node:fs");
|
||||
@@ -591,7 +590,7 @@ test("readWindowsRegistryPath merges HKCU and HKLM and expands refs", async () =
|
||||
return { stdout: " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n" };
|
||||
};
|
||||
const out = await readWindowsRegistryPath({ exec, env: { APPDATA: "C:\\Roaming" } });
|
||||
assert.equal(out, "C:\\Roaming\\npm;C:\\Windows\\System32");
|
||||
assert.equal(out, "C:\\Windows\\System32;C:\\Roaming\\npm");
|
||||
});
|
||||
|
||||
test("readWindowsRegistryPath tolerates a failing hive query", async () => {
|
||||
@@ -603,79 +602,6 @@ test("readWindowsRegistryPath tolerates a failing hive query", async () => {
|
||||
assert.equal(out, "C:\\tools");
|
||||
});
|
||||
|
||||
test("readWindowsRegistryPathSync mirrors the async reader for non-awaiting callers", () => {
|
||||
const exec = (cmd, args) => {
|
||||
assert.equal(cmd, "reg");
|
||||
if (args[1] === "HKCU\\Environment") {
|
||||
return " Path REG_EXPAND_SZ %APPDATA%\\npm\r\n";
|
||||
}
|
||||
return " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n";
|
||||
};
|
||||
const out = readWindowsRegistryPathSync({ exec, env: { APPDATA: "C:\\Roaming" } });
|
||||
assert.equal(out, "C:\\Roaming\\npm;C:\\Windows\\System32");
|
||||
});
|
||||
|
||||
test("readWindowsRegistryPathSync tolerates a failing hive query", () => {
|
||||
const exec = (cmd, args) => {
|
||||
if (args[1] === "HKCU\\Environment") throw new Error("ERROR: cannot read");
|
||||
return " Path REG_SZ C:\\tools\r\n";
|
||||
};
|
||||
assert.equal(readWindowsRegistryPathSync({ exec, env: {} }), "C:\\tools");
|
||||
});
|
||||
|
||||
test("resolveWindowsLivePath front-loads a freshly installed CLI over the stale PATH", () => {
|
||||
// The reported regression: a long-running app captured a PATH whose herdr
|
||||
// entry still points at the previous release directory, which the upgrade
|
||||
// removed, so the stale entry no longer resolves anything.
|
||||
const stalePath = "C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.8.2-x86_64;C:\\Windows\\System32";
|
||||
const exec = (cmd, args) => {
|
||||
if (args[1] === "HKCU\\Environment") {
|
||||
return " Path REG_EXPAND_SZ C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64\r\n";
|
||||
}
|
||||
return " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n";
|
||||
};
|
||||
|
||||
const out = resolveWindowsLivePath({
|
||||
basePath: stalePath,
|
||||
exec,
|
||||
env: {},
|
||||
platform: "win32",
|
||||
knownCliDirs: () => [],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
out,
|
||||
"C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64;C:\\Windows\\System32;"
|
||||
+ "C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.8.2-x86_64",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveWindowsLivePath keeps the captured PATH off Windows", () => {
|
||||
const exec = () => {
|
||||
throw new Error("the registry must not be queried off Windows");
|
||||
};
|
||||
const out = resolveWindowsLivePath({
|
||||
basePath: "/usr/local/bin:/usr/bin",
|
||||
exec,
|
||||
platform: "darwin",
|
||||
});
|
||||
assert.equal(out, "/usr/local/bin:/usr/bin");
|
||||
});
|
||||
|
||||
test("resolveWindowsLivePath falls back to the captured PATH when the registry is unreadable", () => {
|
||||
const exec = () => {
|
||||
throw new Error("access denied");
|
||||
};
|
||||
const out = resolveWindowsLivePath({
|
||||
basePath: "C:\\Windows\\System32",
|
||||
exec,
|
||||
env: {},
|
||||
platform: "win32",
|
||||
knownCliDirs: () => [],
|
||||
});
|
||||
assert.equal(out, "C:\\Windows\\System32");
|
||||
});
|
||||
|
||||
test("tracks PowerShell idle prompt after SSH output", () => {
|
||||
const session = {};
|
||||
|
||||
@@ -820,3 +746,94 @@ function withExecPath(fakePath, fn) {
|
||||
Object.defineProperty(process, "execPath", { value: original, configurable: true, writable: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("Windows live PATH refresh is shared, asynchronous, bounded, and cached", async () => {
|
||||
let calls = 0;
|
||||
let tick = 0;
|
||||
const resolve = createWindowsLivePathResolver({
|
||||
exec: () => { calls += 1; return new Promise(() => {}); },
|
||||
timeoutMs: 20,
|
||||
now: () => tick,
|
||||
ttlMs: 50,
|
||||
});
|
||||
const options = { basePath: "C:\\Windows\\System32", platform: "win32", env: {}, knownCliDirs: () => [] };
|
||||
let eventLoopRan = false;
|
||||
setTimeout(() => { eventLoopRan = true; }, 0);
|
||||
const first = resolve(options);
|
||||
const second = resolve(options);
|
||||
assert.equal(calls, 2, "concurrent terminals share one query per hive");
|
||||
assert.deepEqual(await Promise.all([first, second]), [options.basePath, options.basePath]);
|
||||
assert.equal(eventLoopRan, true, "registry reads must not block the main event loop");
|
||||
await resolve(options);
|
||||
assert.equal(calls, 2, "even failed queries are briefly cached");
|
||||
tick = 51;
|
||||
await resolve(options);
|
||||
assert.equal(calls, 4, "expired snapshots refresh");
|
||||
});
|
||||
|
||||
test("Windows live PATH keeps machine before user and guesses after inherited entries", async () => {
|
||||
const resolve = createWindowsLivePathResolver({
|
||||
exec: async (_cmd, args, options) => {
|
||||
assert.equal(options.windowsHide, true);
|
||||
assert.equal(options.timeout, 1000);
|
||||
return { stdout: args[1].startsWith("HKLM")
|
||||
? " Path REG_SZ C:\\Windows\\System32;C:\\shared\\\r\n"
|
||||
: " Path REG_EXPAND_SZ %APPDATA%\\npm;c:\\SHARED;C:\\new-release\r\n" };
|
||||
},
|
||||
});
|
||||
const result = await resolve({
|
||||
basePath: "C:\\old-release;C:\\Windows\\System32;C:\\launcher",
|
||||
env: { APPDATA: "C:\\Roaming" },
|
||||
platform: "win32",
|
||||
knownCliDirs: () => ["C:\\guess", "C:\\launcher"],
|
||||
});
|
||||
assert.equal(result, "C:\\Windows\\System32;C:\\shared\\;C:\\Roaming\\npm;C:\\new-release;C:\\old-release;C:\\launcher;C:\\guess");
|
||||
});
|
||||
|
||||
test("Windows live PATH picks up upgraded directories after cache expiry", async () => {
|
||||
let tick = 0;
|
||||
let release = "v1";
|
||||
let unavailable = false;
|
||||
const resolve = createWindowsLivePathResolver({
|
||||
now: () => tick,
|
||||
ttlMs: 10,
|
||||
exec: async () => {
|
||||
if (unavailable) throw new Error("registry unavailable");
|
||||
return { stdout: ` Path REG_SZ C:\\${release}\r\n` };
|
||||
},
|
||||
});
|
||||
const options = { platform: "win32", basePath: "C:\\old", env: {}, knownCliDirs: () => [] };
|
||||
assert.equal(await resolve(options), "C:\\v1;C:\\old");
|
||||
release = "v2";
|
||||
tick = 11;
|
||||
assert.equal(await resolve(options), "C:\\v2;C:\\old");
|
||||
unavailable = true;
|
||||
tick = 22;
|
||||
assert.equal(await resolve(options), "C:\\v2;C:\\old", "query failure retains the last successful snapshot");
|
||||
});
|
||||
|
||||
test("cached Windows registry references expand per terminal environment", async () => {
|
||||
const resolve = createWindowsLivePathResolver({
|
||||
exec: async () => ({ stdout: " Path REG_EXPAND_SZ %APPDATA%\\npm\r\n" }),
|
||||
});
|
||||
const options = { platform: "win32", basePath: "", knownCliDirs: () => [] };
|
||||
assert.equal(await resolve({ ...options, env: { APPDATA: "C:\\first" } }), "C:\\first\\npm");
|
||||
assert.equal(await resolve({ ...options, env: { APPDATA: "C:\\second" } }), "C:\\second\\npm");
|
||||
});
|
||||
|
||||
test("Windows live PATH resolver does not query the registry off Windows", async () => {
|
||||
const resolve = createWindowsLivePathResolver({ exec: () => { throw new Error("must not query"); } });
|
||||
assert.equal(await resolve({ basePath: "/usr/bin:/bin", platform: "linux" }), "/usr/bin:/bin");
|
||||
});
|
||||
|
||||
test("a failed machine hive does not promote user directories ahead of inherited system paths", async () => {
|
||||
const resolve = createWindowsLivePathResolver({
|
||||
exec: async (_cmd, args) => {
|
||||
if (args[1].startsWith("HKLM")) throw new Error("unavailable");
|
||||
return { stdout: " Path REG_SZ C:\\user-tools\r\n" };
|
||||
},
|
||||
});
|
||||
assert.equal(await resolve({
|
||||
platform: "win32", basePath: "C:\\Windows\\System32", env: {}, knownCliDirs: () => [],
|
||||
}), "C:\\Windows\\System32;C:\\user-tools");
|
||||
});
|
||||
@@ -936,7 +936,7 @@ const applyLocaleDefaults = (env) => {
|
||||
* Host-level `environmentVariables` overrides are applied on top, and an
|
||||
* explicit PATH among them always wins — widening it would defeat the override.
|
||||
*/
|
||||
function buildLocalSessionEnv(
|
||||
async function buildLocalSessionEnv(
|
||||
payload,
|
||||
{ baseEnv = process.env, platform = process.platform, refreshWindowsPath } = {},
|
||||
) {
|
||||
@@ -953,7 +953,7 @@ function buildLocalSessionEnv(
|
||||
const pathKey = overridePathKey || getPathEnvKey(env, platform);
|
||||
|
||||
if (!overridePathKey) {
|
||||
const livePath = (refreshWindowsPath || resolveWindowsLivePath)({
|
||||
const livePath = await (refreshWindowsPath || resolveWindowsLivePath)({
|
||||
basePath: env[pathKey],
|
||||
env,
|
||||
platform,
|
||||
@@ -972,7 +972,7 @@ function buildLocalSessionEnv(
|
||||
/**
|
||||
* Start a local terminal session
|
||||
*/
|
||||
function startLocalSession(event, payload) {
|
||||
async function startLocalSession(event, payload) {
|
||||
const sessionId = payload?.sessionId || randomUUID();
|
||||
const defaultShell = getDefaultLocalShell();
|
||||
// payload.shell may be a discovered shell ID (e.g., "wsl-ubuntu") — resolve it
|
||||
@@ -995,12 +995,29 @@ function startLocalSession(event, payload) {
|
||||
requestedCwd,
|
||||
);
|
||||
const shellKind = detectShellKind(shell);
|
||||
const { registerPendingBootAbort, clearPendingBootAbort } = require("./sessionBootEpoch.cjs");
|
||||
const pendingBootAbort = registerPendingBootAbort(sessionId, payload?.bootEpoch);
|
||||
let sessionEnv;
|
||||
try {
|
||||
sessionEnv = await buildLocalSessionEnv(payload);
|
||||
} finally {
|
||||
clearPendingBootAbort(sessionId, pendingBootAbort);
|
||||
}
|
||||
if (pendingBootAbort.signal.aborted) {
|
||||
const supersededError = new Error("Local session closed or superseded before startup");
|
||||
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
|
||||
throw supersededError;
|
||||
}
|
||||
const env = applyLocaleDefaults({
|
||||
...buildLocalSessionEnv(payload),
|
||||
...sessionEnv,
|
||||
TERM: "xterm-256color",
|
||||
COLORTERM: "truecolor",
|
||||
});
|
||||
|
||||
if (event.sender.isDestroyed?.()) {
|
||||
throw new Error("Local terminal window closed before startup");
|
||||
}
|
||||
|
||||
// Determine the starting directory
|
||||
// Default to home directory if not specified or if specified path is invalid
|
||||
const defaultCwd = os.homedir();
|
||||
|
||||
@@ -37,8 +37,8 @@ const STALE_PATH =
|
||||
const LIVE_PATH =
|
||||
"C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64-pc-windows-msvc;C:\\Windows\\System32";
|
||||
|
||||
test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", () => {
|
||||
const env = bridge.buildLocalSessionEnv(
|
||||
test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", async () => {
|
||||
const env = await bridge.buildLocalSessionEnv(
|
||||
{ env: { NETCATTY_MARKER: "1" } },
|
||||
{
|
||||
baseEnv: { PATH: "/usr/bin:/bin", HOME: "/home/me" },
|
||||
@@ -51,14 +51,14 @@ test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", () =>
|
||||
assert.equal(env.NETCATTY_MARKER, "1");
|
||||
});
|
||||
|
||||
test("buildLocalSessionEnv refreshes PATH from the registry on Windows", () => {
|
||||
test("buildLocalSessionEnv refreshes PATH from the registry on Windows", async () => {
|
||||
const calls = [];
|
||||
const env = bridge.buildLocalSessionEnv(
|
||||
const env = await bridge.buildLocalSessionEnv(
|
||||
{},
|
||||
{
|
||||
baseEnv: { Path: STALE_PATH, SystemRoot: "C:\\Windows" },
|
||||
platform: "win32",
|
||||
refreshWindowsPath: (options) => {
|
||||
refreshWindowsPath: async (options) => {
|
||||
calls.push(options);
|
||||
return LIVE_PATH;
|
||||
},
|
||||
@@ -73,8 +73,8 @@ test("buildLocalSessionEnv refreshes PATH from the registry on Windows", () => {
|
||||
assert.equal(env.SystemRoot, "C:\\Windows");
|
||||
});
|
||||
|
||||
test("buildLocalSessionEnv collapses a duplicate PATH sitting next to Path", () => {
|
||||
const env = bridge.buildLocalSessionEnv(
|
||||
test("buildLocalSessionEnv collapses a duplicate PATH sitting next to Path", async () => {
|
||||
const env = await bridge.buildLocalSessionEnv(
|
||||
{},
|
||||
{
|
||||
baseEnv: { Path: STALE_PATH, PATH: STALE_PATH },
|
||||
@@ -91,9 +91,9 @@ test("buildLocalSessionEnv collapses a duplicate PATH sitting next to Path", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("buildLocalSessionEnv never widens an explicit PATH override", () => {
|
||||
test("buildLocalSessionEnv never widens an explicit PATH override", async () => {
|
||||
let refreshed = false;
|
||||
const env = bridge.buildLocalSessionEnv(
|
||||
const env = await bridge.buildLocalSessionEnv(
|
||||
{ env: { PATH: "C:\\pinned\\bin" } },
|
||||
{
|
||||
baseEnv: { Path: STALE_PATH },
|
||||
@@ -109,3 +109,18 @@ test("buildLocalSessionEnv never widens an explicit PATH override", () => {
|
||||
assert.equal(env.PATH, "C:\\pinned\\bin");
|
||||
assert.equal(env.Path, undefined);
|
||||
});
|
||||
|
||||
for (const pathKey of ["Path", "PATH", "path"]) {
|
||||
test(`an explicit empty ${pathKey} override stays empty without a registry query`, async () => {
|
||||
const env = await bridge.buildLocalSessionEnv(
|
||||
{ env: { [pathKey]: "" } },
|
||||
{
|
||||
baseEnv: { Path: STALE_PATH, PATH: STALE_PATH },
|
||||
platform: "win32",
|
||||
refreshWindowsPath: () => { throw new Error("must not refresh an explicit override"); },
|
||||
},
|
||||
);
|
||||
assert.equal(env[pathKey], "");
|
||||
assert.deepEqual(Object.keys(env).filter((key) => key.toLowerCase() === "path"), [pathKey]);
|
||||
});
|
||||
}
|
||||
@@ -160,7 +160,7 @@ test("WSL default directory stays outside the Linux command and option values",
|
||||
}
|
||||
});
|
||||
|
||||
test("WSL local sessions pass the home directory option to the spawned process", () => {
|
||||
test("WSL local sessions pass the home directory option to the spawned process", async () => {
|
||||
const spawns = [];
|
||||
const bridge = loadBridgeWithFakes(spawns, []);
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
@@ -172,10 +172,10 @@ test("WSL local sessions pass the home directory option to the spawned process",
|
||||
});
|
||||
const shellArgs = ["-d", "Ubuntu", "--", "zsh", "-l"];
|
||||
const payload = { shell: "C:\\Windows\\System32\\wsl.exe", shellArgs };
|
||||
bridge.startLocalSession({ sender: { id: 7 } }, { ...payload, sessionId: "wsl-home" });
|
||||
await bridge.startLocalSession({ sender: { id: 7 } }, { ...payload, sessionId: "wsl-home" });
|
||||
assert.deepEqual(spawns[0].spawnArgs[1], ["-d", "Ubuntu", "--cd", "~", "--", "zsh", "-l"]);
|
||||
assert.deepEqual(shellArgs, ["-d", "Ubuntu", "--", "zsh", "-l"]);
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ ...payload, sessionId: "wsl-explicit-cwd", cwd: process.cwd() },
|
||||
);
|
||||
@@ -186,7 +186,7 @@ test("WSL local sessions pass the home directory option to the spawned process",
|
||||
}
|
||||
});
|
||||
|
||||
test("Windows local terminals enable the bundled ConPTY implementation required for clear", () => {
|
||||
test("Windows local terminals enable the bundled ConPTY implementation required for clear", async () => {
|
||||
const spawns = [];
|
||||
const sentries = [];
|
||||
const sessions = new Map();
|
||||
@@ -203,7 +203,7 @@ test("Windows local terminals enable the bundled ConPTY implementation required
|
||||
},
|
||||
},
|
||||
});
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "windows-clear", shell: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" },
|
||||
);
|
||||
@@ -215,7 +215,7 @@ test("Windows local terminals enable the bundled ConPTY implementation required
|
||||
}
|
||||
});
|
||||
|
||||
test("local terminal buffers incoming flood while renderer flow is paused", () => {
|
||||
test("local terminal buffers incoming flood while renderer flow is paused", async () => {
|
||||
const spawns = [];
|
||||
const sentries = [];
|
||||
const sent = [];
|
||||
@@ -233,7 +233,7 @@ test("local terminal buffers incoming flood while renderer flow is paused", () =
|
||||
},
|
||||
});
|
||||
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "local-flood", shell: "/bin/sh", cols: 80, rows: 24 },
|
||||
);
|
||||
@@ -258,7 +258,7 @@ test("local terminal buffers incoming flood while renderer flow is paused", () =
|
||||
assert.equal(sentries[0].consumeCalls.length, 2);
|
||||
});
|
||||
|
||||
test("local terminal keeps source paused while paced backlog absorbs fresh flood", () => {
|
||||
test("local terminal keeps source paused while paced backlog absorbs fresh flood", async () => {
|
||||
const spawns = [];
|
||||
const sentries = [];
|
||||
const sent = [];
|
||||
@@ -276,7 +276,7 @@ test("local terminal keeps source paused while paced backlog absorbs fresh flood
|
||||
},
|
||||
});
|
||||
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "local-flood-paced-fresh", shell: "/bin/sh", cols: 80, rows: 24 },
|
||||
);
|
||||
@@ -307,7 +307,7 @@ test("local terminal keeps source paused while paced backlog absorbs fresh flood
|
||||
assert.ok(session.flowState.bufferedBytes >= FLOW_HIGH_WATER_MARK);
|
||||
});
|
||||
|
||||
test("closing a local terminal discards buffered output instead of flushing it", () => {
|
||||
test("closing a local terminal discards buffered output instead of flushing it", async () => {
|
||||
const spawns = [];
|
||||
const sentries = [];
|
||||
const sent = [];
|
||||
@@ -325,7 +325,7 @@ test("closing a local terminal discards buffered output instead of flushing it",
|
||||
},
|
||||
});
|
||||
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "local-flood-close", shell: "/bin/sh", cols: 80, rows: 24 },
|
||||
);
|
||||
@@ -339,7 +339,7 @@ test("closing a local terminal discards buffered output instead of flushing it",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("app cleanup discards buffered output instead of flushing it", () => {
|
||||
test("app cleanup discards buffered output instead of flushing it", async () => {
|
||||
const spawns = [];
|
||||
const sentries = [];
|
||||
const sent = [];
|
||||
@@ -357,7 +357,7 @@ test("app cleanup discards buffered output instead of flushing it", () => {
|
||||
},
|
||||
});
|
||||
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "local-flood-cleanup", shell: "/bin/sh", cols: 80, rows: 24 },
|
||||
);
|
||||
@@ -387,7 +387,7 @@ test("local terminal exit waits for paced buffered output drain", async () => {
|
||||
},
|
||||
});
|
||||
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "local-flood-exit", shell: "/bin/sh", cols: 80, rows: 24 },
|
||||
);
|
||||
@@ -414,7 +414,7 @@ test("local terminal exit waits for paced buffered output drain", async () => {
|
||||
assert.equal(sessions.has("local-flood-exit"), false);
|
||||
});
|
||||
|
||||
test("local terminal exit completes while renderer flow is paused", () => {
|
||||
test("local terminal exit completes while renderer flow is paused", async () => {
|
||||
const spawns = [];
|
||||
const sentries = [];
|
||||
const sent = [];
|
||||
@@ -432,7 +432,7 @@ test("local terminal exit completes while renderer flow is paused", () => {
|
||||
},
|
||||
});
|
||||
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "local-paused-exit", shell: "/bin/sh", cols: 80, rows: 24 },
|
||||
);
|
||||
@@ -448,3 +448,181 @@ test("local terminal exit completes while renderer flow is paused", () => {
|
||||
assert.equal(sent.some((item) => item.channel === "netcatty:exit"), true);
|
||||
assert.equal(sessions.has("local-paused-exit"), false);
|
||||
});
|
||||
|
||||
test("a window closed while preparing the local environment does not spawn a PTY", async () => {
|
||||
const spawns = [];
|
||||
const bridge = loadBridgeWithFakes(spawns, []);
|
||||
let destroyed = false;
|
||||
const pending = bridge.startLocalSession(
|
||||
{ sender: { id: 7, isDestroyed: () => destroyed } },
|
||||
{ sessionId: "closed-before-spawn", shell: "/bin/sh" },
|
||||
);
|
||||
destroyed = true;
|
||||
await assert.rejects(pending, /window closed before startup/);
|
||||
assert.equal(spawns.length, 0);
|
||||
});
|
||||
|
||||
async function withPendingLocalPath(run) {
|
||||
const shellUtils = require("./ai/shellUtils.cjs");
|
||||
const originalResolve = shellUtils.resolveWindowsLivePath;
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const resolvers = [];
|
||||
const spawns = [];
|
||||
const sessions = new Map();
|
||||
const bridge = loadBridgeWithFakes(spawns, []);
|
||||
bridge.init({ sessions, electronModule: { webContents: { fromId: () => null } } });
|
||||
try {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
|
||||
shellUtils.resolveWindowsLivePath = () => new Promise((resolve) => resolvers.push(resolve));
|
||||
await run({ bridge, resolvers, spawns, sessions });
|
||||
} finally {
|
||||
shellUtils.resolveWindowsLivePath = originalResolve;
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
test("closing a local tab during PATH refresh prevents the pending PTY spawn", async () => {
|
||||
await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => {
|
||||
const event = { sender: { id: 7, isDestroyed: () => false } };
|
||||
const payload = { sessionId: "closed-pending-path", bootEpoch: 1, shell: "C:\\Windows\\cmd.exe" };
|
||||
const pending = bridge.startLocalSession(event, payload);
|
||||
const rejected = assert.rejects(pending, { code: "NETCATTY_BOOT_SUPERSEDED" });
|
||||
assert.equal(resolvers.length, 1);
|
||||
bridge.closeSession(event, payload);
|
||||
resolvers[0]("C:\\Windows\\System32");
|
||||
await rejected;
|
||||
assert.equal(spawns.length, 0);
|
||||
assert.equal(sessions.has(payload.sessionId), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("a stale close and old PATH completion cannot cancel a newer local boot", async () => {
|
||||
await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => {
|
||||
const event = { sender: { id: 7, isDestroyed: () => false } };
|
||||
const payload = { sessionId: "restarted-pending-path", shell: "C:\\Windows\\cmd.exe" };
|
||||
const older = bridge.startLocalSession(event, { ...payload, bootEpoch: 1 });
|
||||
const rejected = assert.rejects(older, { code: "NETCATTY_BOOT_SUPERSEDED" });
|
||||
const newer = bridge.startLocalSession(event, { ...payload, bootEpoch: 2 });
|
||||
assert.equal(resolvers.length, 2);
|
||||
assert.deepEqual(bridge.closeSession(event, { ...payload, bootEpoch: 1 }), {
|
||||
skipped: true, reason: "boot-epoch-mismatch",
|
||||
});
|
||||
resolvers[0]("C:\\old");
|
||||
await rejected;
|
||||
const { hasPendingBootAfter } = require("./sessionBootEpoch.cjs");
|
||||
assert.equal(hasPendingBootAfter(payload.sessionId, 1), true);
|
||||
resolvers[1]("C:\\new");
|
||||
await newer;
|
||||
assert.equal(spawns.length, 1);
|
||||
assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 2);
|
||||
bridge.closeSession(event, { ...payload, bootEpoch: 1 });
|
||||
assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 2);
|
||||
bridge.closeSession(event, { ...payload, bootEpoch: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
for (const closeChannel of ["netcatty:close", "netcatty:close:await"]) {
|
||||
test(`worker ${closeChannel} cancels a local start before PATH refresh can spawn it`, async () => {
|
||||
await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => {
|
||||
const { createTerminalWorkerRuntime } = require("../terminalWorker/runtime.cjs");
|
||||
const messages = [];
|
||||
let receiveMessage;
|
||||
const parentPort = {
|
||||
on: (_channel, listener) => { receiveMessage = listener; },
|
||||
postMessage: (message) => messages.push(message),
|
||||
};
|
||||
createTerminalWorkerRuntime({
|
||||
parentPort,
|
||||
registerBridges: (ipcMain) => bridge.registerHandlers(ipcMain),
|
||||
}).start();
|
||||
const payload = { sessionId: `worker-pending-${closeChannel}`, shell: "C:\\Windows\\cmd.exe" };
|
||||
const start = (bootEpoch) => receiveMessage({
|
||||
kind: "request", requestId: `start-${bootEpoch}`, channel: "netcatty:local:start",
|
||||
webContentsId: 7, payload: { ...payload, bootEpoch },
|
||||
});
|
||||
const close = (bootEpoch) => receiveMessage({
|
||||
kind: closeChannel.endsWith(":await") ? "request" : "send",
|
||||
requestId: `close-${bootEpoch}`, channel: closeChannel,
|
||||
webContentsId: 7, payload: { ...payload, bootEpoch },
|
||||
});
|
||||
const drainMessages = () => new Promise((resolve) => setImmediate(resolve));
|
||||
start(1);
|
||||
await drainMessages();
|
||||
assert.equal(resolvers.length, 1, "the real bridge is waiting for PATH inside the worker queue");
|
||||
close(1);
|
||||
resolvers[0]("C:\\Windows\\System32");
|
||||
await drainMessages();
|
||||
await drainMessages();
|
||||
assert.equal(spawns.length, 0, "a closed tab must not run shell startup scripts");
|
||||
assert.equal(sessions.has(payload.sessionId), false);
|
||||
assert.match(messages.find((message) => message.requestId === "start-1")?.error ?? "", /closed or superseded/);
|
||||
if (closeChannel.endsWith(":await")) {
|
||||
assert.equal(messages.find((message) => message.requestId === "close-1")?.result?.closed, false);
|
||||
}
|
||||
|
||||
start(2);
|
||||
await drainMessages();
|
||||
assert.equal(resolvers.length, 2);
|
||||
close(1); // A delayed old close must not abort the replacement's PATH wait.
|
||||
resolvers[1]("C:\\Windows\\System32");
|
||||
await drainMessages();
|
||||
await drainMessages();
|
||||
assert.equal(spawns.length, 1);
|
||||
assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 2);
|
||||
close(2);
|
||||
await drainMessages();
|
||||
await drainMessages();
|
||||
assert.equal(sessions.has(payload.sessionId), false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("worker replacements abort pending and skip superseded queued local starts", async () => {
|
||||
await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => {
|
||||
const { createTerminalWorkerRuntime } = require("../terminalWorker/runtime.cjs");
|
||||
const messages = [];
|
||||
let receiveMessage;
|
||||
createTerminalWorkerRuntime({
|
||||
parentPort: {
|
||||
on: (_channel, listener) => { receiveMessage = listener; },
|
||||
postMessage: (message) => messages.push(message),
|
||||
},
|
||||
registerBridges: (ipcMain) => bridge.registerHandlers(ipcMain),
|
||||
}).start();
|
||||
const payload = { sessionId: "worker-replaced-path", shell: "C:\\Windows\\cmd.exe" };
|
||||
const start = (bootEpoch, requestId) => receiveMessage({
|
||||
kind: "request", requestId, channel: "netcatty:local:start",
|
||||
webContentsId: 7, payload: { ...payload, bootEpoch },
|
||||
});
|
||||
const drainMessages = () => new Promise((resolve) => setImmediate(resolve));
|
||||
start(1, "old-start");
|
||||
await drainMessages();
|
||||
start(2, "queued-replacement");
|
||||
start(3, "replacement");
|
||||
resolvers[0]("C:\\Windows\\System32");
|
||||
await drainMessages();
|
||||
await drainMessages();
|
||||
assert.equal(spawns.length, 0, "a superseded PATH wait must not run shell startup scripts");
|
||||
assert.equal(resolvers.length, 2, "the replacement now owns the startup queue");
|
||||
assert.match(messages.find((message) => message.requestId === "old-start")?.error ?? "", /closed or superseded/);
|
||||
assert.match(messages.find((message) => message.requestId === "queued-replacement")?.error ?? "", /superseded/);
|
||||
start(2, "stale-start");
|
||||
receiveMessage({
|
||||
kind: "send", channel: "netcatty:close", webContentsId: 7,
|
||||
payload: { ...payload, bootEpoch: 2 },
|
||||
});
|
||||
resolvers[1]("C:\\Windows\\System32");
|
||||
await drainMessages();
|
||||
await drainMessages();
|
||||
assert.equal(spawns.length, 1);
|
||||
assert.equal(resolvers.length, 2, "a stale start must not even prepare a new environment");
|
||||
assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 3);
|
||||
assert.match(messages.find((message) => message.requestId === "stale-start")?.error ?? "", /superseded/);
|
||||
receiveMessage({
|
||||
kind: "send", channel: "netcatty:close", webContentsId: 7,
|
||||
payload: { ...payload, bootEpoch: 3 },
|
||||
});
|
||||
await drainMessages();
|
||||
await drainMessages();
|
||||
});
|
||||
});
|
||||
@@ -402,11 +402,11 @@ function createTerminalWorkerRuntime(options = {}) {
|
||||
pendingSessionStartBootEpochs.set(sessionId, normalized);
|
||||
}
|
||||
|
||||
function shouldSkipStaleEpochClose(sessionId, bootEpoch) {
|
||||
const closeEpoch = normalizeBootEpoch(bootEpoch);
|
||||
if (closeEpoch === undefined || !sessionId) return false;
|
||||
function isStaleSessionBootEpoch(sessionId, bootEpoch) {
|
||||
const requestedEpoch = normalizeBootEpoch(bootEpoch);
|
||||
if (requestedEpoch === undefined || !sessionId) return false;
|
||||
const ownerEpoch = pendingSessionStartBootEpochs.get(sessionId);
|
||||
return ownerEpoch !== undefined && ownerEpoch > closeEpoch;
|
||||
return ownerEpoch !== undefined && ownerEpoch > requestedEpoch;
|
||||
}
|
||||
|
||||
function pruneSessionLifecycleTombstones() {
|
||||
@@ -614,20 +614,30 @@ function createTerminalWorkerRuntime(options = {}) {
|
||||
void handleRequest(message);
|
||||
return;
|
||||
}
|
||||
if (isStaleSessionBootEpoch(sessionId, message.payload?.bootEpoch)) {
|
||||
postRequestError(message, new Error("Terminal session start was superseded by a newer boot"));
|
||||
return;
|
||||
}
|
||||
// The replacement cannot register its own controller until the queued
|
||||
// start settles, so cancel that start before waiting for its PATH refresh.
|
||||
const { abortPendingBoot } = require("../bridges/sessionBootEpoch.cjs");
|
||||
abortPendingBoot(sessionId, message.payload?.bootEpoch);
|
||||
rememberPendingStartBootEpoch(sessionId, message.payload?.bootEpoch);
|
||||
const closeEpoch = sessionCloseEpochs.get(sessionId) ?? 0;
|
||||
const previous = sessionOperationTails.get(sessionId);
|
||||
const previousKind = sessionOperationKinds.get(sessionId);
|
||||
const shouldClosePreviousStart = previousKind === "start" || sessionStartMarkers.has(sessionId);
|
||||
const current = (previous || Promise.resolve()).catch(() => {}).then(async () => {
|
||||
if ((sessionCloseEpochs.get(sessionId) ?? 0) !== closeEpoch) {
|
||||
postRequestError(message, new Error("Terminal session start was cancelled by close"));
|
||||
if ((sessionCloseEpochs.get(sessionId) ?? 0) !== closeEpoch
|
||||
|| isStaleSessionBootEpoch(sessionId, message.payload?.bootEpoch)) {
|
||||
postRequestError(message, new Error("Terminal session start was cancelled by close or superseded by a newer boot"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (shouldClosePreviousStart) await closeSupersededSessionStart(message);
|
||||
if ((sessionCloseEpochs.get(sessionId) ?? 0) !== closeEpoch) {
|
||||
postRequestError(message, new Error("Terminal session start was cancelled by close"));
|
||||
if ((sessionCloseEpochs.get(sessionId) ?? 0) !== closeEpoch
|
||||
|| isStaleSessionBootEpoch(sessionId, message.payload?.bootEpoch)) {
|
||||
postRequestError(message, new Error("Terminal session start was cancelled by close or superseded by a newer boot"));
|
||||
return;
|
||||
}
|
||||
sessionStartMarkers.add(sessionId);
|
||||
@@ -646,7 +656,7 @@ function createTerminalWorkerRuntime(options = {}) {
|
||||
else handleSend(message);
|
||||
return;
|
||||
}
|
||||
if (shouldSkipStaleEpochClose(sessionId, message.payload?.bootEpoch)) {
|
||||
if (isStaleSessionBootEpoch(sessionId, message.payload?.bootEpoch)) {
|
||||
if (expectsResponse) {
|
||||
parentPort.postMessage({
|
||||
kind: "response",
|
||||
@@ -656,6 +666,10 @@ function createTerminalWorkerRuntime(options = {}) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Signal cancellation before waiting for the start operation. Otherwise a
|
||||
// pending PATH refresh can spawn a PTY before its queued close runs.
|
||||
const { abortPendingBoot } = require("../bridges/sessionBootEpoch.cjs");
|
||||
abortPendingBoot(sessionId, message.payload?.bootEpoch);
|
||||
sessionCloseEpochs.set(sessionId, (sessionCloseEpochs.get(sessionId) ?? 0) + 1);
|
||||
pendingSessionStartBootEpochs.delete(sessionId);
|
||||
touchSessionLifecycleTombstone(sessionId);
|
||||
|
||||
Reference in new issue
Block a user