-
Notifications
You must be signed in to change notification settings - Fork 2.7k
perf(renderer): bail out of identity-equal terminal layout and cache-timer writes #12420
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
5d4e6b9
perf(renderer): bail out of identity-equal terminal layout and cache-…
brennanb2025 bef37d5
fix(renderer): retry failed remote pane layout pushes
brennanb2025 b91c166
test(renderer): cover stale remote layout failures
brennanb2025 10664b2
test(e2e): cover remote pane layout retry
brennanb2025 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
183 changes: 183 additions & 0 deletions
183
src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| /** | ||
| * Perf regression: layout persists fire on pane-title churn, and every one of | ||
| * them used to push a remote-runtime IPC round trip regardless of whether the | ||
| * host-visible layout had moved. Counting invocations at the mocked IPC boundary | ||
| * pins the before/after: 100 persists with an unchanged layout cost 100 pushes | ||
| * before the dedupe and 1 after. | ||
| */ | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | ||
| import type { TerminalLayoutSnapshot } from '../../../../shared/types' | ||
|
|
||
| const updateWebRuntimePaneLayout = vi.fn() | ||
| vi.mock('@/runtime/web-runtime-session', () => ({ | ||
| updateWebRuntimePaneLayout: (...args: unknown[]) => updateWebRuntimePaneLayout(...args) | ||
| })) | ||
|
|
||
| const { createRemotePaneLayoutPusher } = await import('./remote-pane-layout-push') | ||
|
|
||
| const PERSISTS = 100 | ||
|
|
||
| function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } { | ||
| let resolve = (_value: T): void => undefined | ||
| const promise = new Promise<T>((complete) => { | ||
| resolve = complete | ||
| }) | ||
| return { promise, resolve } | ||
| } | ||
|
|
||
| function makeLayout(overrides: Partial<TerminalLayoutSnapshot> = {}): TerminalLayoutSnapshot { | ||
| return { | ||
| root: { | ||
| type: 'split', | ||
| direction: 'vertical', | ||
| ratio: 0.5, | ||
| first: { type: 'leaf', leafId: 'leaf-a' }, | ||
| second: { type: 'leaf', leafId: 'leaf-b' } | ||
| }, | ||
| activeLeafId: 'leaf-a', | ||
| expandedLeafId: null, | ||
| ptyIdsByLeafId: { 'leaf-a': 'remote:pty-a', 'leaf-b': 'remote:pty-b' }, | ||
| titlesByLeafId: { 'leaf-a': 'build' }, | ||
| ...overrides | ||
| } | ||
| } | ||
|
|
||
| describe('createRemotePaneLayoutPusher', () => { | ||
| beforeEach(() => { | ||
| updateWebRuntimePaneLayout.mockReset().mockResolvedValue(true) | ||
| }) | ||
|
|
||
| it('pushes once across 100 persists of an unchanged layout', () => { | ||
| const pusher = createRemotePaneLayoutPusher() | ||
| for (let i = 0; i < PERSISTS; i += 1) { | ||
| // Fresh object each time: persistLayoutSnapshot re-serializes on every call. | ||
| pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() }) | ||
| } | ||
| expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('sends the same payload the un-deduped path sent', () => { | ||
| const pusher = createRemotePaneLayoutPusher() | ||
| const layout = makeLayout() | ||
| pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout }) | ||
| expect(updateWebRuntimePaneLayout).toHaveBeenCalledWith({ | ||
| worktreeId: 'wt-1', | ||
| tabId: 'tab-1', | ||
| root: layout.root, | ||
| expandedLeafId: layout.expandedLeafId, | ||
| titlesByLeafId: layout.titlesByLeafId | ||
| }) | ||
| }) | ||
|
|
||
| it('omits titlesByLeafId when the layout carries no titles', () => { | ||
| const pusher = createRemotePaneLayoutPusher() | ||
| pusher.push({ | ||
| worktreeId: 'wt-1', | ||
| tabId: 'tab-1', | ||
| layout: makeLayout({ titlesByLeafId: undefined }) | ||
| }) | ||
| expect(updateWebRuntimePaneLayout.mock.calls[0][0]).not.toHaveProperty('titlesByLeafId') | ||
| }) | ||
|
|
||
| it('pushes again for every host-visible change', () => { | ||
| const pusher = createRemotePaneLayoutPusher() | ||
| pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() }) | ||
| pusher.push({ | ||
| worktreeId: 'wt-1', | ||
| tabId: 'tab-1', | ||
| layout: makeLayout({ expandedLeafId: 'leaf-a' }) | ||
| }) | ||
| pusher.push({ | ||
| worktreeId: 'wt-1', | ||
| tabId: 'tab-1', | ||
| layout: makeLayout({ | ||
| expandedLeafId: 'leaf-a', | ||
| titlesByLeafId: { 'leaf-a': 'test' } | ||
| }) | ||
| }) | ||
| pusher.push({ | ||
| worktreeId: 'wt-1', | ||
| tabId: 'tab-1', | ||
| layout: makeLayout({ | ||
| expandedLeafId: 'leaf-a', | ||
| titlesByLeafId: { 'leaf-a': 'test' }, | ||
| root: { | ||
| type: 'split', | ||
| direction: 'vertical', | ||
| ratio: 0.7, | ||
| first: { type: 'leaf', leafId: 'leaf-a' }, | ||
| second: { type: 'leaf', leafId: 'leaf-b' } | ||
| } | ||
| }) | ||
| }) | ||
| expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(4) | ||
| }) | ||
|
|
||
| it('pushes again when a remote pane swaps its pty', () => { | ||
| // ptyIdsByLeafId is not in the payload, but a swap means a different host session. | ||
| const pusher = createRemotePaneLayoutPusher() | ||
| pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() }) | ||
| pusher.push({ | ||
| worktreeId: 'wt-1', | ||
| tabId: 'tab-1', | ||
| layout: makeLayout({ ptyIdsByLeafId: { 'leaf-a': 'remote:pty-c', 'leaf-b': 'remote:pty-b' } }) | ||
| }) | ||
| expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(2) | ||
| }) | ||
|
|
||
| it('does not carry a cached layout across tabs', () => { | ||
| const pusher = createRemotePaneLayoutPusher() | ||
| pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() }) | ||
| pusher.push({ worktreeId: 'wt-1', tabId: 'tab-2', layout: makeLayout() }) | ||
| pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() }) | ||
| expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(3) | ||
| }) | ||
|
|
||
| it('does not carry a cached layout across worktrees', () => { | ||
| const pusher = createRemotePaneLayoutPusher() | ||
| pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() }) | ||
| pusher.push({ worktreeId: 'wt-2', tabId: 'tab-1', layout: makeLayout() }) | ||
| expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(2) | ||
| }) | ||
|
|
||
| it('retries an unchanged layout after a failed push', async () => { | ||
| updateWebRuntimePaneLayout.mockResolvedValueOnce(false) | ||
| const pusher = createRemotePaneLayoutPusher() | ||
| const input = { worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() } | ||
|
|
||
| pusher.push(input) | ||
| await Promise.resolve() | ||
| pusher.push(input) | ||
|
|
||
| expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(2) | ||
| }) | ||
|
|
||
| it('does not let a stale failure invalidate a newer in-flight layout', async () => { | ||
| const first = deferred<boolean>() | ||
| const second = deferred<boolean>() | ||
| updateWebRuntimePaneLayout.mockImplementationOnce(() => first.promise) | ||
| updateWebRuntimePaneLayout.mockImplementationOnce(() => second.promise) | ||
| const pusher = createRemotePaneLayoutPusher() | ||
| const changedLayout = makeLayout({ expandedLeafId: 'leaf-a' }) | ||
|
|
||
| pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() }) | ||
| pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: changedLayout }) | ||
| first.resolve(false) | ||
| await Promise.resolve() | ||
| pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: changedLayout }) | ||
|
|
||
| expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(2) | ||
| second.resolve(true) | ||
| }) | ||
|
|
||
| it('re-pushes after a remount re-establishes host geometry', () => { | ||
| const pusher = createRemotePaneLayoutPusher() | ||
| pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() }) | ||
| createRemotePaneLayoutPusher().push({ | ||
| worktreeId: 'wt-1', | ||
| tabId: 'tab-1', | ||
| layout: makeLayout() | ||
| }) | ||
| expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(2) | ||
| }) | ||
| }) |
47 changes: 47 additions & 0 deletions
47
src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import type { TerminalLayoutSnapshot } from '../../../../shared/types' | ||
| import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' | ||
| import { updateWebRuntimePaneLayout } from '@/runtime/web-runtime-session' | ||
|
|
||
| export type RemotePaneLayoutPusher = { | ||
| push: (input: { worktreeId: string; tabId: string; layout: TerminalLayoutSnapshot }) => void | ||
| } | ||
|
|
||
| /** | ||
| * Pane geometry is host-authoritative for remote tabs, so persists must push it — but | ||
| * persists also fire on pane-title churn, which leaves the host-visible layout untouched. | ||
| * Dedupe against the last push so unchanged layouts cost no remote round trip. | ||
| */ | ||
| export function createRemotePaneLayoutPusher(): RemotePaneLayoutPusher { | ||
| let lastAttempt: { | ||
| id: number | ||
| worktreeId: string | ||
| tabId: string | ||
| snapshot: TerminalLayoutSnapshot | ||
| } | null = null | ||
| let nextAttemptId = 0 | ||
| return { | ||
| push: ({ worktreeId, tabId, layout }) => { | ||
| if ( | ||
| lastAttempt?.worktreeId === worktreeId && | ||
| lastAttempt.tabId === tabId && | ||
| terminalLayoutEqual(lastAttempt.snapshot, layout) | ||
| ) { | ||
| return | ||
| } | ||
| const attempt = { id: ++nextAttemptId, worktreeId, tabId, snapshot: layout } | ||
| lastAttempt = attempt | ||
| void updateWebRuntimePaneLayout({ | ||
| worktreeId, | ||
| tabId, | ||
| root: layout.root, | ||
| expandedLeafId: layout.expandedLeafId, | ||
| ...(layout.titlesByLeafId ? { titlesByLeafId: layout.titlesByLeafId } : {}) | ||
| }).then((updated) => { | ||
| // Why: a disconnected or timed-out push carried no information, so the next persist must retry it. | ||
| if (!updated && lastAttempt?.id === attempt.id) { | ||
| lastAttempt = null | ||
| } | ||
| }) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../shared/types' | ||
|
|
||
| function sameStringRecord( | ||
| a: Readonly<Record<string, string>> | undefined, | ||
| b: Readonly<Record<string, string>> | undefined | ||
| ): boolean { | ||
| const left = a ?? {} | ||
| const right = b ?? {} | ||
| const leftKeys = Object.keys(left) | ||
| const rightKeys = Object.keys(right) | ||
| return ( | ||
| leftKeys.length === rightKeys.length && | ||
| leftKeys.every( | ||
| (key) => Object.prototype.hasOwnProperty.call(right, key) && left[key] === right[key] | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| export function terminalLayoutNodeEqual( | ||
| a: TerminalPaneLayoutNode | null | undefined, | ||
| b: TerminalPaneLayoutNode | null | undefined | ||
| ): boolean { | ||
| if (!a || !b) { | ||
| return !a && !b | ||
| } | ||
| if (a.type !== b.type) { | ||
| return false | ||
| } | ||
| if (a.type === 'leaf') { | ||
| return b.type === 'leaf' && a.leafId === b.leafId | ||
| } | ||
| return ( | ||
| b.type === 'split' && | ||
| a.direction === b.direction && | ||
| a.ratio === b.ratio && | ||
| terminalLayoutNodeEqual(a.first, b.first) && | ||
| terminalLayoutNodeEqual(a.second, b.second) | ||
| ) | ||
| } | ||
|
|
||
| /** Structural equality over every persisted layout field; drives store/IPC write bailouts. */ | ||
| export function terminalLayoutEqual( | ||
| a: TerminalLayoutSnapshot | undefined, | ||
| b: TerminalLayoutSnapshot | ||
| ): boolean { | ||
| return ( | ||
| terminalLayoutNodeEqual(a?.root, b.root) && | ||
| (a?.activeLeafId ?? null) === b.activeLeafId && | ||
| (a?.expandedLeafId ?? null) === b.expandedLeafId && | ||
| sameStringRecord(a?.ptyIdsByLeafId, b.ptyIdsByLeafId) && | ||
| sameStringRecord(a?.buffersByLeafId, b.buffersByLeafId) && | ||
| sameStringRecord(a?.scrollbackRefsByLeafId, b.scrollbackRefsByLeafId) && | ||
| sameStringRecord(a?.titlesByLeafId, b.titlesByLeafId) | ||
| ) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win
Normalize the default split ratio before comparison.
Line 35 treats
undefinedand0.5as different values. The shared type defines an absentratioas0.5. A host snapshot that omits the default ratio will bypass store and IPC identity bailouts when the renderer writes0.5.Proposed fix
Add coverage for an omitted ratio versus
0.5.📝 Committable suggestion