Compare commits

...
4 changed files with 82 additions and 23 deletions

No files matched your search

+10
View File
@@ -564,6 +564,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
const reconnectWakeTokenRef = useRef<symbol | null>(null);
const manualReconnectRequestRef = useRef<() => void>(() => {});
// Once a pane reconnects, every later SSH attempt in it must dial a fresh
// connection: reusing a live/idle pooled transport multiplexes onto the old
// login, so remote supplementary-group changes (e.g. `usermod -aG`) are not
// picked up until the app fully quits (#3293). See createTerminalSessionStarters.
const requireFreshConnectionOnReconnectRef = useRef(false);
const terminalDataCapturedRef = useRef(false);
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
const terminalOutputHistoryRef = useRef(createTerminalOutputHistoryPreview());
@@ -2375,6 +2380,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
reuseConnectionFromSessionIdRef: reuseConnectionSourceRef,
requireFreshConnection,
requireFreshConnectionOnReconnectRef,
reuseConnectionSourceAttemptedRef,
setConnectionReuseAttemptSourceId,
shouldUseFreshSshConnection: () => {
@@ -3627,6 +3633,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const retryStillActive = () => retryTokenStillCurrent() && termRef.current === term;
bootEpochRef.current += 1;
// This start is a reconnect: force the bridge to dial a fresh connection
// instead of borrowing a parked/live transport, so the server performs a
// new login and picks up remote supplementary-group changes (#3293).
requireFreshConnectionOnReconnectRef.current = true;
publishBootEpoch();
isBootActiveRef.current = true;
auth.resetForRetry();
@@ -481,9 +481,9 @@ test("startSSH requests a fresh transport for ordinary opens with connection aut
assert.equal(captured[0].sourceSessionId, "source-session");
assert.equal(captured[1].reuseTransport, false);
assert.equal(captured[1].sourceSessionId, undefined);
assert.equal(captured[2].reuseTransport, undefined);
assert.equal(captured[2].reuseTransport, false);
assert.equal(captured[2].sourceSessionId, undefined);
assert.equal(captured[3].reuseTransport, undefined);
assert.equal(captured[3].reuseTransport, false);
assert.deepEqual(reuseAttempts, ["source-session", undefined, undefined]);
});
@@ -502,9 +502,7 @@ test("startSSH requests a fresh transport for Duplicate Session clones", async (
resizeSession: noop,
};
// Without the marker an ordinary open stays eligible for general endpoint
// reuse; with it, every attempt (including reconnects) dials fresh instead
// of borrowing the source's live authenticated transport.
// Ordinary opens and Duplicate Session both require a new login.
await createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
terminalBackend,
@@ -522,12 +520,65 @@ test("startSSH requests a fresh transport for Duplicate Session clones", async (
await duplicateStarters.startSSH(createTermStub() as never);
await duplicateStarters.startSSH(createTermStub() as never);
assert.equal(captured[0].reuseTransport, undefined);
assert.equal(captured[0].reuseTransport, false);
assert.equal(captured[0].sourceSessionId, undefined);
assert.equal(captured[1].reuseTransport, false);
assert.equal(captured[2].reuseTransport, false);
});
test("startSSH dials a fresh transport once a pane has reconnected (#3293)", async () => {
const captured: Record<string, unknown>[] = [];
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
captured.push(options);
return `ssh-session-${captured.length}`;
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
// An ordinary new tab must refresh groups even before its first reconnect.
await createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
requireFreshConnectionOnReconnectRef: { current: false },
terminalBackend,
}) as never).startSSH(createTermStub() as never);
assert.equal(captured[0].reuseTransport, false);
// After a reconnect (manual retry / auto-reconnect) every attempt must dial
// a brand-new connection so the server performs a fresh login and picks up
// remote supplementary-group changes (e.g. `usermod -aG`).
const reconnectedStarters = createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
requireFreshConnectionOnReconnectRef: { current: true },
terminalBackend,
}) as never);
await reconnectedStarters.startSSH(createTermStub() as never);
await reconnectedStarters.startSSH(createTermStub() as never);
assert.equal(captured[1].reuseTransport, false);
assert.equal(captured[2].reuseTransport, false);
// Copy/Split may still have an unconsumed source while credentials load.
// A reconnect must discard that intent before the next backend attempt.
const pendingSource = { current: "source-session" as string | undefined };
const reuseAttempts: (string | undefined)[] = [];
await createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
requireFreshConnectionOnReconnectRef: { current: true },
reuseConnectionFromSessionIdRef: pendingSource,
setConnectionReuseAttemptSourceId: (id: string | undefined) => reuseAttempts.push(id),
terminalBackend,
}) as never).startSSH(createTermStub() as never);
assert.equal(captured[3].reuseTransport, false);
assert.equal(captured[3].sourceSessionId, undefined);
assert.equal(pendingSource.current, undefined);
assert.deepEqual(reuseAttempts, [undefined]);
});
test("startSSH commits an empty automation snapshot only after the backend session succeeds", async () => {
let resolveStart: ((sessionId: string) => void) | undefined;
let commitCount = 0;
@@ -605,7 +656,7 @@ test("startSSH rechecks the live automation policy before password fallback", as
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(captured.length, 2);
assert.equal(captured[0].reuseTransport, undefined);
assert.equal(captured[0].reuseTransport, false);
assert.equal(captured[1].reuseTransport, false);
assert.equal(commitCount, 0);
});
@@ -616,22 +616,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
? ctx.host.identityFilePaths
: undefined;
let sourceReuseAttemptedWithinStart = false;
const startAttempt = async (attempt: {
password?: string;
key?: SSHKey;
useIdentityFiles?: boolean;
useSshAgent?: boolean;
}): Promise<string> => {
const sourceSessionId = ctx.reuseConnectionFromSessionIdRef?.current;
const sourceReuseAttempted = ctx.reuseConnectionSourceAttemptedRef?.current
?? sourceReuseAttemptedWithinStart;
const isFallbackAfterSourceReuse = sourceReuseAttempted && !sourceSessionId;
// Reconnect supersedes a Copy/Split intent that was still waiting for credentials.
const sourceSessionId = ctx.requireFreshConnectionOnReconnectRef?.current
? undefined
: ctx.reuseConnectionFromSessionIdRef?.current;
if (ctx.reuseConnectionFromSessionIdRef) {
ctx.reuseConnectionFromSessionIdRef.current = undefined;
}
if (sourceSessionId) {
sourceReuseAttemptedWithinStart = true;
if (ctx.reuseConnectionSourceAttemptedRef) {
ctx.reuseConnectionSourceAttemptedRef.current = true;
}
@@ -704,21 +702,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// bridge silently falls back to a fresh connection if the source is
// gone, so reconnect/retry after the source closed still works.
sourceSessionId,
// Connect-time automation must see the complete login sequence. An
// explicit Copy/Split keeps its source-session reuse contract, while
// an ordinary open bypasses endpoint/idle transport reuse. Duplicate
// Session clones must never borrow the source's live transport
// either, so they send an explicit `reuseTransport: false`.
reuseTransport: !sourceSessionId
&& (requiresFreshSshConnection || isFallbackAfterSourceReuse || ctx.requireFreshConnection === true)
? false
: undefined,
// Only an explicit Copy/Split may share an existing login. Ordinary
// opens and reconnects must authenticate again to refresh remote groups.
reuseTransport: sourceSessionId ? undefined : false,
skipShellPidDiscovery: ctx.isNetworkDevice === true,
});
if (!requiresFreshSshConnection) {
ctx.onConnectAutomationSnapshotCommitted?.();
}
sourceReuseAttemptedWithinStart = false;
if (ctx.reuseConnectionSourceAttemptedRef) {
ctx.reuseConnectionSourceAttemptedRef.current = false;
}
@@ -154,6 +154,13 @@ export type TerminalSessionStartersContext = {
// SSH attempt must send `reuseTransport: false` so the bridge never borrows
// the source's live or any other pooled transport.
requireFreshConnection?: boolean;
// Set by the reconnect path (manual retry / auto-reconnect) for the rest of
// the pane's lifetime: every SSH attempt must dial a brand-new connection
// instead of borrowing a live or idle pooled transport. Reusing an
// already-authenticated connection skips the server-side login, so remote
// supplementary-group changes (e.g. `usermod -aG`) stay invisible until the
// whole app quits (#3293).
requireFreshConnectionOnReconnectRef?: MutableRefObject<boolean>;
// Persists across renderer auth retries after the one-shot source intent is
// consumed. Cleared only after a backend session starts successfully.
reuseConnectionSourceAttemptedRef?: MutableRefObject<boolean>;