From c819af29a335d3c7fa6b85fdfebca497fea1b4f0 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Mon, 31 Aug 2026 13:54:15 -0700 Subject: [PATCH] fix(relay): scope identity evidence backstop to visible clients --- src/relay/pty-handler-attach-replay.test.ts | 28 +++++++++++++++++++++ src/relay/pty-handler.ts | 28 +++++++++++++++------ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/relay/pty-handler-attach-replay.test.ts b/src/relay/pty-handler-attach-replay.test.ts index 6af9faec047..2b87d338025 100644 --- a/src/relay/pty-handler-attach-replay.test.ts +++ b/src/relay/pty-handler-attach-replay.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import * as ptyShellUtils from './pty-shell-utils' +import * as processTableSnapshot from '../shared/process-table-snapshot' const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -181,6 +182,33 @@ describe('PtyHandler', () => { expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', expect.anything()) }) + it('keeps stale replay bytes out of the live identity scanner', async () => { + let dataCallback: ((data: string) => void) | undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + }) + vi.spyOn(processTableSnapshot, 'getStrictProcessTableSnapshot').mockResolvedValue([]) + + const spawn = await spawnPty() + const boundary = '\x1b]133;C\x07' + dataCallback!(boundary) + await vi.advanceTimersByTimeAsync(0) + expect(handler.getIdentityEvidenceDebugSnapshot().processTableReads).toBe(1) + + await vi.advanceTimersByTimeAsync(5_001) + await expect(attachPty({ id: PTY_1, suppressReplayNotification: true })).resolves.toEqual({ + incarnationId: spawn.incarnationId, + replay: boundary + }) + await vi.advanceTimersByTimeAsync(0) + + expect(handler.getIdentityEvidenceDebugSnapshot().processTableReads).toBe(1) + }) + it('suppresses legacy replay after the V1 owner is already active', async () => { let dataCallback: ((data: string) => void) | undefined mockPtySpawn.mockReturnValue({ diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index d2f54289bcb..6c875932afa 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -874,13 +874,7 @@ export class PtyHandler { private wireAndStore(managed: ManagedPty): void { managed.physicalExit = new PhysicalExitTracker() this.ptys.set(managed.id, managed) - if (this.identityEvidenceBackstopTimer === null) { - this.identityEvidenceBackstopTimer = setInterval( - () => this.reconcileVisibleIdentityEvidence(), - IDENTITY_EVIDENCE_BACKSTOP_MS - ) - this.identityEvidenceBackstopTimer.unref?.() - } + this.ensureIdentityEvidenceBackstopTimer() if (this.dispatcher.hasConnectedClients?.()) { this.scheduleIdentityEvidenceRead() } @@ -1047,11 +1041,16 @@ export class PtyHandler { } const visible = new Set(ids as string[]) this.identityEvidenceVisibleByClient.set(context.clientId, visible) + this.ensureIdentityEvidenceBackstopTimer() this.publishHeldIdentityEvidenceToClient(context.clientId, visible) return { ok: true } }) this.dispatcher.onClientDetached?.((clientId) => { this.identityEvidenceVisibleByClient.delete(clientId) + if (this.identityEvidenceVisibleByClient.size === 0 && this.identityEvidenceBackstopTimer) { + clearInterval(this.identityEvidenceBackstopTimer) + this.identityEvidenceBackstopTimer = null + } }) this.dispatcher.onRequest('pty.getDefaultShell', async () => resolveDefaultShell()) this.dispatcher.onRequest('pty.serialize', (p) => this.serialize(p)) @@ -2553,6 +2552,21 @@ export class PtyHandler { } } + private ensureIdentityEvidenceBackstopTimer(): void { + if ( + this.identityEvidenceBackstopTimer !== null || + this.identityEvidenceVisibleByClient.size === 0 || + this.ptys.size === 0 + ) { + return + } + this.identityEvidenceBackstopTimer = setInterval( + () => this.reconcileVisibleIdentityEvidence(), + IDENTITY_EVIDENCE_BACKSTOP_MS + ) + this.identityEvidenceBackstopTimer.unref?.() + } + private scheduleIdentityEvidenceRead(id?: string): void { if (id) { this.identityEvidencePendingIds.add(id)