merge origin/main (779 commits) into ethie/pm-clean
Branch semantics kept where main and PM disagree: update_cmd_deps.py, constraints-termux.txt, the Electron update-api-check module and the post-swap hand-off test stay deleted; the pending-fleet-restart catch-up and the local_runtime tag/download ladder stay retired (PM owns engines). Ported from main onto the branch's shape: profile_scoped_chore for the auto-archive and plugin-update housekeeping chores, the local-runtime cross-process boot lock and residency cap, the checkpoint tmp_pack sweep, the cua daemon-liveness status probe, the remote-served Desktop update flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways (urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn without [standard]), and the umask-scoping spawn test. uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from main switched to utf-8-sig (check-windows-footguns).
This commit is contained in:
115
web/src/lib/pty-attach-token.test.ts
Normal file
115
web/src/lib/pty-attach-token.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* One "browser tab": its own sessionStorage, its own module state (the claimed
|
||||
* token) and the pages share the fake Web Locks manager, exactly like tabs on
|
||||
* one origin do.
|
||||
*/
|
||||
type Tab = {
|
||||
ptyAttachToken: (rotate?: boolean) => Promise<string>;
|
||||
storage: Storage;
|
||||
};
|
||||
|
||||
const KEY = "hermes.pty.token.chat";
|
||||
|
||||
function fakeStorage(seed: Record<string, string> = {}): Storage {
|
||||
const store: Record<string, string> = { ...seed };
|
||||
return {
|
||||
clear: () => {
|
||||
for (const key of Object.keys(store)) delete store[key];
|
||||
},
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
key: (index: number) => Object.keys(store)[index] ?? null,
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = String(value);
|
||||
},
|
||||
} as Storage;
|
||||
}
|
||||
|
||||
/** Web Locks, as far as attach-token claiming uses them. */
|
||||
function fakeLocks(held: Set<string>, requests: string[] = []) {
|
||||
return {
|
||||
request: (
|
||||
name: string,
|
||||
_options: { ifAvailable?: boolean },
|
||||
callback: (lock: { name: string } | null) => unknown,
|
||||
) => {
|
||||
requests.push(name);
|
||||
if (held.has(name)) {
|
||||
callback(null);
|
||||
return Promise.resolve();
|
||||
}
|
||||
held.add(name);
|
||||
return Promise.resolve(callback({ name }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The lock a tab holds disappears when the document unloads (reload/close). */
|
||||
function simulateUnload(held: Set<string>) {
|
||||
held.clear();
|
||||
}
|
||||
|
||||
async function openTab(
|
||||
held: Set<string>,
|
||||
seed: Record<string, string> = {},
|
||||
requests: string[] = [],
|
||||
): Promise<Tab> {
|
||||
vi.resetModules(); // a new document has its own claimed-token state
|
||||
const storage = fakeStorage(seed);
|
||||
Object.defineProperty(window, "sessionStorage", { configurable: true, value: storage });
|
||||
Object.defineProperty(window.navigator, "locks", {
|
||||
configurable: true,
|
||||
value: fakeLocks(held, requests),
|
||||
});
|
||||
const { ptyAttachToken } = await import("./pty-attach-token");
|
||||
return { ptyAttachToken, storage };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("crypto", {
|
||||
getRandomValues: (values: Uint8Array) => {
|
||||
values.fill(Math.floor(Math.random() * 256));
|
||||
return values;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
delete (window.navigator as { locks?: unknown }).locks;
|
||||
});
|
||||
|
||||
describe("ptyAttachToken", () => {
|
||||
it("mints its own token for a duplicate tab instead of sharing the live one", async () => {
|
||||
const held = new Set<string>();
|
||||
const first = await openTab(held);
|
||||
const tokenA = await first.ptyAttachToken();
|
||||
|
||||
// Chrome's "Duplicate tab" clones sessionStorage into the new tab.
|
||||
const duplicate = await openTab(held, { [KEY]: tokenA });
|
||||
const tokenB = await duplicate.ptyAttachToken();
|
||||
|
||||
expect(tokenB).not.toBe(tokenA);
|
||||
expect(duplicate.storage.getItem(KEY)).toBe(tokenB);
|
||||
});
|
||||
|
||||
it("reuses the stored token after a reload, once the old document released it", async () => {
|
||||
const held = new Set<string>();
|
||||
const before = await openTab(held, { [KEY]: "tab-token" });
|
||||
expect(await before.ptyAttachToken()).toBe("tab-token");
|
||||
|
||||
simulateUnload(held); // reload: the previous document's lock is gone
|
||||
const reloaded = await openTab(held, { [KEY]: "tab-token" });
|
||||
|
||||
expect(await reloaded.ptyAttachToken()).toBe("tab-token");
|
||||
});
|
||||
});
|
||||
76
web/src/lib/pty-attach-token.ts
Normal file
76
web/src/lib/pty-attach-token.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Identity of THIS browser tab's keep-alive PTY, sent as `?attach=`.
|
||||
*
|
||||
* The dashboard's PTY registry maps one attach token to exactly one PTY, so a
|
||||
* token two tabs present makes the second tab take the first one's terminal
|
||||
* over (the first is closed 4409 and goes dead — the reported multi-tab
|
||||
* interference). localStorage is shared per ORIGIN, so every tab read the same
|
||||
* value, and Chrome's "Duplicate tab" clones sessionStorage into the new tab,
|
||||
* so neither storage alone isolates the tabs. The token therefore lives in
|
||||
* sessionStorage *and* is claimed with a Web Lock: a tab whose token is
|
||||
* already claimed by a live document mints its own instead of sharing one.
|
||||
* See #115304.
|
||||
*/
|
||||
|
||||
const PTY_ATTACH_TOKEN_KEY = "hermes.pty.token.chat";
|
||||
|
||||
/** The token this document claimed — a reconnect must not re-request the lock
|
||||
* (Web Locks are not reentrant, so asking twice in one document deadlocks). */
|
||||
let claimedToken: string | null = null;
|
||||
|
||||
function tabStorage(): Storage | null {
|
||||
try {
|
||||
return typeof window === "undefined" ? null : window.sessionStorage;
|
||||
} catch {
|
||||
return null; /* private mode / storage blocked */
|
||||
}
|
||||
}
|
||||
|
||||
function mint(): string {
|
||||
const a = new Uint8Array(16);
|
||||
crypto.getRandomValues(a);
|
||||
return Array.from(a, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/** Never settles, so the lock stays held for this document's lifetime. */
|
||||
const HOLD: Promise<void> = new Promise<void>(() => {});
|
||||
|
||||
/**
|
||||
* Claim `token` for this document. True when we now hold it; the browser
|
||||
* releases it when the document unloads, so a reload of this tab finds the
|
||||
* token free again while a live second tab does not. Browsers without the Web
|
||||
* Locks API fall back to sessionStorage isolation only.
|
||||
*/
|
||||
async function claim(token: string): Promise<boolean> {
|
||||
const locks = typeof navigator === "undefined" ? undefined : navigator.locks;
|
||||
if (!locks) return true;
|
||||
return new Promise<boolean>((resolve) => {
|
||||
void locks.request(`hermes.pty.attach.${token}`, { ifAvailable: true }, (lock) => {
|
||||
resolve(lock !== null);
|
||||
return lock ? HOLD : Promise.resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This tab's `?attach=` token, minted when `rotate` starts a fresh session (the
|
||||
* old keep-alive PTY must NOT be reattached) or when another live tab already
|
||||
* claimed the stored one.
|
||||
*/
|
||||
export async function ptyAttachToken(rotate = false): Promise<string> {
|
||||
const stored =
|
||||
claimedToken ?? tabStorage()?.getItem(PTY_ATTACH_TOKEN_KEY) ?? "";
|
||||
if (!rotate && stored && (stored === claimedToken || (await claim(stored)))) {
|
||||
claimedToken = stored;
|
||||
return stored;
|
||||
}
|
||||
const token = mint();
|
||||
try {
|
||||
tabStorage()?.setItem(PTY_ATTACH_TOKEN_KEY, token);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await claim(token);
|
||||
claimedToken = token;
|
||||
return token;
|
||||
}
|
||||
@@ -133,3 +133,58 @@ describe("createPtyCompositionForwarder", () => {
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobile IME double-send dedup (#115505)", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("delivers a word exactly once across the three Android/Gboard double-send paths", () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
const forwarder = createPtyCompositionForwarder(send);
|
||||
|
||||
// 1. compositionend fires twice with identical data.
|
||||
forwarder.onCompositionEnd("hello");
|
||||
vi.advanceTimersByTime(16);
|
||||
forwarder.onCompositionEnd("hello");
|
||||
vi.advanceTimersByTime(16);
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith("hello");
|
||||
|
||||
// 2. onData already carried the commit; a trailing compositionend must not re-arm the fallback.
|
||||
vi.advanceTimersByTime(100);
|
||||
forwarder.noteTerminalData("wor");
|
||||
forwarder.noteTerminalData("ld");
|
||||
forwarder.onCompositionEnd("world");
|
||||
vi.runAllTimers();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 3. onData echoes (or strictly extends) text the composition path just committed.
|
||||
vi.advanceTimersByTime(100);
|
||||
forwarder.onCompositionEnd("again");
|
||||
vi.advanceTimersByTime(16);
|
||||
expect(send).toHaveBeenLastCalledWith("again");
|
||||
expect(forwarder.filterTerminalData("again")).toBe("");
|
||||
expect(forwarder.filterTerminalData("again ")).toBe(" ");
|
||||
});
|
||||
|
||||
it("keeps real repeats and unrelated input: outside the echo window and for different text", () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
const forwarder = createPtyCompositionForwarder(send);
|
||||
|
||||
forwarder.onCompositionEnd("hello");
|
||||
vi.advanceTimersByTime(16);
|
||||
vi.advanceTimersByTime(100);
|
||||
forwarder.onCompositionEnd("hello");
|
||||
vi.advanceTimersByTime(16);
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
|
||||
forwarder.noteTerminalData("abc");
|
||||
forwarder.onCompositionEnd("xyz");
|
||||
vi.runAllTimers();
|
||||
expect(send).toHaveBeenLastCalledWith("xyz");
|
||||
|
||||
expect(forwarder.filterTerminalData("other")).toBe("other");
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(forwarder.filterTerminalData("xyz")).toBe("xyz");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,40 @@
|
||||
/**
|
||||
* Delays an IME/dead-key commit just long enough for xterm to emit onData.
|
||||
*
|
||||
* xterm is authoritative when it emits the commit. Browsers/layouts where it
|
||||
* xterm is authoritative when it emits onData. Browsers/layouts where it
|
||||
* does not emit onData still forward the compositionend text on the next turn.
|
||||
*
|
||||
* Mobile IME keyboards (Android/Gboard) turn composition events and onData
|
||||
* into overlapping sources of the same user intent instead of exclusive
|
||||
* ones: compositionend can fire twice with identical data, it can trail
|
||||
* onData that already carried the committed text, and onData can echo a
|
||||
* word the composition path just forwarded. The forwarder keeps a short
|
||||
* record of both delivery channels and drops these re-sends.
|
||||
*/
|
||||
|
||||
// How long committed text stays comparable across the two input channels.
|
||||
// IME re-fires land within a few milliseconds; a human repeating the same
|
||||
// commit takes far longer, so the window will not swallow real retypes.
|
||||
const ECHO_WINDOW_MS = 80;
|
||||
|
||||
// Cap on retained plain terminal input; only recent text is compared, and
|
||||
// this keeps a long typing burst from growing the record without bound.
|
||||
const MAX_DELIVERED_TRACK_CHARS = 256;
|
||||
|
||||
interface DeliveredText {
|
||||
text: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export function createPtyCompositionForwarder(send: (data: string) => void) {
|
||||
let pending: string | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let matchedTerminalPrefix = "";
|
||||
let sawUnrelatedTerminalData = false;
|
||||
// Text recently delivered to the PTY through this forwarder's own send.
|
||||
let lastSent: DeliveredText | null = null;
|
||||
// Plain text recently delivered to the PTY through xterm's onData.
|
||||
let lastTerminalData: DeliveredText | null = null;
|
||||
|
||||
const clearPending = () => {
|
||||
pending = null;
|
||||
@@ -20,21 +46,49 @@ export function createPtyCompositionForwarder(send: (data: string) => void) {
|
||||
}
|
||||
};
|
||||
|
||||
const sendCommitted = (data: string) => {
|
||||
lastSent = { text: data, at: Date.now() };
|
||||
send(data);
|
||||
};
|
||||
|
||||
const withinEchoWindow = (entry: DeliveredText | null): entry is DeliveredText =>
|
||||
entry !== null && Date.now() - entry.at <= ECHO_WINDOW_MS;
|
||||
|
||||
return {
|
||||
onCompositionEnd(data: string | null) {
|
||||
if (!data) return;
|
||||
// A compositionend trailing terminal data that already carried the
|
||||
// commit is a late duplicate, not a new commit — re-sending it
|
||||
// through the fallback duplicates the word in the PTY.
|
||||
if (withinEchoWindow(lastTerminalData) && lastTerminalData.text.endsWith(data)) {
|
||||
return;
|
||||
}
|
||||
// The same IME can fire compositionend twice with identical data;
|
||||
// the first copy has already been delivered.
|
||||
if (withinEchoWindow(lastSent) && lastSent.text === data) {
|
||||
return;
|
||||
}
|
||||
// Preserve rapid consecutive commits instead of discarding the first.
|
||||
const previous = pending;
|
||||
clearPending();
|
||||
if (previous) send(previous);
|
||||
if (previous) sendCommitted(previous);
|
||||
pending = data;
|
||||
timer = setTimeout(() => {
|
||||
const committed = pending;
|
||||
clearPending();
|
||||
if (committed) send(committed);
|
||||
if (committed) sendCommitted(committed);
|
||||
}, 16);
|
||||
},
|
||||
noteTerminalData(data: string) {
|
||||
if (!data.startsWith("\x1b")) {
|
||||
const now = Date.now();
|
||||
const carried = withinEchoWindow(lastTerminalData) ? lastTerminalData.text : "";
|
||||
lastTerminalData = {
|
||||
text: (carried + data).slice(-MAX_DELIVERED_TRACK_CHARS),
|
||||
at: now,
|
||||
};
|
||||
}
|
||||
|
||||
if (!pending || data.startsWith("\x1b") || sawUnrelatedTerminalData) return;
|
||||
|
||||
// xterm may split committed text across callbacks, but only a clean,
|
||||
@@ -49,6 +103,22 @@ export function createPtyCompositionForwarder(send: (data: string) => void) {
|
||||
sawUnrelatedTerminalData = true;
|
||||
}
|
||||
},
|
||||
dispose: clearPending,
|
||||
// A mobile IME can re-emit just-committed composition text through
|
||||
// xterm's onData after the composition path already forwarded it.
|
||||
// Returns the portion still worth forwarding: "" for a full duplicate,
|
||||
// the new suffix for a strict extension, the input unchanged otherwise.
|
||||
filterTerminalData(data: string): string {
|
||||
if (!withinEchoWindow(lastSent)) return data;
|
||||
if (data === lastSent.text) return "";
|
||||
if (data.length > lastSent.text.length && data.startsWith(lastSent.text)) {
|
||||
return data.slice(lastSent.text.length);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
dispose: () => {
|
||||
clearPending();
|
||||
lastSent = null;
|
||||
lastTerminalData = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,39 +92,16 @@ import {
|
||||
ptyRejectionBanner,
|
||||
type PtyBannerAction,
|
||||
} from "@/lib/pty-close-copy";
|
||||
import { ptyAttachToken } from "@/lib/pty-attach-token";
|
||||
import { loseWebglContexts } from "@/lib/xterm-webgl-release";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
import { errorMessage } from "@/lib/api-error";
|
||||
|
||||
// Stable per-browser token identifying THIS chat tab's keep-alive PTY session.
|
||||
// Sent as ?attach=; lets a refresh/disconnect reattach to the same live process
|
||||
// instead of spawning a fresh one. Per-localStorage, so other devices can't grab it.
|
||||
// ``rotate`` mints a new token — used when the user explicitly starts a fresh
|
||||
// session so the old keep-alive PTY is NOT reattached (the registry reaps it).
|
||||
const PTY_ATTACH_TOKEN_KEY = "hermes.pty.token.chat";
|
||||
function ptyAttachToken(rotate = false): string {
|
||||
let t = "";
|
||||
if (!rotate) {
|
||||
try {
|
||||
t = window.localStorage.getItem(PTY_ATTACH_TOKEN_KEY) ?? "";
|
||||
} catch {
|
||||
/* private mode / storage blocked */
|
||||
}
|
||||
}
|
||||
if (!t) {
|
||||
const a = new Uint8Array(16);
|
||||
crypto.getRandomValues(a);
|
||||
t = Array.from(a, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
try {
|
||||
window.localStorage.setItem(PTY_ATTACH_TOKEN_KEY, t);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
// Per-tab keep-alive identity (`?attach=`): lives in pty-attach-token.ts so a
|
||||
// second tab — including a Chrome "Duplicate tab" — gets its own PTY instead of
|
||||
// taking over this one. See #115304.
|
||||
|
||||
// Channel id ties this chat tab's PTY child (publisher) to its sidebar
|
||||
// (subscriber). Generated once per mount so a tab refresh starts a fresh
|
||||
@@ -1254,7 +1231,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
// Keep-alive identity: reattach to this tab's living PTY across
|
||||
// refresh/transient drops. A forced-fresh start rotates the token so
|
||||
// the previous keep-alive PTY is not reattached (registry reaps it).
|
||||
params.attach = ptyAttachToken(forceFresh);
|
||||
params.attach = await ptyAttachToken(forceFresh);
|
||||
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
|
||||
// selected profile, so the conversation runs with that profile's model,
|
||||
// skills, memory, and sessions (see web_server._resolve_chat_argv).
|
||||
@@ -1553,7 +1530,12 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
if (!SGR_MOUSE_RE.test(data)) {
|
||||
compositionForwarder.noteTerminalData(data);
|
||||
}
|
||||
forwardPtyData(data);
|
||||
// A mobile IME can re-emit just-committed composition text through
|
||||
// onData; only the part that is not an echo of that commit is real.
|
||||
const unechoed = compositionForwarder.filterTerminalData(data);
|
||||
if (unechoed) {
|
||||
forwardPtyData(unechoed);
|
||||
}
|
||||
});
|
||||
|
||||
onResizeDisposable = term.onResize(({ cols, rows }) => {
|
||||
|
||||
Reference in New Issue
Block a user