Compare commits

...
Author SHA1 Message Date
陈大猫 51d68b51ce fix(terminal): cancel superseded worker starts before queuing
Signal the existing boot-abort controller when a replacement arrives,
reject stale boot epochs, and recheck queued epochs before startup.
Only the latest reconnect may spawn after a pending PATH refresh.

Cover repeated reconnect requests and delayed old starts/closes through
the real worker dispatcher and local-session bridge.
2026-10-08 15:42:49 +08:00
陈大猫 6b1cf802d0 fix(terminal): abort pending starts before worker close queue
Propagate an owned close to the existing pending boot controller before
waiting for its serialized start. Keep transport cleanup ordered and keep
stale boot-epoch closes from cancelling replacements.

Exercise both fire-and-forget and awaited close messages through the real
worker dispatcher and local-session bridge with a controlled PATH wait.
2026-10-08 15:33:04 +08:00
陈大猫 76957e3597 fix(terminal): cancel pending local starts through boot epochs
Register the existing pending boot controller before awaiting PATH refresh.
Honor tab close and reconnect cancellation before spawning, and clear only
the matching controller so stale completion cannot erase a newer boot.

Regression tests reproduce both the closed-tab PTY spawn and stale-close
ownership failures against the prior revision.
2026-10-08 15:09:49 +08:00
陈大猫 64f6214690 fix(terminal): bound asynchronous Windows PATH refresh
Replace blocking per-terminal registry queries with parallel async reads,
a one-second deadline, and a shared five-second snapshot. Preserve machine
before user PATH ordering and place guessed CLI directories last. Retain
per-hive successful values when registry reads fail, and honor explicit
PATH overrides without querying the registry.
2026-10-08 14:46:25 +08:00
6 changed files with 441 additions and 195 deletions

No files matched your search

+86 -81
View File
@@ -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,
+93 -76
View File
@@ -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");
});
+21 -4
View File
@@ -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();
});
});
+23 -9
View File
@@ -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);