mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Activate terminal splits before inherited CWD resolution (#17601)
* perf(terminal): activate splits before cwd resolution * test(terminal): prove split focus before cwd publish * fix(terminal): release stale split cwd fence * test(terminal): add visible split activation latency benchmark * docs(reliability): clarify split benchmark provenance * fix: preserve deferred split handoffs across remounts * fix: fence late deferred split closes * docs(reliability): record exact split benchmark runs * test(reliability): fail benchmark on artifact write errors * test(reliability): attribute split activation phases * docs(reliability): record schema-v2 split benchmark * refactor(terminal): collapse duplicated split-handoff and write-queue paths - Drop the discardDeferredSplitPaneHandoff alias for its identical clear twin. - Fold the deferred-cwd resolve/reject settle handlers into one applier. - Extract settlePaneCwdDeferredSpawn for the repeated read-clear-write pattern. - Share one head-index FIFO primitive between the ordinary and reply queues. * fix(terminal): stop retaining a promise reaction per acknowledged write Racing every accepted write against one queue-lifetime cancel promise kept a reaction record alive until that promise settled: 200k acknowledged writes retained 88.6MB, now 0.1MB. Give each in-flight write its own cancel, and split the shared FIFO primitive into its own module. Also sanitize the split-latency benchmark report at its single serialization point so shared artifacts no longer carry the machine-local repo path or unbounded cleanup error text. * fix(terminal): settle deferred split input when the spawn is abandoned An abandoned deferred spawn returns before transport.connect(), so nothing drained the pre-connect buffer: sendInputAccepted's promise never settled and a paste into that pane hung forever. Clear the buffer on the abandon fence. Also re-derive the pre-connect retention cap from the clipboard-paste ceiling rather than the 16MB single-write ceiling; it is held twice per pane across up to 64 deferred splits, so 5.59M code units guarded the wrong thing. * fix(terminal): release the deferred cwd fence on a rejected reattach A daemon createOrAttach can turn an apparent fresh spawn into a reattach; when that reattach is refused the spawn ends with deferredSplitSpawn/pendingCwd still set, permanently arming the pre-bind detach refusal. The release no-ops when a PTY did bind, so it only fires where the fence would otherwise leak. The stale-generation return above is deliberately left alone: a newer connect already owns the pane there, and the fence is not generation-scoped.
This commit is contained in:
+295
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-08-23",
|
||||
"updatedAt": "2026-08-31",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -5722,6 +5722,284 @@
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if ownership cardinality flakes, duplicate replay reaches a second renderer, active metadata or authority moves to the wrong leaf, deep normalization regresses, or a supported provider bypasses normalization."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.split-activation-ordering",
|
||||
"title": "Terminal splits activate before inherited-CWD lookup settles",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-renderer-lifecycle",
|
||||
"layer": "renderer-unit-and-local-transport",
|
||||
"surfaces": [
|
||||
"terminal pane split",
|
||||
"inherited working directory",
|
||||
"pre-connect terminal input",
|
||||
"split close cleanup",
|
||||
"pre-bind pane detach"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "local-daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "remote-runtime"],
|
||||
"coverageNotes": "Deterministic renderer contracts hold CWD resolution behind an explicit promise, require the new pane to be created synchronously, and prove that close cancels the pending connection. A module-level stable-pane-key handoff preserves the exact CWD promise and bounded pre-connect input across whole-tab remounts, including a tab rehome between worktree buckets; stale owners are fenced, concrete PTY bind and definitive spawn failure clear the record, and explicit pane close discards it. Detach contracts reject cwd-pending and cwd-resolved deferred splits before PTY bind without mutation, carry resolved cwd for other unbound panes, and preserve persisted or live PTY handoff. Local IPC transport contracts exercise the real bounded pre-connect buffer and one live input FIFO across seeded and newly typed ordinary, acknowledged, and immediate writes, concurrent flushes, in-flight teardown, late spawn success or failure, attach failure, failed spawn, same-id reuse, stale-spawn retirement ownership, destroy, and mutable recovery metadata. The handoff registry is capped at 64 records for 15 seconds and shares the existing 1,024-entry/conservative UTF-16 input ceilings. A mocked direct-SSH authority-rotation contract proves a rejected stale spawn releases its deferred-CWD fence. The schema-v2 headful Electron benchmark records exact revision identity and attributes CWD request/settlement, PTY spawn request/result, bind, fixture unlock request/IPC write, fixture readiness, input, and first echo across 3 warmups and 20 measured cold-CWD cycles, requiring a distinct child PTY and observed child pty:exit before the next cycle. Remote-runtime coverage proves delegation remains host-owned; its host-delegated split path does not consume the local pre-connect input options, so remote-runtime input-remount replay and physical local-daemon, SSH, WSL, Linux, Windows, and folder-workspace latency journeys remain gaps.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/commit/572ed1a8882"],
|
||||
"invariant": "A terminal split creates and activates its renderer pane before an inherited-CWD lookup settles, starts its PTY only after the resolved directory is available, and cannot be externally detached while that deferred spawn remains unbound. A whole-tab remount or worktree rehome preserves the same stable pane's CWD promise and admitted local pre-connect bytes in order until a successor binds or the intent is definitively abandoned; stale owners cannot append or clear the successor's record. Other unbound panes preserve resolved cwd as startupCwd when detached. Bounded pre-connect input and later live local input share byte order, and teardown settles acknowledged writes without creating or rebinding a stale PTY. A disconnected or detached pending connect cannot bind its late fresh spawn, report its late failure through current callbacks, or ID-retire a newer same-ID owner; rejecting a stale direct-SSH spawn also releases the matching deferred-CWD fence. Natural exit cannot deliver queued work into a reused PTY id. Bound and remote-runtime splits remain owned by their execution host.",
|
||||
"oracle": "Hold CWD resolution behind a controllable promise, invoke the production split path, and require manager.splitPane plus split telemetry before resolving it. Before PTY bind, require both cwd-pending and cwd-resolved deferred detach attempts to return null without layout, pane, tab, ownership, or focus mutation; separately require resolved cwd on an allowed unbound detach and unchanged persisted/live PTY adoption. Exercise the stable-pane handoff registry through repeated remounts and a worktree rehome, requiring the identical CWD promise, ordered ordinary/acknowledged/immediate seed replay, stale-owner fencing, 64-record/15-second bounds, and discard on bind, failure, or explicit close. Rotate a mocked direct-SSH authority while its delayed spawn is in flight, reject and disconnect the stale PTY claim, then require exactly one deferred-CWD cleanup when the delayed connect settles. At the local IPC PTY boundary, require zero connect calls while pending, the resolved CWD in spawn and local recovery metadata, one shared FIFO across seeded/new pre-connect and live ordinary/acknowledged/immediate input, prompt predecessor acknowledged-promise settlement on teardown, retirement of an unowned late fresh spawn, preservation of a newer same-ID owner, and zero stale delivery after failure, close, destroy, detach, natural exit, or same-id reuse. Capture callback exceptions must not change admission results. In visible Electron, press the real split shortcut for 3 warmup cycles, then after a cold inherited-CWD interval for each of 20 measured cycles, require an exact clean revision identity, complete focus/CWD/spawn/bind/fixture/input/echo attribution, distinct child PTYs, pane count, and child exits for every cycle; a timed-out, missing-event, or cleanup-aborted run must publish no headline latency and fail.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts tests/e2e/terminal-split-activation-latency-main-probe.ts tests/e2e/terminal-split-activation-latency-phases.ts tests/e2e/terminal-split-activation-latency-report.unit.test.ts --reporter=dot",
|
||||
// Historical evidence record retained so its seven-file command remains auditable.
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts --reporter=dot",
|
||||
// Historical evidence record retained so its nine-file command remains auditable.
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts --reporter=dot",
|
||||
// Historical schema-v1 evidence records only; their labels do not verify the checkout or harness.
|
||||
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=baseline-df14d1a2983d8339e788d0e521f1c4affd9c6d5f-headful-run1 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-baseline-df14d1a2983d8339e788d0e521f1c4affd9c6d5f-headful-run1.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-d453ffcdb704764daced1b2917fddee7224389f0-headful-run2 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-candidate-d453ffcdb704764daced1b2917fddee7224389f0-headful-run2.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
// Exact-HEAD schema-v1 evidence records use the committed harness but predate revision metadata.
|
||||
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-962faacec8c-headful-current ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-962faacec8c-headful.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-962faacec8c-headful-run2 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-962faacec8c-headful-run2.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
// Schema-v2 evidence embeds the exact checkout identity and clean/dirty state.
|
||||
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-073e6c7b0eb-headful-clean ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-073e6c7b0eb-headful-clean.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-split-close.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts",
|
||||
"tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts",
|
||||
"tests/e2e/terminal-split-activation-latency-main-probe.ts",
|
||||
"tests/e2e/terminal-split-activation-latency-phases.ts",
|
||||
"tests/e2e/terminal-split-activation-latency-report.unit.test.ts",
|
||||
"tests/e2e/terminal-split-activation-latency.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts",
|
||||
"assertions": [
|
||||
"creates and records the split before pending CWD resolution",
|
||||
"passes the resolved CWD promise without reviving a stale manager",
|
||||
"rapid nested splits reuse one pending CWD lookup",
|
||||
"keeps remote-runtime split ownership on its execution host"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/pane-split-close.test.ts",
|
||||
"assertions": ["focuses the new pane before publishing an unresolved CWD spawn hint"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts",
|
||||
"assertions": [
|
||||
"preserves a deferred split fence when OSC 7 updates cwd",
|
||||
"clears a settled deferred entry only for matching promise identity",
|
||||
"keeps a newer deferred lookup when an older cleanup callback arrives"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts",
|
||||
"assertions": [
|
||||
"starts no PTY connection before inherited CWD resolves",
|
||||
"applies the resolved directory to transport options",
|
||||
"disposing the split before resolution cancels the pending spawn",
|
||||
"invokes deferred-CWD cleanup exactly once after an authority-rotated direct-SSH spawn is disconnected and its delayed connect settles"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
|
||||
"assertions": [
|
||||
"rejects a deferred split while inherited CWD is pending without mutation",
|
||||
"continues rejecting after CWD resolves until PTY bind",
|
||||
"carries resolved CWD as startupCwd for an allowed unbound detach",
|
||||
"preserves persisted and live remote PTY detach handoff"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts",
|
||||
"assertions": [
|
||||
"concurrent flush calls share one worker and preserve mixed input order",
|
||||
"clear settles an in-flight acknowledged write before its late resolve or reject",
|
||||
"in-flight acknowledged input remains charged to entry and code-unit caps"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts",
|
||||
"assertions": [
|
||||
"ordinary, acknowledged, and immediate pre-connect input flushes in byte order",
|
||||
"pending acknowledged input settles on connect, destroy, and spawn failure",
|
||||
"disconnect, destroy, and natural exit cancel an in-flight acknowledged write without blocking connect",
|
||||
"live acknowledged input blocks later ordinary and immediate writes at its invocation position",
|
||||
"the preconnect-to-live transition preserves the same input FIFO",
|
||||
"disconnect and detach retire a late fresh spawn and suppress late failures before they reach current callbacks",
|
||||
"natural exit fences queued ordinary and acknowledged chunks across same-id reuse",
|
||||
"buffered exit and attach failure clear retained input",
|
||||
"ordinary and acknowledged write failures drop later input without leaving promises pending",
|
||||
"entry and code-unit ceilings bound pre-connect input retention",
|
||||
"local recovery metadata observes the resolved split CWD",
|
||||
"a stale fresh-spawn completion cannot retire a newer same-ID owner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts",
|
||||
"assertions": [
|
||||
"hands the same cwd promise and buffered input to a remounted leaf in order",
|
||||
"keeps input across repeated remounts and fences stale owners",
|
||||
"releases an unmounted owner without dropping its pending handoff",
|
||||
"retains input within the shared preconnect entry and code-unit caps",
|
||||
"evicts the oldest handoff when the record cap is reached",
|
||||
"expires an abandoned handoff after the bounded remount window"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts",
|
||||
"assertions": [
|
||||
"writes a passing benchmark report to the requested artifact path",
|
||||
"fails when the benchmark artifact path cannot be written"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-split-activation-latency-main-probe.ts",
|
||||
"assertions": [
|
||||
"attributes CWD and PTY spawn request/settlement events to the source and child PTYs",
|
||||
"captures the fixture unlock carriage return on both ordinary and acknowledged IPC channels",
|
||||
"restores the intercepted IPC handlers and listener when the probe is disposed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-split-activation-latency-phases.ts",
|
||||
"assertions": [
|
||||
"merges main-process events by operation and PTY identity without cross-cycle attribution",
|
||||
"requires every activation, fixture, input, echo, pane, PTY, and cleanup observation for success",
|
||||
"reports each attributed phase distribution with non-negative cross-clock durations"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-split-activation-latency-report.unit.test.ts",
|
||||
"assertions": [
|
||||
"attributes main-process phases to the matching source and child PTYs",
|
||||
"embeds schema-v2 revision identity and summarizes the attributed phases",
|
||||
"invalidates a sample when the actual fixture-unlock IPC write is missing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-split-activation-latency.spec.ts",
|
||||
"assertions": [
|
||||
"requires a visible BrowserWindow and visible document before sampling",
|
||||
"records schema-v2 revision identity plus attributed CWD, spawn, bind, fixture-ready, input, and echo phases",
|
||||
"records 3 warmups, then 20 measured real-shortcut cycles after cold inherited-CWD intervals",
|
||||
"requires every split to focus, bind a PTY distinct from its source, and echo immediate input",
|
||||
"observes each closed child PTY exit before starting the next cycle",
|
||||
"publishes headline latency only for a fully successful 3-warmup/20-measured run"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts --reporter=dot",
|
||||
"durationSeconds": 56.59,
|
||||
"summary": "Seven focused files and 78 tests passed. Vitest reported 49.92 seconds and the measured wall time was 56.59 seconds; coverage includes split creation and focus ordering, nested CWD lineage, promise-identity and SSH authority-rotation cleanup fencing, full pre-bind detach fencing, resolved-CWD detach handoff, close-cancellation, single-FIFO ordering, late-spawn retirement and error suppression, preservation of a newer same-ID owner, generation fencing, in-flight settlement across explicit teardown and natural exit, attach cleanup, bounded retention, and existing input-write contracts."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-31",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts --reporter=dot",
|
||||
"durationSeconds": 44.43,
|
||||
"summary": "Nine focused files and 96 tests passed. Vitest reported 37.78 seconds and measured wall time was 44.43 seconds; the run adds stable-pane CWD/input handoff, repeated-remount stale-owner fencing, bounded 64-record/15-second retention, seeded-input caps, ordered ordinary/acknowledged/immediate replay, predecessor acknowledged-promise settlement, capture-callback failure containment, and benchmark-artifact write-failure coverage to the existing split, detach, CWD, and local transport contracts."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-31",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts tests/e2e/terminal-split-activation-latency-main-probe.ts tests/e2e/terminal-split-activation-latency-phases.ts tests/e2e/terminal-split-activation-latency-report.unit.test.ts --reporter=dot",
|
||||
"durationSeconds": 4.14,
|
||||
"summary": "The updated twelve-path focused command passed 99 tests (10 runnable test files plus 2 benchmark support modules), including schema-v2 main-process phase attribution, report revision identity, fixture IPC-write validation, stable-pane handoff, ordered pre-connect/live input, cleanup, detach, failure, and same-ID ownership contracts."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=baseline-df14d1a2983d8339e788d0e521f1c4affd9c6d5f-headful-run1 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-baseline-df14d1a2983d8339e788d0e521f1c4affd9c6d5f-headful-run1.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"durationSeconds": 72,
|
||||
"summary": "At baseline df14d1a2983d8339e788d0e521f1c4affd9c6d5f, the visible BrowserWindow and document completed 3/3 warmups followed by 20/20 measured cold-CWD cycles with every event present, distinct child PTYs, and observed child exits. Shortcut-to-focus p50/p95/max was 65.7/88.7/102.6 ms, PTY bind was 122.8/154.0/159.7 ms, and first echo was 203.8/264.2/332.7 ms. Artifact SHA-256: 6d860cd0cd210f55f2349a197318248af488042117b20c08b6831160950c3277."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-d453ffcdb704764daced1b2917fddee7224389f0-headful-run2 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-candidate-d453ffcdb704764daced1b2917fddee7224389f0-headful-run2.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"durationSeconds": 72,
|
||||
"summary": "At candidate d453ffcdb704764daced1b2917fddee7224389f0, the visible BrowserWindow and document completed 3/3 warmups followed by 20/20 measured cold-CWD cycles with every event present, distinct child PTYs, and observed child exits. Shortcut-to-focus p50/p95/max was 12.8/14.4/16.5 ms, PTY bind was 177.0/316.1/333.3 ms, and first echo was 268.6/627.4/710.7 ms. Artifact SHA-256: 9aef7fa842c732eb74f0066a77b2a0336e8f956a06ca61387f9999d6e76213cc."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-31",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-962faacec8c-headful-current ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-962faacec8c-headful.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"durationSeconds": 72,
|
||||
"summary": "At exact HEAD 962faacec8c, the visible BrowserWindow and document completed 3/3 warmups followed by 20/20 measured cold-CWD cycles with every event present, distinct child PTYs, and observed child exits. Shortcut-to-focus p50/p95/max was 12.6/13.7/13.7 ms, PTY bind was 140.5/399.1/464.1 ms, and first echo was 192.0/519.3/2343.1 ms. Artifact SHA-256: 875e9d37dc711472a81438e4bbdbc8cbc7aada8c961d14195c024d8da350b9e2."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-31",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-962faacec8c-headful-run2 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-962faacec8c-headful-run2.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"durationSeconds": 72,
|
||||
"summary": "At exact HEAD 962faacec8c, the visible BrowserWindow and document completed 3/3 warmups followed by 20/20 measured cold-CWD cycles with every event present, distinct child PTYs, and observed child exits. Shortcut-to-focus p50/p95/max was 12.8/14.0/14.3 ms, PTY bind was 236.5/654.0/687.3 ms, and first echo was 566.8/1191.5/1219.7 ms. Artifact SHA-256: 4f66b93e5c936ade05f880010f4ec027385d5c89893430169af91c7fdfbe1d06."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-31",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-073e6c7b0eb-headful-clean ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-073e6c7b0eb-headful-clean.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"durationSeconds": 87.6,
|
||||
"summary": "At exact clean HEAD 073e6c7b0eb1c0ccbb115db1528b641901997c73, the schema-v2 artifact records dirty=false, a visible BrowserWindow/document, and 3/3 warmups plus 20/20 measured cycles with every missing-event counter at zero, distinct child PTYs, and observed child exits. Measured focus p50/p95/max was 12.0/13.6/14.7 ms; attributed CWD lookup was 40/97/103 ms, CWD-settle to spawn request 1/4/4 ms, spawn request to result 48/120/122 ms, and spawn result to bind 2.1/2.5/2.5 ms. Fixture unlock request to IPC write was 0.5/0.8/0.9 ms, IPC write to fixture-ready parse was 35.1/87.4/141.8 ms, and input to first echo was 1.3/3.5/3.9 ms. Total shortcut-to-bind was 113.5/161.3/162.7 ms and shortcut-to-first-echo was 158.5/240.7/291.2 ms. Artifact SHA-256: 1e7ff9e658b717056273d64ecdf662cc6b5776bb6dae1827ed213b7647e4a5fb."
|
||||
}
|
||||
],
|
||||
"evidenceProcedure": "Run the benchmark spec in a visible macOS Electron project from a clean primary worktree, complete 3 warmups followed by 20 measured cold-CWD cycles, require zero missing events and successful cleanup, save the schema-v2 JSON report, and record its SHA-256 plus embedded revision identity. The four older records are schema-v1 historical audit records whose labels do not verify the checkout or include phase attribution; the clean schema-v2 record is the current candidate evidence. A paired baseline/final rerun with one revision-verifying harness remains required before promotion or a readiness comparison claim.",
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 240,
|
||||
"scope": "the full listed gate command set: one current twelve-path focused invocation (ten runnable test files plus two benchmark support modules), one historical nine-file unit invocation, one historical seven-file unit invocation, and five opt-in 3-warmup/20-measured visible Electron benchmark invocations (four schema-v1 historical records plus one clean schema-v2 record)"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "The focused promise-barrier, remount-handoff, benchmark-artifact, and schema-v2 attribution suite passes locally in 99 tests; the clean visible benchmark passes 3/3 warmups and 20/20 measured cycles with zero missing events. Its first attempt hit a transient warmup cleanup-dialog click timeout, then the exact command passed on retry without a launch, profile, or port workaround. Routed CI and soak history have not started. Historical benchmark labels do not verify the product checkout or embed revision identity."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Before the production seam landed, the split assertion failed with zero manager calls while CWD was pending, and the transport assertion rejected the first pre-connect input. Before the detach fence, a deferred split could be removed after CWD resolved but before PTY bind, dropping its pre-connect input. Before the single-flight hardening, the concurrent-flush oracle delivered ordinary input before the earlier acknowledged write and clear left the in-flight promise pending. Before stale-spawn ownership fencing, the combined deferred-connect and newer same-ID attach fixture called kill on the current PTY. An intentional one-line revert of deferred-CWD cleanup in the stale direct-SSH claim branch failed its focused callback assertion with zero calls instead of one; restoring it passed the prior focused tests. The remount-handoff, transport, artifact-write, and schema-v2 attribution regressions are green in the 99-test twelve-path run, but isolated intentional-revert evidence for each cleanup branch remains outstanding."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Pane creation and focus add no timer, polling, provider inventory, or subprocess work. CWD resolution remains one existing bounded request off the visible activation path, and detach admission adds only bounded map and record lookups. The remount handoff adds one module-level map lookup per pane lifecycle, a 64-record cap, and a 15-second expiry; it retains no unbounded payload. Pre-connect input, including an in-flight acknowledged write and remount seed replay, is capped at 1,024 entries and a conservative UTF-16 ceiling derived from the existing terminal-input byte limit, drains through one worker in order, and clears on teardown or failed connect. The historical schema-v1 same-mode pair recorded shortcut-to-focus p50/p95/max changing from 65.7/88.7/102.6 ms to 12.8/14.4/16.5 ms; those labels do not embed revision identity, so the comparison is directional evidence only. The clean schema-v2 candidate attributes focus at 12.0/13.6/14.7 ms while CWD lookup takes 40/97/103 ms and spawn request-to-result takes 48/120/122 ms, demonstrating that provider/process startup follows activation rather than blocking it. In that clean run, shortcut-to-bind is 113.5/161.3/162.7 ms, fixture IPC-write-to-ready is 35.1/87.4/141.8 ms, and input-to-echo is 1.3/3.5/3.9 ms; these readiness phases are diagnostic, one-host descriptive measurements, and no clean schema-v2 baseline exists to support a readiness improvement or regression claim. The n=20 empirical p95 values are descriptive, are not a distribution guarantee, and are not CI-enforced."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Record complete red/green evidence for close, remount/rehome handoff, mixed-input ordering, metadata, and failure cleanup.",
|
||||
"Collect 100 consecutive focused CI passes or 14 days without an unexplained flake.",
|
||||
"Run the committed real-shortcut Electron benchmark in routed CI or soak before enforcing a latency budget.",
|
||||
"Collect physical local-daemon, SSH or WSL plus Linux, Windows, and folder-workspace evidence before claiming provider-complete coverage."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The clean schema-v2 candidate run and the historical schema-v1 comparison records ran on one Apple-silicon macOS host with a synthetic POSIX echo shell and a git-backed workspace; their n=20 empirical p95 values are descriptive and not CI-enforced.",
|
||||
"No physical local-daemon, SSH, WSL, Linux, Windows, or folder-workspace latency journey has run; the synthetic fixture is currently skipped on Windows because it requires a POSIX shell.",
|
||||
"Remote-runtime split creation remains host-delegated and its transport does not consume the local pre-connect seed/capture options; CWD handoff is covered, but remote-runtime pre-connect input replay has no implementation or evidence.",
|
||||
"The four stored schema-v1 artifacts predate the final harness attribution/reporting and do not embed revision identity; the clean schema-v2 candidate artifact is revision-verified, but both product revisions still need a paired schema-v2 rerun with one committed harness before promotion.",
|
||||
"No forced-failure visible benchmark artifact has been recorded; the focused artifact-write and missing-event report contracts verify local failure handling, while failure-report serialization remains unverified by a full visible run.",
|
||||
"No clean schema-v2 baseline phase artifact exists, so the attributed CWD, spawn, fixture-ready, bind, and echo timings diagnose where time is spent but do not establish a shell-readiness improvement or regression."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if pane activation waits on CWD, a deferred split can detach before PTY bind, a remount or rehome loses its stable CWD/input handoff, stale owners mutate a successor record, detached cwd is lost, input reorders or remains pending after cleanup, a closed pane can spawn, stale retirement kills a newer same-ID owner, remote-runtime delegation creates a competing local pane, or the focused suite flakes without an identified product or harness cause."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.kill-all-surface-cleanup",
|
||||
"title": "Kill all sessions removes only the confirmed terminal surfaces and current bindings",
|
||||
@@ -8194,9 +8472,7 @@
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/persisted-session-production-upgrade.spec.ts",
|
||||
"assertions": [
|
||||
"upgrades a legacy daemon session and keeps it stable after relaunch"
|
||||
]
|
||||
"assertions": ["upgrades a legacy daemon session and keeps it stable after relaunch"]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
@@ -13970,14 +14246,14 @@
|
||||
"providers": ["local", "daemon", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh"],
|
||||
"coverageNotes": "Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, one-reply-per-write delivery for OSC, DA1, and CPR replies, real xterm OSC reply generation, drain-failure containment, and clear/reuse generation fencing. Remote-runtime tests preserve separate query-reply writes across pending input, async validation, and viewport-claim buffering. Host-contract tests prove a later DA1/CPR reply cannot overtake a deferred OSC reply, including a coalesced legacy-client payload. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves an upstream-node-pty Linux relay keeps OSC/DA1 replies out of the next fish child's stdin. No live daemon, paired-runtime, WSL, physical Linux/Windows client, or binary mixed-version run is registered.",
|
||||
"coverageNotes": "Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, acknowledged-write FIFO barriers, single-worker drain reentrancy, one-reply-per-write delivery for OSC, DA1, and CPR replies, real xterm OSC reply generation, drain-failure containment, teardown settlement, and clear/reuse generation fencing. Remote-runtime tests preserve separate query-reply writes across pending input, async validation, and viewport-claim buffering. Host-contract tests prove a later DA1/CPR reply cannot overtake a deferred OSC reply, including a coalesced legacy-client payload. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves an upstream-node-pty Linux relay keeps OSC/DA1 replies out of the next fish child's stdin. No live daemon, paired-runtime, WSL, physical Linux/Windows client, or binary mixed-version run is registered.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/13137",
|
||||
"https://github.com/stablyai/orca/issues/7329",
|
||||
"https://github.com/stablyai/orca/issues/13892"
|
||||
],
|
||||
"invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending terminal query replies and 4096 UTF-16 code units. Every retained reply reaches the provider as one atomic write, and the host writes each reply the moment it accepts it, so replies reach the PTY in the order they were produced with no queue that could reorder them. A reply's own echo is contained on the output side by projecting its known echo shapes; the ESC-initial verbatim shape is matched only when complete, never held as a partial, so a query torn at its own ESC is still answered. Overflow removes only the oldest query replies, never ordinary input except the documented modified-F3/CPR byte collision, and drain failures cannot clear a newer queue generation.",
|
||||
"oracle": "Synchronously enqueue separate 10,000-entry OSC and DA1 reply floods before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. At the host boundary, defer an OSC reply and assert that separate or legacy-coalesced DA1/CPR replies flush after it in observed query order. At the remote-runtime boundary, preserve separate writes around pending ordinary input, async validation, and viewport-claim buffering. Repeat behind 10,000 ordinary inputs and exercise the text ceiling, real xterm generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
|
||||
"invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending terminal query replies and 4096 UTF-16 code units. Every retained reply reaches the provider as one atomic write, and ordinary, acknowledged, and reply input share one invocation-ordered FIFO so no later write overtakes an acknowledged write. Reentrant write callbacks cannot start a second drain worker or strand input admitted after clear/reuse. A reply's own echo is contained on the output side by projecting its known echo shapes; the ESC-initial verbatim shape is matched only when complete, never held as a partial, so a query torn at its own ESC is still answered. Overflow removes only the oldest query replies, never ordinary input except the documented modified-F3/CPR byte collision, and failure or teardown cannot clear a newer queue generation or strand an acknowledged promise.",
|
||||
"oracle": "Synchronously enqueue separate 10,000-entry OSC and DA1 reply floods before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. Stall an acknowledged write between earlier and later ordinary/reply input, then require teardown to settle it and same-id reuse to receive no stale tail. Reenter the queue synchronously from an acknowledged write with both enqueue and clear/reuse, requiring one drain and fresh input delivery only after the stale acknowledged write settles false. At the host boundary, defer an OSC reply and assert that separate or legacy-coalesced DA1/CPR replies flush after it in observed query order. At the remote-runtime boundary, preserve separate writes around pending ordinary input, async validation, and viewport-claim buffering. Repeat behind 10,000 ordinary inputs and exercise the text ceiling, real xterm generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts",
|
||||
@@ -14009,14 +14285,20 @@
|
||||
"real xterm OSC 10/11 query handlers remain subject to the same retention ceiling",
|
||||
"retained OSC, DA1, and CPR replies stay one provider write each and both OSC and DA1 floods remain bounded",
|
||||
"provider-write and yield failures settle without unhandled rejection, repeated same-generation admission, or stale-generation clearing",
|
||||
"clear releases saturated reply accounting and fences in-flight validation before later input"
|
||||
"clear releases saturated reply accounting and fences in-flight validation before later input",
|
||||
"acknowledged input is serialized between earlier and later ordinary/reply writes",
|
||||
"clear settles active and pending acknowledged input before same-id queue reuse",
|
||||
"reentrant enqueue cannot start a second drain worker past an unacknowledged write",
|
||||
"reentrant clear captures the stale cancellation and continues draining fresh input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts",
|
||||
"assertions": [
|
||||
"sendInputImmediate applies the reply ceiling while sendInput preserves a reply-shaped ordinary payload in exact IPC write order",
|
||||
"a thrown renderer write triggers one owning-transport recovery callback and rejects later input in that queue generation"
|
||||
"a thrown renderer write triggers one owning-transport recovery callback and rejects later input in that queue generation",
|
||||
"live and preconnect acknowledged writes remain FIFO barriers for later ordinary and immediate input",
|
||||
"disconnect, detach, and natural exit settle acknowledged writes and fence same-id stale chunks"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -14072,13 +14354,13 @@
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-25",
|
||||
"date": "2026-08-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1.05,
|
||||
"summary": "Five files and 92 tests passed, including the live-pty echo-shape transcript, including explicit IPC reply-source routing, the 10,000-reply count ceiling, text ceiling, 10,000-entry ordinary backlog preservation, real xterm OSC query flood, single-shot drain-failure recovery, one-reply-per-write echo containment, and clear/reuse generation fencing."
|
||||
"durationSeconds": 15,
|
||||
"summary": "Five files and 149 tests passed in 15.16 seconds, including explicit IPC reply-source routing, the 10,000-reply count and text ceilings, 10,000-entry ordinary backlog preservation, acknowledged-write FIFO barriers, synchronous reentrancy fencing, prompt teardown settlement, same-id generation fencing, real xterm OSC query floods, single-shot drain-failure recovery, and one-reply-per-write echo containment."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-09",
|
||||
@@ -14127,7 +14409,7 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Without the branch's admission cap, the 10,000-reply fixture writes all replies before the trailing keystroke. Before the source-routing repair, the queue had no API capable of distinguishing reply-shaped ordinary input; before failure containment, a thrown provider write rejected waitForDrain and Vitest recorded an unhandled rejection; the first containment pass retried a failed generation and invoked recovery twice; before generation fencing, a rejected stale yield cleared fresh input. No saved intentional-break artifact is attached yet."
|
||||
"evidence": "Without the branch's admission cap, the 10,000-reply fixture writes all replies before the trailing keystroke. Before the source-routing repair, the queue had no API capable of distinguishing reply-shaped ordinary input; before failure containment, a thrown provider write rejected waitForDrain and Vitest recorded an unhandled rejection; the first containment pass retried a failed generation and invoked recovery twice; before generation fencing, a rejected stale yield cleared fresh input. Before reentrancy fencing, a synchronous accepted-write callback started a second drain that falsely accepted the pending write; clear/reuse also captured the replacement generation's cancellation and stranded fresh input. No saved intentional-break artifact is attached yet."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
|
||||
@@ -95,6 +95,66 @@ describe('benchmark artifact comparison', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('compares terminal split headline metrics in milliseconds', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'split-baseline.json', {
|
||||
label: 'split baseline',
|
||||
headlineMs: {
|
||||
shortcutToFocusP50: 284.2,
|
||||
shortcutToFocusP95: 676.3
|
||||
}
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'split-candidate.json', {
|
||||
label: 'split candidate',
|
||||
headlineMs: {
|
||||
shortcutToFocusP50: 12.7,
|
||||
shortcutToFocusP95: 13.7
|
||||
}
|
||||
})
|
||||
|
||||
const comparison = comparePaths(baselinePath, candidatePath)
|
||||
|
||||
expect(comparison.baseline.kind).toBe('terminal-split-activation')
|
||||
expect(comparison.metrics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: 'shortcutToFocusP50',
|
||||
unit: 'ms',
|
||||
baseline: 284.2,
|
||||
candidate: 12.7,
|
||||
status: 'improved'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'shortcutToFocusP95',
|
||||
unit: 'ms',
|
||||
baseline: 676.3,
|
||||
candidate: 13.7,
|
||||
status: 'improved'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid benchmark artifacts before comparing partial metrics', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'split-invalid.json', {
|
||||
label: 'invalid split',
|
||||
status: 'failed',
|
||||
valid: false,
|
||||
headlineMs: { shortcutToFocusP50: 0 }
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'split-valid.json', {
|
||||
label: 'valid split',
|
||||
status: 'passed',
|
||||
valid: true,
|
||||
headlineMs: { shortcutToFocusP50: 10 }
|
||||
})
|
||||
|
||||
expect(() => comparePaths(baselinePath, candidatePath)).toThrow(
|
||||
'split-invalid.json: benchmark artifact is marked invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('compares numeric Playwright annotation metrics and omits metadata fields', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'baseline-playwright.json', {
|
||||
|
||||
@@ -74,6 +74,9 @@ export function readBenchmarkArtifact(path) {
|
||||
}
|
||||
|
||||
export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifact(path)) {
|
||||
if (artifact?.valid === false || artifact?.status === 'failed') {
|
||||
throw new Error(`${path}: benchmark artifact is marked invalid`)
|
||||
}
|
||||
if (artifact?.summaryMedianMs != null) {
|
||||
return normalizeNumericObject(path, artifact, 'startup', artifact.summaryMedianMs, () => 'ms')
|
||||
}
|
||||
@@ -82,6 +85,15 @@ export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifac
|
||||
key.endsWith('Count') || key.endsWith('After') ? 'count' : 'ms'
|
||||
)
|
||||
}
|
||||
if (artifact?.headlineMs != null) {
|
||||
return normalizeNumericObject(
|
||||
path,
|
||||
artifact,
|
||||
'terminal-split-activation',
|
||||
artifact.headlineMs,
|
||||
() => 'ms'
|
||||
)
|
||||
}
|
||||
if (artifact?.suites != null) {
|
||||
return normalizePlaywrightArtifact(path, artifact)
|
||||
}
|
||||
@@ -89,7 +101,7 @@ export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifac
|
||||
return normalizeSummaryArtifact(path, artifact)
|
||||
}
|
||||
throw new Error(
|
||||
`${path}: unsupported benchmark artifact; expected summaryMedianMs, summaryMedian, Playwright suites, or top-level summary`
|
||||
`${path}: unsupported benchmark artifact; expected summaryMedianMs, summaryMedian, headlineMs, Playwright suites, or top-level summary`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
} from './pane-title-overlay-rects'
|
||||
import NativeChatView from '../native-chat/NativeChatView'
|
||||
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
|
||||
import type { PaneCwdMap } from './resolve-split-cwd'
|
||||
import { TerminalAgentSessionForkDialog } from './TerminalAgentSessionForkDialog'
|
||||
import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog'
|
||||
import { SessionRestoredBannerPortals } from './SessionRestoredBannerPortals'
|
||||
@@ -345,7 +346,7 @@ function TerminalPane(
|
||||
)
|
||||
const paneTransportsRef = useRef<Map<number, PtyTransport>>(new Map())
|
||||
// Why: per-pane live cwd via OSC 7 for split-pane cwd inheritance; split actions read it at dispatch. See docs/ssh-split-pane-inherit-cwd.md.
|
||||
const paneCwdRef = useRef<Map<number, { cwd: string; confirmed: boolean }>>(new Map())
|
||||
const paneCwdRef = useRef<PaneCwdMap>(new Map())
|
||||
const paneMode2031Ref = useRef<Map<number, boolean>>(new Map())
|
||||
// Why: per-pane mirror of kitty keyboard flags; the keyboard policy reads it to encode Option chords as kitty CSI-u for opted-in TUIs.
|
||||
const paneKittyKeyboardModesRef = useRef<Map<number, TerminalKittyKeyboardModeTracker>>(new Map())
|
||||
@@ -1428,6 +1429,7 @@ function TerminalPane(
|
||||
return false
|
||||
}
|
||||
const fallbackPtyId = paneTransportsRef.current.get(sourcePaneId)?.getPtyId() ?? null
|
||||
const sourcePaneCwd = paneCwdRef.current.get(sourcePaneId)
|
||||
return (
|
||||
detachTerminalPaneToTab({
|
||||
fallbackPtyId,
|
||||
@@ -1435,6 +1437,7 @@ function TerminalPane(
|
||||
manager: managerRef.current,
|
||||
persistLayoutSnapshot,
|
||||
sourcePaneId,
|
||||
...(sourcePaneCwd ? { sourcePaneCwd } : {}),
|
||||
sourceTabId: tabId,
|
||||
targetGroupId: target.groupId,
|
||||
targetIndex: target.insertionIndex,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import {
|
||||
PTY_PRECONNECT_INPUT_MAX_CODE_UNITS,
|
||||
PTY_PRECONNECT_INPUT_MAX_ENTRIES
|
||||
} from './pty-preconnect-input-buffer'
|
||||
import {
|
||||
appendDeferredSplitPaneInput,
|
||||
beginDeferredSplitPaneHandoff,
|
||||
claimDeferredSplitPaneHandoff,
|
||||
clearDeferredSplitPaneHandoff,
|
||||
DEFERRED_SPLIT_PANE_HANDOFF_MAX_RECORDS,
|
||||
DEFERRED_SPLIT_PANE_HANDOFF_TTL_MS,
|
||||
discardDeferredSplitPaneHandoffForKey,
|
||||
discardDeferredSplitPaneHandoffsForTab,
|
||||
getDeferredSplitPaneHandoffCountForTests,
|
||||
releaseDeferredSplitPaneHandoff,
|
||||
resetDeferredSplitPaneHandoffsForTests
|
||||
} from './deferred-split-pane-handoff'
|
||||
|
||||
const LEAF_1 = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_2 = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
describe('deferred split pane handoff', () => {
|
||||
beforeEach(resetDeferredSplitPaneHandoffsForTests)
|
||||
afterEach(resetDeferredSplitPaneHandoffsForTests)
|
||||
|
||||
it('hands the same cwd promise and buffered input to a remounted leaf in order', () => {
|
||||
const key = makePaneKey('tab-1', LEAF_1)
|
||||
const cwdPromise = Promise.resolve('/source/cwd')
|
||||
const initial = beginDeferredSplitPaneHandoff(key, cwdPromise)
|
||||
|
||||
appendDeferredSplitPaneInput(initial, { data: 'typed', kind: 'ordinary' })
|
||||
appendDeferredSplitPaneInput(initial, { data: '\x1b[0n', kind: 'immediate' })
|
||||
appendDeferredSplitPaneInput(initial, { data: '\x03', kind: 'accepted' })
|
||||
|
||||
const remounted = claimDeferredSplitPaneHandoff(key)
|
||||
|
||||
expect(remounted?.cwdPromise).toBe(cwdPromise)
|
||||
expect(remounted?.preconnectInput).toEqual([
|
||||
{ data: 'typed', kind: 'ordinary' },
|
||||
{ data: '\x1b[0n', kind: 'immediate' },
|
||||
{ data: '\x03', kind: 'accepted' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps input across repeated remounts and fences stale owners', () => {
|
||||
const key = makePaneKey('tab-1', LEAF_1)
|
||||
const initial = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd'))
|
||||
appendDeferredSplitPaneInput(initial, { data: 'before-first-remount', kind: 'ordinary' })
|
||||
const firstRemount = claimDeferredSplitPaneHandoff(key)
|
||||
expect(firstRemount).not.toBeNull()
|
||||
|
||||
appendDeferredSplitPaneInput(initial, { data: 'stale-input', kind: 'ordinary' })
|
||||
clearDeferredSplitPaneHandoff(initial)
|
||||
clearDeferredSplitPaneHandoff(initial)
|
||||
appendDeferredSplitPaneInput(firstRemount!.handle, {
|
||||
data: 'before-second-remount',
|
||||
kind: 'ordinary'
|
||||
})
|
||||
|
||||
const secondRemount = claimDeferredSplitPaneHandoff(key)
|
||||
expect(secondRemount?.preconnectInput).toEqual([
|
||||
{ data: 'before-first-remount', kind: 'ordinary' },
|
||||
{ data: 'before-second-remount', kind: 'ordinary' }
|
||||
])
|
||||
|
||||
clearDeferredSplitPaneHandoff(firstRemount!.handle)
|
||||
expect(getDeferredSplitPaneHandoffCountForTests()).toBe(1)
|
||||
clearDeferredSplitPaneHandoff(secondRemount!.handle)
|
||||
expect(getDeferredSplitPaneHandoffCountForTests()).toBe(0)
|
||||
})
|
||||
|
||||
it('releases an unmounted owner without dropping its pending handoff', () => {
|
||||
const key = makePaneKey('tab-1', LEAF_1)
|
||||
const initial = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd'))
|
||||
appendDeferredSplitPaneInput(initial, { data: 'before-unmount', kind: 'ordinary' })
|
||||
|
||||
releaseDeferredSplitPaneHandoff(initial)
|
||||
appendDeferredSplitPaneInput(initial, { data: 'late-stale', kind: 'ordinary' })
|
||||
clearDeferredSplitPaneHandoff(initial)
|
||||
|
||||
expect(claimDeferredSplitPaneHandoff(key)?.preconnectInput).toEqual([
|
||||
{ data: 'before-unmount', kind: 'ordinary' }
|
||||
])
|
||||
})
|
||||
|
||||
it('lets a late close discard a released handoff by its stable pane key', () => {
|
||||
const key = makePaneKey('tab-1', LEAF_1)
|
||||
const owner = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd'))
|
||||
appendDeferredSplitPaneInput(owner, { data: 'must-not-replay', kind: 'ordinary' })
|
||||
|
||||
// Whole-tab cleanup releases the mount-local handle before a stale close callback can run.
|
||||
releaseDeferredSplitPaneHandoff(owner)
|
||||
discardDeferredSplitPaneHandoffForKey(key)
|
||||
|
||||
expect(claimDeferredSplitPaneHandoff(key)).toBeNull()
|
||||
})
|
||||
|
||||
it('clears or discards only the current owner', () => {
|
||||
const clearedKey = makePaneKey('tab-clear', LEAF_1)
|
||||
const cleared = beginDeferredSplitPaneHandoff(clearedKey, Promise.resolve('/clear'))
|
||||
clearDeferredSplitPaneHandoff(cleared)
|
||||
expect(claimDeferredSplitPaneHandoff(clearedKey)).toBeNull()
|
||||
|
||||
const discardedKey = makePaneKey('tab-discard', LEAF_1)
|
||||
const discarded = beginDeferredSplitPaneHandoff(discardedKey, Promise.resolve('/discard'))
|
||||
clearDeferredSplitPaneHandoff(discarded)
|
||||
expect(claimDeferredSplitPaneHandoff(discardedKey)).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a stale record when an authoritative restored PTY wins the key', () => {
|
||||
const key = makePaneKey('tab-authoritative', LEAF_1)
|
||||
const stale = beginDeferredSplitPaneHandoff(key, Promise.resolve('/stale'))
|
||||
appendDeferredSplitPaneInput(stale, { data: 'must-not-replay', kind: 'ordinary' })
|
||||
|
||||
discardDeferredSplitPaneHandoffForKey(key)
|
||||
|
||||
expect(claimDeferredSplitPaneHandoff(key)).toBeNull()
|
||||
expect(getDeferredSplitPaneHandoffCountForTests()).toBe(0)
|
||||
})
|
||||
|
||||
it('replaces an older handoff for the same stable pane key', () => {
|
||||
const key = makePaneKey('tab-1', LEAF_1)
|
||||
const stale = beginDeferredSplitPaneHandoff(key, Promise.resolve('/stale'))
|
||||
appendDeferredSplitPaneInput(stale, { data: 'stale', kind: 'ordinary' })
|
||||
const currentPromise = Promise.resolve('/current')
|
||||
const current = beginDeferredSplitPaneHandoff(key, currentPromise)
|
||||
|
||||
appendDeferredSplitPaneInput(stale, { data: 'late-stale', kind: 'ordinary' })
|
||||
clearDeferredSplitPaneHandoff(stale)
|
||||
appendDeferredSplitPaneInput(current, { data: 'current', kind: 'ordinary' })
|
||||
|
||||
const claimed = claimDeferredSplitPaneHandoff(key)
|
||||
expect(claimed?.cwdPromise).toBe(currentPromise)
|
||||
expect(claimed?.preconnectInput).toEqual([{ data: 'current', kind: 'ordinary' }])
|
||||
})
|
||||
|
||||
it('retains input within the shared preconnect entry and code-unit caps', () => {
|
||||
const entryKey = makePaneKey('tab-entries', LEAF_1)
|
||||
const entryHandle = beginDeferredSplitPaneHandoff(entryKey, Promise.resolve('/entries'))
|
||||
for (let index = 0; index < PTY_PRECONNECT_INPUT_MAX_ENTRIES; index += 1) {
|
||||
appendDeferredSplitPaneInput(entryHandle, { data: '', kind: 'ordinary' })
|
||||
}
|
||||
appendDeferredSplitPaneInput(entryHandle, { data: 'overflow', kind: 'ordinary' })
|
||||
expect(claimDeferredSplitPaneHandoff(entryKey)?.preconnectInput).toHaveLength(
|
||||
PTY_PRECONNECT_INPUT_MAX_ENTRIES
|
||||
)
|
||||
|
||||
const codeUnitKey = makePaneKey('tab-code-units', LEAF_1)
|
||||
const codeUnitHandle = beginDeferredSplitPaneHandoff(
|
||||
codeUnitKey,
|
||||
Promise.resolve('/code-units')
|
||||
)
|
||||
appendDeferredSplitPaneInput(codeUnitHandle, {
|
||||
data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS),
|
||||
kind: 'ordinary'
|
||||
})
|
||||
appendDeferredSplitPaneInput(codeUnitHandle, { data: 'overflow', kind: 'ordinary' })
|
||||
expect(claimDeferredSplitPaneHandoff(codeUnitKey)?.preconnectInput).toEqual([
|
||||
{ data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), kind: 'ordinary' }
|
||||
])
|
||||
})
|
||||
|
||||
it('discards every handoff for one tab without touching another tab', () => {
|
||||
const firstKey = makePaneKey('tab-1', LEAF_1)
|
||||
const secondKey = makePaneKey('tab-1', LEAF_2)
|
||||
const otherKey = makePaneKey('tab-2', LEAF_1)
|
||||
beginDeferredSplitPaneHandoff(firstKey, Promise.resolve('/first'))
|
||||
beginDeferredSplitPaneHandoff(secondKey, Promise.resolve('/second'))
|
||||
beginDeferredSplitPaneHandoff(otherKey, Promise.resolve('/other'))
|
||||
|
||||
discardDeferredSplitPaneHandoffsForTab('tab-1')
|
||||
|
||||
expect(claimDeferredSplitPaneHandoff(firstKey)).toBeNull()
|
||||
expect(claimDeferredSplitPaneHandoff(secondKey)).toBeNull()
|
||||
expect(claimDeferredSplitPaneHandoff(otherKey)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('evicts the oldest handoff when the record cap is reached', () => {
|
||||
const keys = Array.from({ length: DEFERRED_SPLIT_PANE_HANDOFF_MAX_RECORDS + 1 }, (_, index) =>
|
||||
makePaneKey(`tab-${index}`, LEAF_1)
|
||||
)
|
||||
for (const key of keys) {
|
||||
beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd'))
|
||||
}
|
||||
|
||||
expect(getDeferredSplitPaneHandoffCountForTests()).toBe(DEFERRED_SPLIT_PANE_HANDOFF_MAX_RECORDS)
|
||||
expect(claimDeferredSplitPaneHandoff(keys[0])).toBeNull()
|
||||
expect(claimDeferredSplitPaneHandoff(keys.at(-1)!)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('expires an abandoned handoff after the bounded remount window', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const key = makePaneKey('tab-1', LEAF_1)
|
||||
beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd'))
|
||||
expect(getDeferredSplitPaneHandoffCountForTests()).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(DEFERRED_SPLIT_PANE_HANDOFF_TTL_MS)
|
||||
|
||||
expect(getDeferredSplitPaneHandoffCountForTests()).toBe(0)
|
||||
expect(claimDeferredSplitPaneHandoff(key)).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not expose the registry input array by reference', () => {
|
||||
const key = makePaneKey('tab-1', LEAF_1)
|
||||
const handle = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd'))
|
||||
appendDeferredSplitPaneInput(handle, { data: 'kept', kind: 'ordinary' })
|
||||
const firstClaim = claimDeferredSplitPaneHandoff(key)
|
||||
firstClaim?.preconnectInput.splice(0)
|
||||
|
||||
expect(claimDeferredSplitPaneHandoff(key)?.preconnectInput).toEqual([
|
||||
{ data: 'kept', kind: 'ordinary' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { PaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import {
|
||||
PTY_PRECONNECT_INPUT_MAX_CODE_UNITS,
|
||||
PTY_PRECONNECT_INPUT_MAX_ENTRIES
|
||||
} from './pty-preconnect-input-buffer'
|
||||
import type { PtyPreconnectInputEntry, PtyPreconnectInputKind } from './pty-preconnect-input-buffer'
|
||||
|
||||
export type DeferredSplitPaneInputKind = PtyPreconnectInputKind
|
||||
export type DeferredSplitPaneInput = PtyPreconnectInputEntry
|
||||
|
||||
declare const deferredSplitPaneHandoffHandleBrand: unique symbol
|
||||
|
||||
export type DeferredSplitPaneHandoffHandle = {
|
||||
readonly [deferredSplitPaneHandoffHandleBrand]: true
|
||||
}
|
||||
|
||||
export type ClaimedDeferredSplitPaneHandoff = {
|
||||
handle: DeferredSplitPaneHandoffHandle
|
||||
cwdPromise: Promise<string>
|
||||
preconnectInput: DeferredSplitPaneInput[]
|
||||
}
|
||||
|
||||
export const DEFERRED_SPLIT_PANE_HANDOFF_TTL_MS = 15_000
|
||||
export const DEFERRED_SPLIT_PANE_HANDOFF_MAX_RECORDS = 64
|
||||
|
||||
type DeferredSplitPaneHandoffRecord = {
|
||||
cwdPromise: Promise<string>
|
||||
expiresAtMs: number
|
||||
expiryTimer: ReturnType<typeof setTimeout>
|
||||
inputCodeUnits: number
|
||||
owner: DeferredSplitPaneHandoffHandle
|
||||
preconnectInput: DeferredSplitPaneInput[]
|
||||
}
|
||||
|
||||
const handoffs = new Map<PaneKey, DeferredSplitPaneHandoffRecord>()
|
||||
let keyByHandle = new WeakMap<DeferredSplitPaneHandoffHandle, PaneKey>()
|
||||
|
||||
function createHandle(key: PaneKey): DeferredSplitPaneHandoffHandle {
|
||||
const handle = {} as DeferredSplitPaneHandoffHandle
|
||||
keyByHandle.set(handle, key)
|
||||
return handle
|
||||
}
|
||||
|
||||
function getOwnedRecord(
|
||||
handle: DeferredSplitPaneHandoffHandle
|
||||
): { key: PaneKey; record: DeferredSplitPaneHandoffRecord } | null {
|
||||
const key = keyByHandle.get(handle)
|
||||
const record = key ? handoffs.get(key) : undefined
|
||||
if (!key || !record || record.owner !== handle) {
|
||||
return null
|
||||
}
|
||||
if (record.expiresAtMs <= Date.now()) {
|
||||
deleteHandoff(key, record)
|
||||
return null
|
||||
}
|
||||
return { key, record }
|
||||
}
|
||||
|
||||
function deleteHandoff(key: PaneKey, expected?: DeferredSplitPaneHandoffRecord): void {
|
||||
const record = handoffs.get(key)
|
||||
if (!record || (expected && record !== expected)) {
|
||||
return
|
||||
}
|
||||
clearTimeout(record.expiryTimer)
|
||||
handoffs.delete(key)
|
||||
}
|
||||
|
||||
function pruneExpiredHandoffs(nowMs: number): void {
|
||||
for (const [key, record] of handoffs) {
|
||||
if (record.expiresAtMs <= nowMs) {
|
||||
deleteHandoff(key, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function beginDeferredSplitPaneHandoff(
|
||||
key: PaneKey,
|
||||
cwdPromise: Promise<string>
|
||||
): DeferredSplitPaneHandoffHandle {
|
||||
const nowMs = Date.now()
|
||||
pruneExpiredHandoffs(nowMs)
|
||||
deleteHandoff(key)
|
||||
if (handoffs.size >= DEFERRED_SPLIT_PANE_HANDOFF_MAX_RECORDS) {
|
||||
const oldestKey = handoffs.keys().next().value
|
||||
if (oldestKey) {
|
||||
deleteHandoff(oldestKey)
|
||||
}
|
||||
}
|
||||
const owner = createHandle(key)
|
||||
const record: DeferredSplitPaneHandoffRecord = {
|
||||
cwdPromise,
|
||||
expiresAtMs: nowMs + DEFERRED_SPLIT_PANE_HANDOFF_TTL_MS,
|
||||
expiryTimer: setTimeout(() => {
|
||||
deleteHandoff(key, record)
|
||||
}, DEFERRED_SPLIT_PANE_HANDOFF_TTL_MS),
|
||||
inputCodeUnits: 0,
|
||||
owner,
|
||||
preconnectInput: []
|
||||
}
|
||||
record.expiryTimer.unref?.()
|
||||
handoffs.set(key, record)
|
||||
return owner
|
||||
}
|
||||
|
||||
export function claimDeferredSplitPaneHandoff(
|
||||
key: PaneKey
|
||||
): ClaimedDeferredSplitPaneHandoff | null {
|
||||
const record = handoffs.get(key)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
if (record.expiresAtMs <= Date.now()) {
|
||||
deleteHandoff(key, record)
|
||||
return null
|
||||
}
|
||||
const owner = createHandle(key)
|
||||
record.owner = owner
|
||||
return {
|
||||
handle: owner,
|
||||
cwdPromise: record.cwdPromise,
|
||||
preconnectInput: record.preconnectInput.map((input) => ({ ...input }))
|
||||
}
|
||||
}
|
||||
|
||||
export function appendDeferredSplitPaneInput(
|
||||
handle: DeferredSplitPaneHandoffHandle,
|
||||
input: DeferredSplitPaneInput
|
||||
): void {
|
||||
const owned = getOwnedRecord(handle)
|
||||
if (
|
||||
!owned ||
|
||||
owned.record.preconnectInput.length >= PTY_PRECONNECT_INPUT_MAX_ENTRIES ||
|
||||
input.data.length > PTY_PRECONNECT_INPUT_MAX_CODE_UNITS - owned.record.inputCodeUnits
|
||||
) {
|
||||
return
|
||||
}
|
||||
owned.record.preconnectInput.push({ data: input.data, kind: input.kind })
|
||||
owned.record.inputCodeUnits += input.data.length
|
||||
}
|
||||
|
||||
export function releaseDeferredSplitPaneHandoff(handle: DeferredSplitPaneHandoffHandle): void {
|
||||
const owned = getOwnedRecord(handle)
|
||||
if (owned) {
|
||||
owned.record.owner = createHandle(owned.key)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDeferredSplitPaneHandoff(handle: DeferredSplitPaneHandoffHandle): void {
|
||||
const owned = getOwnedRecord(handle)
|
||||
if (owned) {
|
||||
deleteHandoff(owned.key, owned.record)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops a stale record when a restored pane already has an authoritative PTY. */
|
||||
export function discardDeferredSplitPaneHandoffForKey(key: PaneKey): void {
|
||||
deleteHandoff(key)
|
||||
}
|
||||
|
||||
export function discardDeferredSplitPaneHandoffsForTab(tabId: string): void {
|
||||
for (const [key, record] of handoffs) {
|
||||
if (parsePaneKey(key)?.tabId === tabId) {
|
||||
deleteHandoff(key, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetDeferredSplitPaneHandoffsForTests(): void {
|
||||
for (const [key, record] of handoffs) {
|
||||
deleteHandoff(key, record)
|
||||
}
|
||||
keyByHandle = new WeakMap()
|
||||
}
|
||||
|
||||
export function getDeferredSplitPaneHandoffCountForTests(): number {
|
||||
pruneExpiredHandoffs(Date.now())
|
||||
return handoffs.size
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import {
|
||||
isTerminalInputTooLargeWithDeferredMeasurement,
|
||||
iterateTerminalInputChunks
|
||||
} from '../../../../shared/terminal-input'
|
||||
|
||||
export async function writeAcceptedIpcPtyInput(
|
||||
id: string,
|
||||
data: string,
|
||||
isCurrent: () => boolean
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data)
|
||||
if (typeof tooLarge === 'boolean' ? tooLarge : await tooLarge) {
|
||||
return false
|
||||
}
|
||||
const chunks = iterateTerminalInputChunks(data)
|
||||
let chunk = chunks.next()
|
||||
while (!chunk.done) {
|
||||
if (!isCurrent()) {
|
||||
return false
|
||||
}
|
||||
const accepted = await window.api.pty.writeAccepted(id, chunk.value)
|
||||
if (!accepted) {
|
||||
return false
|
||||
}
|
||||
chunk = chunks.next()
|
||||
if (!chunk.done) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ type IpcPtyConnectContext = {
|
||||
transportOptions: IpcPtyTransportOptions
|
||||
handlers: IpcPtySessionHandlers
|
||||
isDestroyed: () => boolean
|
||||
/** True only for the one buffered exit consumed by this connect attempt. */
|
||||
isExpectedExitCurrent: () => boolean
|
||||
ownsPtyId: (id: string) => boolean
|
||||
bind: (id: string) => void
|
||||
isCurrent: (id: string) => boolean
|
||||
setCallbacks: (callbacks: PtyConnectOptions['callbacks']) => void
|
||||
@@ -44,11 +47,20 @@ export async function connectIpcPty(
|
||||
}
|
||||
if (options.sessionId && hasPreHandlerPtyExit(options.sessionId)) {
|
||||
if (options.admitPtyId && !options.admitPtyId(options.sessionId)) {
|
||||
return { id: options.sessionId }
|
||||
return context.isDestroyed() ? undefined : { id: options.sessionId }
|
||||
}
|
||||
if (context.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
context.bind(options.sessionId)
|
||||
handlers.registerData(options.sessionId)
|
||||
if (context.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
handlers.registerExit(options.sessionId)
|
||||
if (!context.isExpectedExitCurrent()) {
|
||||
return
|
||||
}
|
||||
return { id: options.sessionId, exitedBeforeAttach: true }
|
||||
}
|
||||
|
||||
@@ -77,7 +89,12 @@ export async function connectIpcPty(
|
||||
const priorIncarnationFence = currentPreHandlerPtySequence()
|
||||
const spawnResult = await spawnIpcPty(transportOptions, options, admittedSessionId)
|
||||
const retireFreshSpawn = async (): Promise<void> => {
|
||||
if (!spawnResult.isReattach && !spawnResult.coldRestore) {
|
||||
// A newer generation may already own a recycled id; an id-only kill would retire its PTY.
|
||||
if (
|
||||
!spawnResult.isReattach &&
|
||||
!spawnResult.coldRestore &&
|
||||
!context.ownsPtyId(spawnResult.id)
|
||||
) {
|
||||
await window.api.pty.kill(spawnResult.id)
|
||||
}
|
||||
}
|
||||
@@ -88,10 +105,18 @@ export async function connectIpcPty(
|
||||
}
|
||||
if (options.admitPtyId && !options.admitPtyId(spawnResult.id)) {
|
||||
await retireFreshSpawn()
|
||||
return spawnResult
|
||||
return context.isDestroyed() ? undefined : spawnResult
|
||||
}
|
||||
if (context.isDestroyed()) {
|
||||
await retireFreshSpawn()
|
||||
return
|
||||
}
|
||||
if (spawnResult.isReattach && !admittedSessionId) {
|
||||
context.getCallbacks().onReattachDetermined?.()
|
||||
if (context.isDestroyed()) {
|
||||
await retireFreshSpawn()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Why unconditional: this runs on identity, not timing. Whatever we attached to — fresh,
|
||||
@@ -106,20 +131,41 @@ export async function connectIpcPty(
|
||||
context.bind(spawnResult.id)
|
||||
if (!spawnResult.isReattach && !spawnResult.coldRestore) {
|
||||
onPtySpawn?.(spawnResult.id)
|
||||
if (context.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
handlers.registerData(spawnResult.id)
|
||||
if (context.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const exitedBeforeAttach = handlers.registerExit(spawnResult.id, spawnResult.incarnationId)
|
||||
if (exitedBeforeAttach) {
|
||||
if (!context.isExpectedExitCurrent()) {
|
||||
return
|
||||
}
|
||||
return { id: spawnResult.id, exitedBeforeAttach: true }
|
||||
}
|
||||
if (context.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
if (!context.isCurrent(spawnResult.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
context.getCallbacks().onConnect?.()
|
||||
if (context.isDestroyed() || !context.isCurrent(spawnResult.id)) {
|
||||
return
|
||||
}
|
||||
context.getCallbacks().onStatus?.('shell')
|
||||
if (context.isDestroyed() || !context.isCurrent(spawnResult.id)) {
|
||||
return
|
||||
}
|
||||
return projectIpcPtyConnectResult(spawnResult)
|
||||
} catch (error) {
|
||||
if (context.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
return handleConnectError(error, options, context)
|
||||
}
|
||||
}
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
import { connectPanePty } from './pty-connection'
|
||||
import { createDeferred, flushAsyncTicks } from './pty-connection-test-async'
|
||||
import {
|
||||
createManager,
|
||||
createMockTransport,
|
||||
createPane,
|
||||
type MockTransport
|
||||
} from './pty-connection-test-pane-fixtures'
|
||||
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import {
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
|
||||
const {
|
||||
notifyCodexPaneBoundForStaleSweep,
|
||||
scheduleRuntimeGraphSync,
|
||||
shouldSeedCacheTimerOnInitialTitle,
|
||||
toastInfo
|
||||
} = vi.hoisted(() => ({
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn(),
|
||||
scheduleRuntimeGraphSync: vi.fn(),
|
||||
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
|
||||
toastInfo: vi.fn()
|
||||
}))
|
||||
|
||||
let mockStoreState: StoreState
|
||||
let transportFactoryQueue: MockTransport[] = []
|
||||
let createdTransportOptions: Record<string, unknown>[] = []
|
||||
let storeSubscribers: ((state: StoreState) => void)[] = []
|
||||
|
||||
vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync }))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => mockStoreState,
|
||||
subscribe: (listener: (state: StoreState) => void) => {
|
||||
storeSubscribers.push(listener)
|
||||
return () => {
|
||||
storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment')
|
||||
return buildAgentStatusModuleMock(await importOriginal<Record<string, unknown>>())
|
||||
})
|
||||
|
||||
vi.mock('./cache-timer-seeding', () => ({ shouldSeedCacheTimerOnInitialTitle }))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: toastInfo } }))
|
||||
|
||||
vi.mock('@/lib/codex-stale-pane-sweep', () => ({ notifyCodexPaneBoundForStaleSweep }))
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof React>()
|
||||
return {
|
||||
...actual,
|
||||
useCallback: <T extends (...args: unknown[]) => unknown>(fn: T): T => fn
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./pty-transport', () => ({
|
||||
createIpcPtyTransport: vi.fn((options: Record<string, unknown>) => {
|
||||
createdTransportOptions.push(options)
|
||||
const nextTransport = transportFactoryQueue.shift()
|
||||
if (!nextTransport) {
|
||||
throw new Error('No mock transport queued')
|
||||
}
|
||||
return nextTransport
|
||||
})
|
||||
}))
|
||||
|
||||
describe('connectPanePty split cwd resolution', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
transportFactoryQueue = []
|
||||
createdTransportOptions = []
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
installTerminalTestGlobals()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
|
||||
it('waits for inherited cwd before fresh spawn and applies the resolved directory', async () => {
|
||||
const cwd = createDeferred<string>()
|
||||
const transport = createMockTransport('pty-1')
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const binding = connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
buildPaneConnectionDeps(() => mockStoreState, { cwdPromise: cwd.promise }) as never
|
||||
)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(createdTransportOptions[0]?.bufferInputUntilConnect).toBe(true)
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
|
||||
cwd.resolve('/resolved/source-cwd')
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(createdTransportOptions[0]?.cwd).toBe('/resolved/source-cwd')
|
||||
expect(transport.connect).toHaveBeenCalledOnce()
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('cancels the pending spawn when the split closes before cwd resolution', async () => {
|
||||
const cwd = createDeferred<string>()
|
||||
const transport = createMockTransport('pty-1')
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const binding = connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
buildPaneConnectionDeps(() => mockStoreState, { cwdPromise: cwd.promise }) as never
|
||||
)
|
||||
await flushAsyncTicks(20)
|
||||
binding.dispose()
|
||||
|
||||
cwd.resolve('/resolved/too-late')
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the deferred cwd fence when direct SSH authority changes during spawn', async () => {
|
||||
const cwd = createDeferred<string>()
|
||||
const spawn = createDeferred<string>()
|
||||
const transport = createMockTransport()
|
||||
const stalePtyId = toAppSshPtyId('target-a', 'pty-stale-split')
|
||||
const livePtyId = toAppSshPtyId('target-a', 'pty-live')
|
||||
let transportPtyId: string | null = null
|
||||
transport.connect.mockReturnValueOnce(spawn.promise)
|
||||
transport.getPtyId.mockImplementation(() => transportPtyId)
|
||||
transport.disconnect.mockImplementation(() => {
|
||||
transportPtyId = null
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: livePtyId, generation: 7 }] },
|
||||
ptyIdsByTabId: { 'tab-1': [livePtyId] },
|
||||
repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }],
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
'target-a',
|
||||
{
|
||||
targetId: 'target-a',
|
||||
status: 'connected',
|
||||
providerEpoch: 'epoch-old',
|
||||
connectionGeneration: 3
|
||||
}
|
||||
]
|
||||
]),
|
||||
directSshPaneRetryByTabId: {},
|
||||
directSshLivePtyBindingByTabId: {
|
||||
'tab-1': {
|
||||
attemptId: 'attempt-live-split',
|
||||
authority: {
|
||||
targetId: 'target-a',
|
||||
providerEpoch: 'epoch-old',
|
||||
connectionGeneration: 3
|
||||
},
|
||||
tabGeneration: 7,
|
||||
ptyId: livePtyId
|
||||
}
|
||||
},
|
||||
settleDirectSshPaneRetry: vi.fn()
|
||||
}
|
||||
const onDeferredCwdSpawnFailed = vi.fn()
|
||||
const paneTransportsRef = {
|
||||
current: new Map([[1, createMockTransport(livePtyId)]])
|
||||
}
|
||||
const binding = connectPanePty(
|
||||
createPane(2) as never,
|
||||
createManager(2) as never,
|
||||
buildPaneConnectionDeps(() => mockStoreState, {
|
||||
cwdPromise: cwd.promise,
|
||||
onDeferredCwdSpawnFailed,
|
||||
paneTransportsRef
|
||||
}) as never
|
||||
)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
cwd.resolve('/resolved/source-cwd')
|
||||
await flushAsyncTicks(20)
|
||||
expect(transport.connect).toHaveBeenCalledOnce()
|
||||
|
||||
mockStoreState.sshConnectionStates = new Map([
|
||||
[
|
||||
'target-a',
|
||||
{
|
||||
targetId: 'target-a',
|
||||
status: 'connected',
|
||||
providerEpoch: 'epoch-new',
|
||||
connectionGeneration: 4
|
||||
}
|
||||
]
|
||||
])
|
||||
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
|
||||
| ((ptyId: string) => void)
|
||||
| undefined
|
||||
expect(onPtySpawn).toBeTypeOf('function')
|
||||
transportPtyId = stalePtyId
|
||||
onPtySpawn?.(stalePtyId)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(transport.disconnect).toHaveBeenCalledOnce()
|
||||
expect(onDeferredCwdSpawnFailed).not.toHaveBeenCalled()
|
||||
|
||||
spawn.resolve(stalePtyId)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(onDeferredCwdSpawnFailed).toHaveBeenCalledOnce()
|
||||
binding.dispose()
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/termin
|
||||
import type { PtyTransportRecoveryState } from './pty-transport-types'
|
||||
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
|
||||
import type { DirectSshPaneRetryAttemptId } from '@/store/slices/direct-ssh-terminal-recovery'
|
||||
import type { PtyPreconnectInputEntry } from './pty-preconnect-input-buffer'
|
||||
|
||||
export type PtyPaneStartup = {
|
||||
command: string
|
||||
@@ -55,6 +56,14 @@ export type PtyConnectionDeps = {
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
cwd?: string
|
||||
/** Delays a fresh split's spawn without delaying its renderer pane. */
|
||||
cwdPromise?: Promise<string>
|
||||
/** Input handed off from a predecessor mount of the same deferred split. */
|
||||
preconnectInput?: readonly PtyPreconnectInputEntry[]
|
||||
/** Captures newly retained input for a remount-safe deferred split handoff. */
|
||||
onPreconnectInput?: (input: PtyPreconnectInputEntry) => void
|
||||
/** Releases a deferred split's detach fence when its initial spawn yields no PTY. */
|
||||
onDeferredCwdSpawnFailed?: () => void
|
||||
startup?: PtyPaneStartup
|
||||
restoredLeafId?: string | null
|
||||
restoredPtyIdByLeafId?: Record<string, string>
|
||||
|
||||
@@ -21,7 +21,20 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
startupOverride?: PendingStartupCommand | null,
|
||||
options: FreshSpawnOptions = {}
|
||||
): Promise<string | null> => {
|
||||
const releaseDeferredCwdFence = (): void => {
|
||||
if (!session.transport.getPtyId()) {
|
||||
// An abandoned spawn never reaches connect(), so nothing else would ever
|
||||
// drain the pre-connect buffer or settle its acknowledged-write promises.
|
||||
session.transport.abandonPreconnectInput?.()
|
||||
try {
|
||||
session.deps.onDeferredCwdSpawnFailed?.()
|
||||
} catch {
|
||||
// A cleanup callback must not turn a settled spawn into an unhandled rejection.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (session.isLegacyWorkerAutomaticResumeBlocked()) {
|
||||
releaseDeferredCwdFence()
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (useAppStore.getState().deleteStateByWorktreeId?.[session.deps.worktreeId]?.isDeleting) {
|
||||
@@ -29,6 +42,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
// filesystem teardown. A fresh shell must not spawn into a directory the
|
||||
// removal is about to delete (main fences it anyway), and the pane is
|
||||
// about to unmount — so skip the doomed respawn instead of racing it.
|
||||
releaseDeferredCwdFence()
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
session.authoritativeReattachGeneration += 1
|
||||
@@ -163,6 +177,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
? spawnedPtyId
|
||||
: session.transport.getPtyId()
|
||||
if (resolvedPtyId && !session.claimCapturedDirectSshRetryPty(resolvedPtyId)) {
|
||||
releaseDeferredCwdFence()
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
// Why: an outstanding declare keeps main's cooperation gate suppressing
|
||||
// this paneKey's daemon-snapshot seed until something releases it.
|
||||
@@ -188,6 +203,10 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
} else if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
if (!accepted) {
|
||||
// A rejected reattach ends this spawn; nothing later clears the fence.
|
||||
releaseDeferredCwdFence()
|
||||
}
|
||||
return accepted ? resolvedPtyId : null
|
||||
}
|
||||
if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) {
|
||||
@@ -246,6 +265,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
session.reconcilePtySizeAfterSpawn(resolvedPtyId, session.cols, session.rows)
|
||||
}
|
||||
if (!resolvedPtyId) {
|
||||
releaseDeferredCwdFence()
|
||||
clearPreSignaledSerializer()
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
return null
|
||||
@@ -274,6 +294,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
return resolvedPtyId
|
||||
})
|
||||
.catch(async () => {
|
||||
releaseDeferredCwdFence()
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
if (
|
||||
session.paneStartup?.launchConfig ||
|
||||
|
||||
@@ -36,6 +36,15 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void {
|
||||
session.agentLaunchPreferences = toAgentLaunchPreferences(session.paneStartup?.sessionOptions)
|
||||
session.transportOptions = {
|
||||
cwd: session.deps.cwd,
|
||||
...(session.deps.cwdPromise || session.deps.preconnectInput?.length
|
||||
? { bufferInputUntilConnect: true }
|
||||
: {}),
|
||||
...(session.deps.preconnectInput?.length
|
||||
? { preconnectInput: session.deps.preconnectInput }
|
||||
: {}),
|
||||
...(session.deps.onPreconnectInput
|
||||
? { onPreconnectInput: session.deps.onPreconnectInput }
|
||||
: {}),
|
||||
// Why: only fresh local IPC spawns may recover from a saved startup cwd
|
||||
// whose directory was deleted (#7239); remote-runtime and SSH spawns
|
||||
// resolve cwd on another host and must keep exact cwd semantics.
|
||||
|
||||
@@ -23,10 +23,43 @@ import { bindHiddenOutputSeqAndSkip } from './hidden-output-seq-and-skip'
|
||||
import { bindHiddenRestoreStateAndSshProbe } from './hidden-restore-state-and-ssh-probe'
|
||||
|
||||
export function installRunDeferredConnect(session: ConnectPanePtySession): void {
|
||||
const cwdPromise = session.deps.cwdPromise
|
||||
let cwdPromiseSettled = cwdPromise === undefined
|
||||
let cwdPromiseWaitStarted = false
|
||||
|
||||
session.runDeferredConnect = (): void => {
|
||||
if (session.connectStarted) {
|
||||
return
|
||||
}
|
||||
if (!cwdPromiseSettled) {
|
||||
session.cancelScheduledConnectFrame()
|
||||
if (session.connectFallbackTimer !== null) {
|
||||
clearTimeout(session.connectFallbackTimer)
|
||||
session.connectFallbackTimer = null
|
||||
}
|
||||
if (!cwdPromiseWaitStarted) {
|
||||
cwdPromiseWaitStarted = true
|
||||
void cwdPromise?.then(
|
||||
(cwd: string) => {
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
session.deps.cwd = cwd
|
||||
session.transportOptions.cwd = cwd
|
||||
cwdPromiseSettled = true
|
||||
session.runDeferredConnect()
|
||||
},
|
||||
() => {
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
cwdPromiseSettled = true
|
||||
session.runDeferredConnect()
|
||||
}
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!session.startupGridSettledForConnect && session.shouldSettleStartupGridBeforeConnect()) {
|
||||
session.cancelScheduledConnectFrame()
|
||||
if (session.connectFallbackTimer !== null) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PendingPtyInputWrite } from './pty-input-write-queue-contract'
|
||||
|
||||
/** Amortized-O(1) FIFO: shift by head index, compact once the dead prefix dominates. */
|
||||
export type HeadQueue = { items: (PendingPtyInputWrite | undefined)[]; head: number }
|
||||
|
||||
export function createHeadQueue(): HeadQueue {
|
||||
return { items: [], head: 0 }
|
||||
}
|
||||
|
||||
export function resetHeadQueue(queue: HeadQueue): void {
|
||||
queue.items = []
|
||||
queue.head = 0
|
||||
}
|
||||
|
||||
export function peekHeadQueue(queue: HeadQueue): PendingPtyInputWrite | undefined {
|
||||
return queue.items[queue.head]
|
||||
}
|
||||
|
||||
export function shiftHeadQueue(queue: HeadQueue): PendingPtyInputWrite | undefined {
|
||||
const removed = queue.items[queue.head]
|
||||
queue.items[queue.head] = undefined
|
||||
queue.head += 1
|
||||
if (queue.head === queue.items.length) {
|
||||
resetHeadQueue(queue)
|
||||
} else if (queue.head >= 1024 && queue.head * 2 >= queue.items.length) {
|
||||
queue.items = queue.items.slice(queue.head)
|
||||
queue.head = 0
|
||||
}
|
||||
return removed
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096
|
||||
export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES = 64
|
||||
export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS =
|
||||
TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS
|
||||
|
||||
export type PendingPtyInputWrite = {
|
||||
sequence: number
|
||||
id: string
|
||||
text: string
|
||||
replyOnly: boolean
|
||||
resolveAccepted: ((accepted: boolean) => void) | undefined
|
||||
tooLarge: boolean | Promise<boolean>
|
||||
chunks?: Iterator<string>
|
||||
nextChunk?: string
|
||||
}
|
||||
|
||||
export type PtyInputWriteQueue = {
|
||||
enqueue: (id: string, data: string) => boolean
|
||||
enqueueQueryReply: (id: string, data: string) => boolean
|
||||
enqueueAccepted: (id: string, data: string) => Promise<boolean>
|
||||
waitForDrain: () => Promise<void>
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export type PtyInputWriteQueueDeps = {
|
||||
isWritable: (id: string) => boolean
|
||||
write: (id: string, data: string) => void
|
||||
writeAccepted?: (id: string, data: string) => Promise<boolean>
|
||||
yieldBetweenWrites?: () => Promise<void>
|
||||
onDrainFailure?: (id: string) => void
|
||||
}
|
||||
|
||||
export function isCoalesciblePtyInput(input: PendingPtyInputWrite): boolean {
|
||||
return (
|
||||
input.text.length <= TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS &&
|
||||
!input.replyOnly &&
|
||||
!input.resolveAccepted
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
needsCookedEchoSafeQueryReply
|
||||
} from '../../../../shared/terminal-query-reply'
|
||||
import { installTerminalCapabilityReplyHandlers } from './terminal-capability-replies'
|
||||
import { createDeferred, flushAsyncTicks } from './pty-connection-test-async'
|
||||
|
||||
const WHEEL_UP_REPORT = '\x1b[<64;60;20M'
|
||||
|
||||
@@ -430,6 +431,126 @@ describe('pty input write queue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('serializes accepted input at its invocation position', async () => {
|
||||
const acceptedWrite = createDeferred<boolean>()
|
||||
const delivered: string[] = []
|
||||
const queue = createPtyInputWriteQueue({
|
||||
isWritable: () => true,
|
||||
write: (_id, data) => delivered.push(`ordinary:${data}`),
|
||||
writeAccepted: async (_id, data) => {
|
||||
delivered.push(`accepted:${data}`)
|
||||
return acceptedWrite.promise
|
||||
}
|
||||
})
|
||||
|
||||
expect(queue.enqueue('pty-1', 'first')).toBe(true)
|
||||
const accepted = queue.enqueueAccepted('pty-1', 'second')
|
||||
expect(queue.enqueue('pty-1', 'third')).toBe(true)
|
||||
expect(queue.enqueueQueryReply('pty-1', 'fourth')).toBe(true)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(delivered).toEqual(['ordinary:first', 'accepted:second'])
|
||||
|
||||
acceptedWrite.resolve(true)
|
||||
await expect(accepted).resolves.toBe(true)
|
||||
await queue.waitForDrain()
|
||||
|
||||
expect(delivered).toEqual([
|
||||
'ordinary:first',
|
||||
'accepted:second',
|
||||
'ordinary:third',
|
||||
'ordinary:fourth'
|
||||
])
|
||||
})
|
||||
|
||||
it('reserves one drain before an accepted write enqueues reentrantly', async () => {
|
||||
const acceptedWrite = createDeferred<boolean>()
|
||||
const delivered: string[] = []
|
||||
let reentered = false
|
||||
let queue!: ReturnType<typeof createPtyInputWriteQueue>
|
||||
queue = createPtyInputWriteQueue({
|
||||
isWritable: () => true,
|
||||
write: (_id, data) => delivered.push(`ordinary:${data}`),
|
||||
writeAccepted: async (_id, data) => {
|
||||
delivered.push(`accepted:${data}`)
|
||||
if (!reentered) {
|
||||
reentered = true
|
||||
queue.enqueue('pty-1', 'later')
|
||||
}
|
||||
return acceptedWrite.promise
|
||||
}
|
||||
})
|
||||
|
||||
const accepted = queue.enqueueAccepted('pty-1', 'first')
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(delivered).toEqual(['accepted:first'])
|
||||
|
||||
acceptedWrite.resolve(true)
|
||||
await expect(accepted).resolves.toBe(true)
|
||||
await queue.waitForDrain()
|
||||
|
||||
expect(delivered).toEqual(['accepted:first', 'ordinary:later'])
|
||||
})
|
||||
|
||||
it('keeps draining fresh input when an accepted write clears reentrantly', async () => {
|
||||
const acceptedWrite = createDeferred<boolean>()
|
||||
const delivered: string[] = []
|
||||
let queue!: ReturnType<typeof createPtyInputWriteQueue>
|
||||
queue = createPtyInputWriteQueue({
|
||||
isWritable: () => true,
|
||||
write: (_id, data) => delivered.push(`ordinary:${data}`),
|
||||
writeAccepted: (_id, data) => {
|
||||
delivered.push(`accepted:${data}`)
|
||||
queue.clear()
|
||||
queue.enqueue('pty-1', 'fresh')
|
||||
return acceptedWrite.promise
|
||||
}
|
||||
})
|
||||
|
||||
const accepted = queue.enqueueAccepted('pty-1', 'stale')
|
||||
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
await queue.waitForDrain()
|
||||
expect(delivered).toEqual(['accepted:stale', 'ordinary:fresh'])
|
||||
|
||||
acceptedWrite.resolve(true)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(delivered).toEqual(['accepted:stale', 'ordinary:fresh'])
|
||||
})
|
||||
|
||||
it('clear settles active and pending accepted input before same-id queue reuse', async () => {
|
||||
const acceptedWrite = createDeferred<boolean>()
|
||||
const acceptedStarted = createDeferred<void>()
|
||||
const delivered: string[] = []
|
||||
const queue = createPtyInputWriteQueue({
|
||||
isWritable: () => true,
|
||||
write: (_id, data) => delivered.push(`ordinary:${data}`),
|
||||
writeAccepted: async (_id, data) => {
|
||||
delivered.push(`accepted:${data}`)
|
||||
acceptedStarted.resolve()
|
||||
return acceptedWrite.promise
|
||||
}
|
||||
})
|
||||
const active = queue.enqueueAccepted('pty-1', 'stale-active')
|
||||
const pending = queue.enqueueAccepted('pty-1', 'stale-pending')
|
||||
await acceptedStarted.promise
|
||||
|
||||
queue.clear()
|
||||
expect(queue.enqueue('pty-1', 'fresh')).toBe(true)
|
||||
|
||||
await expect(active).resolves.toBe(false)
|
||||
await expect(pending).resolves.toBe(false)
|
||||
await queue.waitForDrain()
|
||||
expect(delivered).toEqual(['accepted:stale-active', 'ordinary:fresh'])
|
||||
|
||||
acceptedWrite.resolve(true)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(delivered).toEqual(['accepted:stale-active', 'ordinary:fresh'])
|
||||
})
|
||||
|
||||
it('reports the pty id that failed so a rebound owner can ignore the drain failure', async () => {
|
||||
const failure = new Error('yield failed')
|
||||
const onDrainFailure = vi.fn()
|
||||
@@ -621,4 +742,24 @@ describe('pty input write queue', () => {
|
||||
|
||||
expect(extractReplyWrites(writes)).toEqual([reply, reply])
|
||||
})
|
||||
|
||||
it('does not retain a reaction record per acknowledged write', async () => {
|
||||
// Regression: racing every accepted write against one queue-lifetime promise
|
||||
// retained a reaction until that promise settled — ~440 bytes per write.
|
||||
const queue = createPtyInputWriteQueue({
|
||||
isWritable: () => true,
|
||||
write: () => undefined,
|
||||
writeAccepted: async () => true,
|
||||
yieldBetweenWrites: async () => undefined
|
||||
})
|
||||
globalThis.gc?.()
|
||||
const heapBefore = process.memoryUsage().heapUsed
|
||||
for (let index = 0; index < 20_000; index += 1) {
|
||||
await queue.enqueueAccepted('pty-1', 'x')
|
||||
}
|
||||
await queue.waitForDrain()
|
||||
globalThis.gc?.()
|
||||
const growthMb = (process.memoryUsage().heapUsed - heapBefore) / 1024 / 1024
|
||||
expect(growthMb).toBeLessThan(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,89 +3,49 @@ import {
|
||||
isTerminalInputTooLargeWithDeferredMeasurement,
|
||||
iterateTerminalInputChunks
|
||||
} from '../../../../shared/terminal-input'
|
||||
import {
|
||||
isCoalesciblePtyInput,
|
||||
PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES,
|
||||
PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS,
|
||||
TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS,
|
||||
type PendingPtyInputWrite,
|
||||
type PtyInputWriteQueue,
|
||||
type PtyInputWriteQueueDeps
|
||||
} from './pty-input-write-queue-contract'
|
||||
import {
|
||||
createHeadQueue,
|
||||
peekHeadQueue,
|
||||
resetHeadQueue,
|
||||
shiftHeadQueue
|
||||
} from './pty-input-write-head-queue'
|
||||
|
||||
// Why: 4096 UTF-16 code units encode to at most ~12KB UTF-8, safely under the
|
||||
// 16KB TERMINAL_INPUT_CHUNK_MAX_BYTES cap without paying byte measurement on
|
||||
// the hot input path.
|
||||
export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096
|
||||
// Match host delivery's reply ceiling while keeping all retained reply text under one PTY chunk.
|
||||
export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES = 64
|
||||
// Keep ≤ TERMINAL_INPUT_CHUNK_MAX_BYTES/3 so a reply is written and dropped in one drain step:
|
||||
// admitReply evicts the head, and a half-written entry would truncate. Guarded by a unit test.
|
||||
export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS =
|
||||
export {
|
||||
PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES,
|
||||
PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS,
|
||||
TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS
|
||||
|
||||
type PendingPtyInputWrite = {
|
||||
sequence: number
|
||||
id: string
|
||||
text: string
|
||||
replyOnly: boolean
|
||||
tooLarge: boolean | Promise<boolean>
|
||||
chunks?: Iterator<string>
|
||||
nextChunk?: string
|
||||
}
|
||||
|
||||
export type PtyInputWriteQueue = {
|
||||
enqueue: (id: string, data: string) => boolean
|
||||
enqueueQueryReply: (id: string, data: string) => boolean
|
||||
waitForDrain: () => Promise<void>
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export type PtyInputWriteQueueDeps = {
|
||||
isWritable: (id: string) => boolean
|
||||
write: (id: string, data: string) => void
|
||||
yieldBetweenWrites?: () => Promise<void>
|
||||
onDrainFailure?: (id: string) => void
|
||||
}
|
||||
|
||||
function isCoalescibleInput(input: PendingPtyInputWrite): boolean {
|
||||
// Echo-risk replies stay atomic so host classifiers cannot miss them (#13137).
|
||||
return input.text.length <= TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS && !input.replyOnly
|
||||
}
|
||||
} from './pty-input-write-queue-contract'
|
||||
|
||||
export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInputWriteQueue {
|
||||
const yieldBetweenWrites = deps.yieldBetweenWrites ?? yieldToEventLoop
|
||||
let pendingOrdinary: (PendingPtyInputWrite | undefined)[] = []
|
||||
let pendingOrdinaryHead = 0
|
||||
let pendingReplies: (PendingPtyInputWrite | undefined)[] = []
|
||||
let pendingReplyHead = 0
|
||||
const pendingOrdinary = createHeadQueue()
|
||||
const pendingReplies = createHeadQueue()
|
||||
let pendingReplyCount = 0
|
||||
let pendingReplyCodeUnits = 0
|
||||
let nextSequence = 0
|
||||
let generation = 0
|
||||
let failedGeneration: number | null = null
|
||||
let drainPromise: Promise<void> | null = null
|
||||
|
||||
function compactOrdinary(): void {
|
||||
if (pendingOrdinaryHead === pendingOrdinary.length) {
|
||||
pendingOrdinary = []
|
||||
pendingOrdinaryHead = 0
|
||||
} else if (pendingOrdinaryHead >= 1024 && pendingOrdinaryHead * 2 >= pendingOrdinary.length) {
|
||||
pendingOrdinary = pendingOrdinary.slice(pendingOrdinaryHead)
|
||||
pendingOrdinaryHead = 0
|
||||
}
|
||||
}
|
||||
|
||||
function compactReplies(): void {
|
||||
if (pendingReplyHead === pendingReplies.length) {
|
||||
pendingReplies = []
|
||||
pendingReplyHead = 0
|
||||
} else if (pendingReplyHead >= 1024 && pendingReplyHead * 2 >= pendingReplies.length) {
|
||||
pendingReplies = pendingReplies.slice(pendingReplyHead)
|
||||
pendingReplyHead = 0
|
||||
}
|
||||
}
|
||||
const pendingAcceptedCancels = new Set<() => void>()
|
||||
|
||||
function resetSequenceIfEmpty(): void {
|
||||
if (pendingOrdinary.length === 0 && pendingReplies.length === 0) {
|
||||
if (pendingOrdinary.items.length === 0 && pendingReplies.items.length === 0) {
|
||||
nextSequence = 0
|
||||
}
|
||||
}
|
||||
|
||||
function firstPending(): PendingPtyInputWrite | undefined {
|
||||
const ordinary = pendingOrdinary[pendingOrdinaryHead]
|
||||
const reply = pendingReplies[pendingReplyHead]
|
||||
const ordinary = peekHeadQueue(pendingOrdinary)
|
||||
const reply = peekHeadQueue(pendingReplies)
|
||||
if (!ordinary) {
|
||||
return reply
|
||||
}
|
||||
@@ -96,33 +56,30 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
|
||||
}
|
||||
|
||||
function shiftOrdinary(): PendingPtyInputWrite | undefined {
|
||||
const removed = pendingOrdinary[pendingOrdinaryHead]
|
||||
pendingOrdinary[pendingOrdinaryHead] = undefined
|
||||
pendingOrdinaryHead += 1
|
||||
compactOrdinary()
|
||||
const removed = shiftHeadQueue(pendingOrdinary)
|
||||
resetSequenceIfEmpty()
|
||||
return removed
|
||||
}
|
||||
|
||||
function shiftReply(): PendingPtyInputWrite | undefined {
|
||||
const removed = pendingReplies[pendingReplyHead]
|
||||
pendingReplies[pendingReplyHead] = undefined
|
||||
pendingReplyHead += 1
|
||||
const removed = shiftHeadQueue(pendingReplies)
|
||||
if (removed) {
|
||||
pendingReplyCount -= 1
|
||||
pendingReplyCodeUnits -= removed.text.length
|
||||
}
|
||||
compactReplies()
|
||||
resetSequenceIfEmpty()
|
||||
return removed
|
||||
}
|
||||
|
||||
function removePending(item: PendingPtyInputWrite): void {
|
||||
function removePending(item: PendingPtyInputWrite, accepted?: boolean): void {
|
||||
if (item.replyOnly) {
|
||||
shiftReply()
|
||||
} else {
|
||||
shiftOrdinary()
|
||||
}
|
||||
if (accepted !== undefined) {
|
||||
item.resolveAccepted?.(accepted)
|
||||
}
|
||||
}
|
||||
|
||||
function admitReply(text: string): boolean {
|
||||
@@ -141,15 +98,37 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
|
||||
}
|
||||
|
||||
function clearPending(): void {
|
||||
pendingOrdinary = []
|
||||
pendingOrdinaryHead = 0
|
||||
pendingReplies = []
|
||||
pendingReplyHead = 0
|
||||
for (let index = pendingOrdinary.head; index < pendingOrdinary.items.length; index += 1) {
|
||||
pendingOrdinary.items[index]?.resolveAccepted?.(false)
|
||||
}
|
||||
resetHeadQueue(pendingOrdinary)
|
||||
resetHeadQueue(pendingReplies)
|
||||
pendingReplyCount = 0
|
||||
pendingReplyCodeUnits = 0
|
||||
nextSequence = 0
|
||||
}
|
||||
|
||||
// Why: one cancel per in-flight write rather than `.then()` on a queue-lifetime
|
||||
// promise — those reactions are retained until that promise settles, so a
|
||||
// long-lived pane accumulated one record per acknowledged write (Esc, Ctrl+C).
|
||||
async function writeAcceptedChunk(id: string, data: string): Promise<boolean> {
|
||||
let cancel = (): void => undefined
|
||||
const cancelled = new Promise<boolean>((resolve) => {
|
||||
cancel = () => resolve(false)
|
||||
})
|
||||
// Registered before the write starts so a clear() inside a synchronous
|
||||
// writeAccepted callback still unblocks this race.
|
||||
pendingAcceptedCancels.add(cancel)
|
||||
try {
|
||||
return await Promise.race([
|
||||
cancelled,
|
||||
Promise.resolve(deps.writeAccepted?.(id, data) ?? false).catch(() => false)
|
||||
])
|
||||
} finally {
|
||||
pendingAcceptedCancels.delete(cancel)
|
||||
}
|
||||
}
|
||||
|
||||
async function drain(): Promise<void> {
|
||||
let failureGeneration = generation
|
||||
// Why: the drain yields, so the owner may rebind before the failure surfaces; report the id that actually failed.
|
||||
@@ -160,7 +139,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
|
||||
failureGeneration = generation
|
||||
failingId = next.id
|
||||
if (!deps.isWritable(next.id)) {
|
||||
removePending(next)
|
||||
removePending(next, false)
|
||||
continue
|
||||
}
|
||||
if (next.tooLarge !== false) {
|
||||
@@ -169,11 +148,11 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
|
||||
continue
|
||||
}
|
||||
if (next.tooLarge) {
|
||||
removePending(next)
|
||||
removePending(next, false)
|
||||
continue
|
||||
}
|
||||
if (!deps.isWritable(next.id)) {
|
||||
removePending(next)
|
||||
removePending(next, false)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -184,7 +163,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
|
||||
// the gesture ended and the TUI visibly replays them one by one.
|
||||
// Coalescing consecutive validated small items into a single write keeps
|
||||
// the PTY byte stream identical while draining the backlog in one turn.
|
||||
if (next.chunks === undefined && isCoalescibleInput(next)) {
|
||||
if (next.chunks === undefined && isCoalesciblePtyInput(next)) {
|
||||
let payload = next.text
|
||||
removePending(next)
|
||||
let peek: PendingPtyInputWrite | undefined
|
||||
@@ -193,7 +172,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
|
||||
peek.id !== next.id ||
|
||||
peek.tooLarge !== false ||
|
||||
peek.chunks !== undefined ||
|
||||
!isCoalescibleInput(peek) ||
|
||||
!isCoalesciblePtyInput(peek) ||
|
||||
payload.length + peek.text.length > TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS
|
||||
) {
|
||||
break
|
||||
@@ -212,13 +191,23 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
|
||||
next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk }
|
||||
next.nextChunk = undefined
|
||||
if (chunk.done) {
|
||||
removePending(next)
|
||||
removePending(next, true)
|
||||
continue
|
||||
}
|
||||
deps.write(next.id, chunk.value)
|
||||
const writeGeneration = generation
|
||||
const accepted = next.resolveAccepted
|
||||
? await writeAcceptedChunk(next.id, chunk.value)
|
||||
: (deps.write(next.id, chunk.value), true)
|
||||
if (generation !== writeGeneration || firstPending() !== next) {
|
||||
continue
|
||||
}
|
||||
if (!accepted) {
|
||||
clearPending()
|
||||
return
|
||||
}
|
||||
const following = next.chunks.next()
|
||||
if (following.done) {
|
||||
removePending(next)
|
||||
removePending(next, true)
|
||||
} else {
|
||||
next.nextChunk = following.value
|
||||
}
|
||||
@@ -253,12 +242,20 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
|
||||
scheduleDrain()
|
||||
}
|
||||
}
|
||||
// Reserve the worker before drain() can invoke a reentrant write callback.
|
||||
drainPromise = Promise.resolve()
|
||||
drainPromise = drain().finally(finishDrain)
|
||||
}
|
||||
|
||||
function enqueueInput(id: string, data: string, queryReply: boolean): boolean {
|
||||
function enqueueInput(
|
||||
id: string,
|
||||
data: string,
|
||||
queryReply: boolean,
|
||||
resolveAccepted?: PendingPtyInputWrite['resolveAccepted']
|
||||
): boolean {
|
||||
try {
|
||||
if (failedGeneration === generation) {
|
||||
resolveAccepted?.(false)
|
||||
return false
|
||||
}
|
||||
// Every query reply stays atomic so host-side ordering can classify it (#13892).
|
||||
@@ -268,20 +265,22 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
|
||||
}
|
||||
const tooLarge = replyOnly ? false : isTerminalInputTooLargeWithDeferredMeasurement(data)
|
||||
if (tooLarge === true) {
|
||||
resolveAccepted?.(false)
|
||||
return false
|
||||
}
|
||||
const item = { sequence: nextSequence, id, text: data, replyOnly, tooLarge }
|
||||
const item = { sequence: nextSequence, id, text: data, replyOnly, tooLarge, resolveAccepted }
|
||||
nextSequence += 1
|
||||
if (replyOnly) {
|
||||
pendingReplies.push(item)
|
||||
pendingReplies.items.push(item)
|
||||
pendingReplyCount += 1
|
||||
pendingReplyCodeUnits += data.length
|
||||
} else {
|
||||
pendingOrdinary.push(item)
|
||||
pendingOrdinary.items.push(item)
|
||||
}
|
||||
scheduleDrain()
|
||||
return true
|
||||
} catch {
|
||||
resolveAccepted?.(false)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -295,6 +294,11 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
|
||||
return enqueueInput(id, data, true)
|
||||
},
|
||||
|
||||
enqueueAccepted: (id, data) =>
|
||||
new Promise((resolve) => {
|
||||
enqueueInput(id, data, false, resolve)
|
||||
}),
|
||||
|
||||
async waitForDrain(): Promise<void> {
|
||||
while (drainPromise) {
|
||||
await drainPromise
|
||||
@@ -305,6 +309,10 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
|
||||
generation += 1
|
||||
failedGeneration = null
|
||||
clearPending()
|
||||
for (const cancel of pendingAcceptedCancels) {
|
||||
cancel()
|
||||
}
|
||||
pendingAcceptedCancels.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createDeferred, flushAsyncTicks } from './pty-connection-test-async'
|
||||
import {
|
||||
createPtyPreconnectInputBuffer,
|
||||
PTY_PRECONNECT_INPUT_MAX_CODE_UNITS,
|
||||
PTY_PRECONNECT_INPUT_MAX_ENTRIES
|
||||
} from './pty-preconnect-input-buffer'
|
||||
|
||||
describe('createPtyPreconnectInputBuffer', () => {
|
||||
it('shares one flush worker and preserves mixed input order', async () => {
|
||||
const acceptedWrite = createDeferred<boolean>()
|
||||
const buffer = createPtyPreconnectInputBuffer()
|
||||
const delivered: string[] = []
|
||||
const accepted = buffer.enqueueAccepted('first')
|
||||
expect(buffer.enqueue('second', 'ordinary')).toBe(true)
|
||||
expect(buffer.enqueue('third', 'immediate')).toBe(true)
|
||||
const writer = {
|
||||
isCurrent: () => true,
|
||||
sendInput: (data: string) => {
|
||||
delivered.push(`ordinary:${data}`)
|
||||
return true
|
||||
},
|
||||
sendInputImmediate: (data: string) => {
|
||||
delivered.push(`immediate:${data}`)
|
||||
return true
|
||||
},
|
||||
sendInputAccepted: async (data: string) => {
|
||||
const result = await acceptedWrite.promise
|
||||
delivered.push(`accepted:${data}`)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
const firstFlush = buffer.flush(writer)
|
||||
const overlappingFlush = buffer.flush(writer)
|
||||
|
||||
expect(overlappingFlush).toBe(firstFlush)
|
||||
await flushAsyncTicks()
|
||||
expect(delivered).toEqual([])
|
||||
|
||||
acceptedWrite.resolve(true)
|
||||
await expect(accepted).resolves.toBe(true)
|
||||
await Promise.all([firstFlush, overlappingFlush])
|
||||
|
||||
expect(delivered).toEqual(['accepted:first', 'ordinary:second', 'immediate:third'])
|
||||
})
|
||||
|
||||
it.each(['resolve', 'reject'] as const)(
|
||||
'clear settles an in-flight accepted write before its late %s',
|
||||
async (lateOutcome) => {
|
||||
const acceptedWrite = createDeferred<boolean>()
|
||||
const writeStarted = createDeferred<void>()
|
||||
const buffer = createPtyPreconnectInputBuffer()
|
||||
const sendInput = vi.fn(() => true)
|
||||
const accepted = buffer.enqueueAccepted('first')
|
||||
const laterAccepted = buffer.enqueueAccepted('third')
|
||||
expect(buffer.enqueue('second', 'ordinary')).toBe(true)
|
||||
const flushing = buffer.flush({
|
||||
isCurrent: () => true,
|
||||
sendInput,
|
||||
sendInputImmediate: () => true,
|
||||
sendInputAccepted: async () => {
|
||||
writeStarted.resolve()
|
||||
return acceptedWrite.promise
|
||||
}
|
||||
})
|
||||
await writeStarted.promise
|
||||
|
||||
buffer.clear()
|
||||
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
await expect(laterAccepted).resolves.toBe(false)
|
||||
await expect(flushing).resolves.toBeUndefined()
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
expect(buffer.enqueue('after-clear', 'ordinary')).toBe(false)
|
||||
|
||||
if (lateOutcome === 'resolve') {
|
||||
acceptedWrite.resolve(true)
|
||||
} else {
|
||||
acceptedWrite.reject(new Error('late accepted write failure'))
|
||||
}
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('counts an in-flight accepted write toward both retention caps', async () => {
|
||||
const codeUnitWrite = createDeferred<boolean>()
|
||||
const codeUnitWriteStarted = createDeferred<void>()
|
||||
const codeUnitBuffer = createPtyPreconnectInputBuffer()
|
||||
const codeUnitAccepted = codeUnitBuffer.enqueueAccepted(
|
||||
'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS)
|
||||
)
|
||||
const codeUnitFlush = codeUnitBuffer.flush({
|
||||
isCurrent: () => true,
|
||||
sendInput: () => true,
|
||||
sendInputImmediate: () => true,
|
||||
sendInputAccepted: async () => {
|
||||
codeUnitWriteStarted.resolve()
|
||||
return codeUnitWrite.promise
|
||||
}
|
||||
})
|
||||
await codeUnitWriteStarted.promise
|
||||
|
||||
expect(codeUnitBuffer.enqueue('overflow', 'ordinary')).toBe(false)
|
||||
|
||||
codeUnitBuffer.clear()
|
||||
codeUnitWrite.resolve(true)
|
||||
await expect(codeUnitAccepted).resolves.toBe(false)
|
||||
await codeUnitFlush
|
||||
|
||||
const entryWrite = createDeferred<boolean>()
|
||||
const entryWriteStarted = createDeferred<void>()
|
||||
const entryBuffer = createPtyPreconnectInputBuffer()
|
||||
const entryAccepted = entryBuffer.enqueueAccepted('first')
|
||||
for (let index = 1; index < PTY_PRECONNECT_INPUT_MAX_ENTRIES; index += 1) {
|
||||
expect(entryBuffer.enqueue('', 'ordinary')).toBe(true)
|
||||
}
|
||||
const entryFlush = entryBuffer.flush({
|
||||
isCurrent: () => true,
|
||||
sendInput: () => true,
|
||||
sendInputImmediate: () => true,
|
||||
sendInputAccepted: async () => {
|
||||
entryWriteStarted.resolve()
|
||||
return entryWrite.promise
|
||||
}
|
||||
})
|
||||
await entryWriteStarted.promise
|
||||
|
||||
expect(entryBuffer.enqueue('', 'ordinary')).toBe(false)
|
||||
|
||||
entryBuffer.clear()
|
||||
entryWrite.resolve(true)
|
||||
await expect(entryAccepted).resolves.toBe(false)
|
||||
await entryFlush
|
||||
})
|
||||
|
||||
it('applies entry and code-unit caps to seeded input', async () => {
|
||||
const entryBuffer = createPtyPreconnectInputBuffer(
|
||||
Array.from({ length: PTY_PRECONNECT_INPUT_MAX_ENTRIES + 1 }, () => ({
|
||||
data: '',
|
||||
kind: 'ordinary' as const
|
||||
}))
|
||||
)
|
||||
let entryWrites = 0
|
||||
|
||||
expect(entryBuffer.enqueue('', 'ordinary')).toBe(false)
|
||||
await entryBuffer.flush({
|
||||
isCurrent: () => true,
|
||||
sendInput: () => {
|
||||
entryWrites += 1
|
||||
return true
|
||||
},
|
||||
sendInputImmediate: () => true
|
||||
})
|
||||
expect(entryWrites).toBe(PTY_PRECONNECT_INPUT_MAX_ENTRIES)
|
||||
|
||||
const codeUnitBuffer = createPtyPreconnectInputBuffer([
|
||||
{ data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), kind: 'ordinary' },
|
||||
{ data: 'overflow', kind: 'ordinary' }
|
||||
])
|
||||
const codeUnitWrites: number[] = []
|
||||
|
||||
expect(codeUnitBuffer.enqueue('new', 'ordinary')).toBe(false)
|
||||
await codeUnitBuffer.flush({
|
||||
isCurrent: () => true,
|
||||
sendInput: (data) => {
|
||||
codeUnitWrites.push(data.length)
|
||||
return true
|
||||
},
|
||||
sendInputImmediate: () => true
|
||||
})
|
||||
expect(codeUnitWrites).toEqual([PTY_PRECONNECT_INPUT_MAX_CODE_UNITS])
|
||||
})
|
||||
|
||||
it('settles retained acknowledged input when the spawn is abandoned before connect', async () => {
|
||||
// Regression: an abandoned deferred spawn never reaches connect(), so nothing
|
||||
// drained the buffer and a paste awaiting sendInputAccepted hung forever.
|
||||
const buffer = createPtyPreconnectInputBuffer()
|
||||
const pastePending = buffer.enqueueAccepted('pasted text')
|
||||
let settled = false
|
||||
void pastePending.then(() => {
|
||||
settled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
buffer.clear()
|
||||
|
||||
await expect(pastePending).resolves.toBe(false)
|
||||
expect(buffer.isBuffering()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text'
|
||||
|
||||
export const PTY_PRECONNECT_INPUT_MAX_ENTRIES = 1024
|
||||
// Why: a retention budget, not the 16MB single-write ceiling. The deferral lasts
|
||||
// under a second and this is held twice (transport buffer + handoff record) across
|
||||
// up to 64 deferred splits, so size it at a few clipboard-sized pastes.
|
||||
export const PTY_PRECONNECT_INPUT_MAX_CODE_UNITS = 4 * CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS
|
||||
|
||||
export type PtyPreconnectInputKind = 'ordinary' | 'immediate' | 'accepted'
|
||||
|
||||
/** Input retained while a pane waits for its first PTY connection. */
|
||||
export type PtyPreconnectInputEntry = {
|
||||
data: string
|
||||
kind: PtyPreconnectInputKind
|
||||
}
|
||||
|
||||
type BufferedInput = PtyPreconnectInputEntry & {
|
||||
resolve?: (accepted: boolean) => void
|
||||
}
|
||||
|
||||
type PreconnectInputWriter = {
|
||||
isCurrent: () => boolean
|
||||
sendInput: (data: string) => boolean
|
||||
sendInputImmediate: (data: string) => boolean
|
||||
sendInputAccepted?: (data: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export type PtyPreconnectInputBuffer = {
|
||||
isBuffering: () => boolean
|
||||
enqueue: (
|
||||
data: string,
|
||||
kind: 'ordinary' | 'immediate',
|
||||
onRetained?: (entry: PtyPreconnectInputEntry) => void
|
||||
) => boolean
|
||||
enqueueAccepted: (
|
||||
data: string,
|
||||
onRetained?: (entry: PtyPreconnectInputEntry) => void
|
||||
) => Promise<boolean>
|
||||
flush: (writer: PreconnectInputWriter) => Promise<void>
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export function createPtyPreconnectInputBuffer(
|
||||
initialEntries: readonly PtyPreconnectInputEntry[] = []
|
||||
): PtyPreconnectInputBuffer {
|
||||
let pending: BufferedInput[] = []
|
||||
let pendingCodeUnits = 0
|
||||
let buffering = true
|
||||
let activeAcceptedInput: BufferedInput | null = null
|
||||
let activeFlush: Promise<void> | null = null
|
||||
let stopFlush!: () => void
|
||||
const flushStopped = new Promise<void>((resolve) => {
|
||||
stopFlush = resolve
|
||||
})
|
||||
|
||||
const retain = (input: BufferedInput): boolean => {
|
||||
const activeEntries = activeAcceptedInput ? 1 : 0
|
||||
const activeCodeUnits = activeAcceptedInput?.data.length ?? 0
|
||||
if (
|
||||
!buffering ||
|
||||
pending.length + activeEntries >= PTY_PRECONNECT_INPUT_MAX_ENTRIES ||
|
||||
input.data.length > PTY_PRECONNECT_INPUT_MAX_CODE_UNITS - pendingCodeUnits - activeCodeUnits
|
||||
) {
|
||||
return false
|
||||
}
|
||||
pending.push(input)
|
||||
pendingCodeUnits += input.data.length
|
||||
return true
|
||||
}
|
||||
const createInput = (
|
||||
data: string,
|
||||
kind: PtyPreconnectInputKind,
|
||||
resolve?: BufferedInput['resolve']
|
||||
): BufferedInput => ({ data, kind, ...(resolve ? { resolve } : {}) })
|
||||
const notifyRetained = (
|
||||
onRetained: ((entry: PtyPreconnectInputEntry) => void) | undefined,
|
||||
input: BufferedInput
|
||||
): void => {
|
||||
try {
|
||||
onRetained?.({ data: input.data, kind: input.kind })
|
||||
} catch {
|
||||
// Handoff capture is advisory; a callback failure must not reject input admission.
|
||||
}
|
||||
}
|
||||
|
||||
// Seeded entries came from a predecessor transport and must not be reported
|
||||
// back to that predecessor's handoff owner as newly typed input.
|
||||
for (const entry of initialEntries) {
|
||||
retain(createInput(entry.data, entry.kind))
|
||||
}
|
||||
const clear = (): void => {
|
||||
const dropped = pending
|
||||
pending = []
|
||||
pendingCodeUnits = 0
|
||||
buffering = false
|
||||
const inFlight = activeAcceptedInput
|
||||
activeAcceptedInput = null
|
||||
inFlight?.resolve?.(false)
|
||||
for (const input of dropped) {
|
||||
input.resolve?.(false)
|
||||
}
|
||||
stopFlush()
|
||||
}
|
||||
|
||||
const runFlush = async (writer: PreconnectInputWriter): Promise<void> => {
|
||||
try {
|
||||
while (buffering && pending.length > 0) {
|
||||
const input = pending.shift()
|
||||
if (!input) {
|
||||
continue
|
||||
}
|
||||
pendingCodeUnits -= input.data.length
|
||||
if (input.kind === 'accepted') {
|
||||
activeAcceptedInput = input
|
||||
}
|
||||
if (!buffering || !writer.isCurrent()) {
|
||||
input.resolve?.(false)
|
||||
clear()
|
||||
return
|
||||
}
|
||||
if (input.kind === 'accepted') {
|
||||
let accepted: boolean | null = null
|
||||
try {
|
||||
accepted = await Promise.race([
|
||||
Promise.resolve(
|
||||
writer.sendInputAccepted
|
||||
? writer.sendInputAccepted(input.data)
|
||||
: writer.sendInput(input.data)
|
||||
),
|
||||
flushStopped.then(() => null)
|
||||
])
|
||||
} catch {
|
||||
input.resolve?.(false)
|
||||
clear()
|
||||
return
|
||||
} finally {
|
||||
if (activeAcceptedInput === input) {
|
||||
activeAcceptedInput = null
|
||||
}
|
||||
}
|
||||
if (!buffering || accepted === null) {
|
||||
input.resolve?.(false)
|
||||
return
|
||||
}
|
||||
input.resolve?.(accepted)
|
||||
if (!accepted) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
const accepted =
|
||||
input.kind === 'immediate'
|
||||
? writer.sendInputImmediate(input.data)
|
||||
: writer.sendInput(input.data)
|
||||
if (!accepted) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
}
|
||||
buffering = false
|
||||
stopFlush()
|
||||
} catch (error) {
|
||||
clear()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const flush = (writer: PreconnectInputWriter): Promise<void> => {
|
||||
if (activeFlush) {
|
||||
return activeFlush
|
||||
}
|
||||
if (!buffering) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
const flushPromise = Promise.resolve().then(() => runFlush(writer))
|
||||
activeFlush = flushPromise
|
||||
const releaseFlight = (): void => {
|
||||
if (activeFlush === flushPromise) {
|
||||
activeFlush = null
|
||||
}
|
||||
}
|
||||
void flushPromise.then(releaseFlight, releaseFlight)
|
||||
return flushPromise
|
||||
}
|
||||
|
||||
return {
|
||||
isBuffering: () => buffering,
|
||||
enqueue(data, kind, onRetained) {
|
||||
const input = createInput(data, kind)
|
||||
const retained = retain(input)
|
||||
if (retained) {
|
||||
notifyRetained(onRetained, input)
|
||||
}
|
||||
return retained
|
||||
},
|
||||
enqueueAccepted(data, onRetained) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const input = createInput(data, 'accepted', resolve)
|
||||
if (!retain(input)) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
notifyRetained(onRetained, input)
|
||||
})
|
||||
},
|
||||
flush,
|
||||
clear
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,31 @@ import {
|
||||
} from '../../../../shared/terminal-input'
|
||||
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text'
|
||||
import { PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES } from './pty-input-write-queue'
|
||||
import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness'
|
||||
import { createDeferred, flushAsyncTicks } from './pty-connection-test-async'
|
||||
import {
|
||||
PTY_PRECONNECT_INPUT_MAX_CODE_UNITS,
|
||||
PTY_PRECONNECT_INPUT_MAX_ENTRIES,
|
||||
type PtyPreconnectInputEntry
|
||||
} from './pty-preconnect-input-buffer'
|
||||
import {
|
||||
installIpcPtyWindow,
|
||||
restorePtySpecWindow,
|
||||
type PtyExitPayload
|
||||
} from './pty-transport-test-harness'
|
||||
|
||||
describe('createIpcPtyTransport', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
let onWriteUnavailable: ((payload: { id: string }) => void) | null = null
|
||||
let onExit: ((payload: PtyExitPayload) => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
onWriteUnavailable = null
|
||||
onExit = null
|
||||
installIpcPtyWindow(originalWindow, {
|
||||
exit: (callback) => {
|
||||
onExit = callback
|
||||
},
|
||||
writeUnavailable: (callback) => {
|
||||
onWriteUnavailable = callback
|
||||
}
|
||||
@@ -73,6 +88,640 @@ describe('createIpcPtyTransport', () => {
|
||||
expect(sshTransport.sendInputAccepted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('flushes ordinary, accepted, and immediate split input in byte order', async () => {
|
||||
const spawn = createDeferred<{ id: string }>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise as never)
|
||||
const delivered: string[] = []
|
||||
vi.mocked(window.api.pty.write).mockImplementation((_id, data) => {
|
||||
delivered.push(data)
|
||||
})
|
||||
vi.mocked(window.api.pty.writeAccepted).mockImplementation(async (_id, data) => {
|
||||
delivered.push(data)
|
||||
return true
|
||||
})
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
|
||||
const connecting = transport.connect({ url: '', callbacks: {} })
|
||||
expect(transport.sendInput('first-')).toBe(true)
|
||||
const accepted = transport.sendInputAccepted?.('second-')
|
||||
expect(transport.sendInputImmediate('third')).toBe(true)
|
||||
expect(window.api.pty.write).not.toHaveBeenCalled()
|
||||
expect(window.api.pty.writeAccepted).not.toHaveBeenCalled()
|
||||
|
||||
spawn.resolve({ id: 'pty-1' })
|
||||
await connecting
|
||||
await expect(accepted).resolves.toBe(true)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(delivered).toEqual(['first-', 'second-', 'third'])
|
||||
})
|
||||
|
||||
it('flushes remount-handoff input before newly typed input without recapturing the seed', async () => {
|
||||
const spawn = createDeferred<{ id: string }>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise as never)
|
||||
const delivered: string[] = []
|
||||
vi.mocked(window.api.pty.write).mockImplementation((_id, data) => {
|
||||
delivered.push(data)
|
||||
})
|
||||
vi.mocked(window.api.pty.writeAccepted).mockImplementation(async (_id, data) => {
|
||||
delivered.push(data)
|
||||
return true
|
||||
})
|
||||
const onPreconnectInput = vi.fn()
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({
|
||||
bufferInputUntilConnect: true,
|
||||
preconnectInput: [
|
||||
{ data: 'before-remount-', kind: 'ordinary' },
|
||||
{ data: '\x1b[0n', kind: 'immediate' },
|
||||
{ data: '\x03', kind: 'accepted' }
|
||||
],
|
||||
onPreconnectInput
|
||||
})
|
||||
|
||||
const connecting = transport.connect({ url: '', callbacks: {} })
|
||||
expect(transport.sendInput('new-ordinary-')).toBe(true)
|
||||
expect(transport.sendInputImmediate('new-immediate-')).toBe(true)
|
||||
const accepted = transport.sendInputAccepted?.('new-accepted')
|
||||
expect(onPreconnectInput.mock.calls).toEqual([
|
||||
[{ data: 'new-ordinary-', kind: 'ordinary' }],
|
||||
[{ data: 'new-immediate-', kind: 'immediate' }],
|
||||
[{ data: 'new-accepted', kind: 'accepted' }]
|
||||
])
|
||||
|
||||
spawn.resolve({ id: 'pty-1' })
|
||||
await connecting
|
||||
await expect(accepted).resolves.toBe(true)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(delivered).toEqual([
|
||||
'before-remount-',
|
||||
'\x1b[0n',
|
||||
'\x03',
|
||||
'new-ordinary-',
|
||||
'new-immediate-',
|
||||
'new-accepted'
|
||||
])
|
||||
})
|
||||
|
||||
it('settles a predecessor accepted write while its successor replays the captured bytes', async () => {
|
||||
const spawn = createDeferred<{ id: string }>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise as never)
|
||||
const delivered: string[] = []
|
||||
vi.mocked(window.api.pty.writeAccepted).mockImplementation(async (_id, data) => {
|
||||
delivered.push(data)
|
||||
return true
|
||||
})
|
||||
const captured: PtyPreconnectInputEntry[] = []
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const predecessor = createIpcPtyTransport({
|
||||
bufferInputUntilConnect: true,
|
||||
onPreconnectInput: (input) => captured.push(input)
|
||||
})
|
||||
|
||||
const predecessorAccepted = predecessor.sendInputAccepted?.('\x03')
|
||||
expect(captured).toEqual([{ data: '\x03', kind: 'accepted' }])
|
||||
|
||||
const successor = createIpcPtyTransport({ preconnectInput: captured })
|
||||
const connecting = successor.connect({ url: '', callbacks: {} })
|
||||
await predecessor.destroy?.()
|
||||
|
||||
await expect(predecessorAccepted).resolves.toBe(false)
|
||||
spawn.resolve({ id: 'pty-1' })
|
||||
await connecting
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(delivered).toEqual(['\x03'])
|
||||
})
|
||||
|
||||
it('contains capture callback failures without rejecting retained input', async () => {
|
||||
const onPreconnectInput = vi.fn(() => {
|
||||
throw new Error('capture failed')
|
||||
})
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({
|
||||
bufferInputUntilConnect: true,
|
||||
onPreconnectInput
|
||||
})
|
||||
|
||||
expect(transport.sendInput('ordinary')).toBe(true)
|
||||
expect(transport.sendInputImmediate('immediate')).toBe(true)
|
||||
const accepted = transport.sendInputAccepted?.('accepted')
|
||||
expect(onPreconnectInput).toHaveBeenCalledTimes(3)
|
||||
|
||||
await transport.connect({ url: '', callbacks: {} })
|
||||
|
||||
await expect(accepted).resolves.toBe(true)
|
||||
expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'ordinary')
|
||||
expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'immediate')
|
||||
expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'accepted')
|
||||
})
|
||||
|
||||
it('keeps live acknowledged input ahead of later ordinary and immediate writes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const acceptedWrite = createDeferred<boolean>()
|
||||
const delivered: string[] = []
|
||||
vi.mocked(window.api.pty.write).mockImplementation((_id, data) => {
|
||||
delivered.push(`ordinary:${data}`)
|
||||
})
|
||||
vi.mocked(window.api.pty.writeAccepted).mockImplementation(async (_id, data) => {
|
||||
delivered.push(`accepted:${data}`)
|
||||
return acceptedWrite.promise
|
||||
})
|
||||
|
||||
try {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({})
|
||||
await transport.connect({ url: '', callbacks: {} })
|
||||
|
||||
expect(transport.sendInput('first')).toBe(true)
|
||||
const accepted = transport.sendInputAccepted?.('second')
|
||||
expect(transport.sendInput('third')).toBe(true)
|
||||
expect(transport.sendInputImmediate('fourth')).toBe(true)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(delivered).toEqual(['ordinary:first', 'accepted:second'])
|
||||
|
||||
acceptedWrite.resolve(true)
|
||||
await vi.runAllTimersAsync()
|
||||
await expect(accepted).resolves.toBe(true)
|
||||
|
||||
expect(delivered).toEqual([
|
||||
'ordinary:first',
|
||||
'accepted:second',
|
||||
'ordinary:third',
|
||||
'ordinary:fourth'
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves queue order across the preconnect-to-live boundary', async () => {
|
||||
vi.useFakeTimers()
|
||||
const delivered: { data: string; kind: 'ordinary' | 'accepted' }[] = []
|
||||
vi.mocked(window.api.pty.write).mockImplementation((_id, data) => {
|
||||
delivered.push({ data, kind: 'ordinary' })
|
||||
})
|
||||
vi.mocked(window.api.pty.writeAccepted).mockImplementation(async (_id, data) => {
|
||||
delivered.push({ data, kind: 'accepted' })
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
const slowInput = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
|
||||
|
||||
const connecting = transport.connect({ url: '', callbacks: {} })
|
||||
expect(transport.sendInput(slowInput)).toBe(true)
|
||||
await connecting
|
||||
|
||||
const accepted = transport.sendInputAccepted?.('accepted')
|
||||
expect(transport.sendInput('later')).toBe(true)
|
||||
expect(transport.sendInputImmediate('reply')).toBe(true)
|
||||
expect(delivered).toEqual([])
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
await expect(accepted).resolves.toBe(true)
|
||||
|
||||
const acceptedIndex = delivered.findIndex((entry) => entry.kind === 'accepted')
|
||||
expect(acceptedIndex).toBeGreaterThan(0)
|
||||
expect(
|
||||
delivered
|
||||
.slice(0, acceptedIndex)
|
||||
.map((entry) => entry.data)
|
||||
.join('')
|
||||
).toBe(slowInput)
|
||||
expect(delivered.slice(acceptedIndex)).toEqual([
|
||||
{ data: 'accepted', kind: 'accepted' },
|
||||
{ data: 'later', kind: 'ordinary' },
|
||||
{ data: 'reply', kind: 'ordinary' }
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a resolved split cwd through local recovery metadata', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const options = { cwd: '/fallback', bufferInputUntilConnect: true }
|
||||
const transport = createIpcPtyTransport(options)
|
||||
|
||||
options.cwd = '/resolved/source-cwd'
|
||||
|
||||
expect(transport.getLocalSessionMetadata?.()).toEqual({ cwd: '/resolved/source-cwd' })
|
||||
})
|
||||
|
||||
it('settles and drops preconnect input when the split transport is destroyed', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
|
||||
expect(transport.sendInput('ordinary')).toBe(true)
|
||||
const accepted = transport.sendInputAccepted?.('accepted')
|
||||
await transport.destroy?.()
|
||||
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
expect(transport.sendInput('after-destroy')).toBe(false)
|
||||
expect(transport.sendInputImmediate('after-destroy')).toBe(false)
|
||||
await expect(transport.sendInputAccepted?.('after-destroy')).resolves.toBe(false)
|
||||
expect(window.api.pty.write).not.toHaveBeenCalled()
|
||||
expect(window.api.pty.writeAccepted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['disconnect', 'destroy', 'exit'] as const)(
|
||||
'cancels an in-flight preconnect accepted write on %s',
|
||||
async (teardown) => {
|
||||
const spawn = createDeferred<{ id: string }>()
|
||||
const acceptedWrite = createDeferred<boolean>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise as never)
|
||||
vi.mocked(window.api.pty.writeAccepted).mockReturnValue(acceptedWrite.promise)
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
|
||||
const connecting = transport.connect({ url: '', callbacks: {} })
|
||||
const accepted = transport.sendInputAccepted?.('first')
|
||||
expect(transport.sendInput('later')).toBe(true)
|
||||
spawn.resolve({ id: 'pty-1' })
|
||||
await flushAsyncTicks()
|
||||
expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'first')
|
||||
|
||||
if (teardown === 'disconnect') {
|
||||
transport.disconnect()
|
||||
} else if (teardown === 'destroy') {
|
||||
transport.destroy?.()
|
||||
} else {
|
||||
onExit?.({ id: 'pty-1', code: 0 })
|
||||
}
|
||||
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
await expect(connecting).resolves.toBe('pty-1')
|
||||
expect(window.api.pty.write).not.toHaveBeenCalled()
|
||||
expect(transport.sendInput('after-teardown')).toBe(false)
|
||||
|
||||
acceptedWrite.resolve(true)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(window.api.pty.write).not.toHaveBeenCalled()
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['disconnect', 'detach', 'exit'] as const)(
|
||||
'cancels an in-flight live accepted write on %s',
|
||||
async (teardown) => {
|
||||
const acceptedWrite = createDeferred<boolean>()
|
||||
vi.mocked(window.api.pty.writeAccepted).mockReturnValue(acceptedWrite.promise)
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({})
|
||||
await transport.connect({ url: '', callbacks: {} })
|
||||
|
||||
const accepted = transport.sendInputAccepted?.('first')
|
||||
expect(transport.sendInput('later')).toBe(true)
|
||||
await flushAsyncTicks()
|
||||
expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'first')
|
||||
|
||||
if (teardown === 'disconnect') {
|
||||
transport.disconnect()
|
||||
} else if (teardown === 'detach') {
|
||||
transport.detach?.()
|
||||
} else {
|
||||
onExit?.({ id: 'pty-1', code: 0 })
|
||||
}
|
||||
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
expect(window.api.pty.write).not.toHaveBeenCalled()
|
||||
expect(transport.sendInput('after-teardown')).toBe(false)
|
||||
|
||||
acceptedWrite.resolve(true)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(window.api.pty.write).not.toHaveBeenCalled()
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['disconnect', 'detach'] as const)(
|
||||
'retires a late fresh spawn after %s invalidates its connect',
|
||||
async (teardown) => {
|
||||
const spawn = createDeferred<{ id: string }>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise as never)
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
const onConnect = vi.fn()
|
||||
|
||||
const connecting = transport.connect({ url: '', callbacks: { onConnect } })
|
||||
const accepted = transport.sendInputAccepted?.('pending')
|
||||
if (teardown === 'disconnect') {
|
||||
transport.disconnect()
|
||||
} else {
|
||||
transport.detach?.()
|
||||
}
|
||||
|
||||
spawn.resolve({ id: 'pty-late' })
|
||||
|
||||
await expect(connecting).resolves.toBeUndefined()
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith('pty-late')
|
||||
expect(onConnect).not.toHaveBeenCalled()
|
||||
expect(transport.isConnected()).toBe(false)
|
||||
expect(transport.getPtyId()).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not retire a stale fresh spawn id owned by a newer attach', async () => {
|
||||
const spawn = createDeferred<{ id: string }>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise as never)
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
|
||||
const connecting = transport.connect({ url: '', callbacks: {} })
|
||||
transport.attach({ existingPtyId: 'pty-reused', callbacks: {} })
|
||||
spawn.resolve({ id: 'pty-reused' })
|
||||
|
||||
await expect(connecting).resolves.toBeUndefined()
|
||||
expect(window.api.pty.kill).not.toHaveBeenCalled()
|
||||
expect(transport.isConnected()).toBe(true)
|
||||
expect(transport.getPtyId()).toBe('pty-reused')
|
||||
})
|
||||
|
||||
it.each(['disconnect', 'detach'] as const)(
|
||||
'drops a late spawn error after %s invalidates its connect',
|
||||
async (teardown) => {
|
||||
const spawn = createDeferred<{ id: string }>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise as never)
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
const staleOnError = vi.fn()
|
||||
const currentOnError = vi.fn()
|
||||
|
||||
const connecting = transport.connect({ url: '', callbacks: { onError: staleOnError } })
|
||||
if (teardown === 'disconnect') {
|
||||
transport.disconnect()
|
||||
} else {
|
||||
transport.detach?.()
|
||||
}
|
||||
transport.attach({ existingPtyId: 'pty-current', callbacks: { onError: currentOnError } })
|
||||
|
||||
spawn.reject(new Error('late spawn failed'))
|
||||
|
||||
await expect(connecting).resolves.toBeUndefined()
|
||||
expect(staleOnError).not.toHaveBeenCalled()
|
||||
expect(currentOnError).not.toHaveBeenCalled()
|
||||
expect(transport.getPtyId()).toBe('pty-current')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['disconnect', 'detach'] as const)(
|
||||
'does not surface a rejected spawn after %s invalidates its connect',
|
||||
async (teardown) => {
|
||||
const spawn = createDeferred<{ id: string }>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise as never)
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
const onError = vi.fn()
|
||||
|
||||
const connecting = transport.connect({ url: '', callbacks: { onError } })
|
||||
if (teardown === 'disconnect') {
|
||||
transport.disconnect()
|
||||
} else {
|
||||
transport.detach?.()
|
||||
}
|
||||
|
||||
spawn.reject(new Error('late spawn failed'))
|
||||
|
||||
await expect(connecting).resolves.toBeUndefined()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(transport.isConnected()).toBe(false)
|
||||
expect(transport.getPtyId()).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not leave stale exit handlers when onPtySpawn tears down synchronously', async () => {
|
||||
const onExitCallback = vi.fn()
|
||||
const onPtyExit = vi.fn()
|
||||
let transport: ReturnType<typeof createIpcPtyTransport> | undefined
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
transport = createIpcPtyTransport({
|
||||
onPtySpawn: () => transport?.disconnect(),
|
||||
onPtyExit
|
||||
})
|
||||
|
||||
const connecting = transport.connect({
|
||||
url: '',
|
||||
callbacks: { onExit: onExitCallback }
|
||||
})
|
||||
await expect(connecting).resolves.toBeUndefined()
|
||||
|
||||
onExit?.({ id: 'pty-1', code: 0 })
|
||||
|
||||
expect(onExitCallback).not.toHaveBeenCalled()
|
||||
expect(onPtyExit).not.toHaveBeenCalled()
|
||||
expect(transport.getPtyId()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not return a stale exitedBeforeAttach result after its exit callback tears down', async () => {
|
||||
const { bufferPreHandlerPtyExit, clearPreHandlerPtyState } =
|
||||
await import('./pty-pre-handler-buffer')
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const sessionId = 'pty-buffered-exit-teardown'
|
||||
bufferPreHandlerPtyExit(sessionId, 0)
|
||||
let transport: ReturnType<typeof createIpcPtyTransport> | undefined
|
||||
const onExitCallback = vi.fn(() => transport?.disconnect())
|
||||
transport = createIpcPtyTransport({})
|
||||
|
||||
try {
|
||||
await expect(
|
||||
transport.connect({ url: '', sessionId, callbacks: { onExit: onExitCallback } })
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
onExit?.({ id: sessionId, code: 0 })
|
||||
expect(onExitCallback).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
clearPreHandlerPtyState(sessionId)
|
||||
}
|
||||
})
|
||||
|
||||
it('fences stale queued chunks when natural exit reuses the same pty id', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({})
|
||||
const chunk = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)
|
||||
await transport.connect({ url: '', callbacks: {} })
|
||||
|
||||
expect(transport.sendInput(`${chunk}${chunk}`)).toBe(true)
|
||||
expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith('pty-1', chunk)
|
||||
|
||||
onExit?.({ id: 'pty-1', code: 0 })
|
||||
transport.attach({ existingPtyId: 'pty-1', callbacks: {} })
|
||||
expect(transport.sendInput('fresh')).toBe(true)
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([
|
||||
['pty-1', chunk],
|
||||
['pty-1', 'fresh']
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('fences stale accepted chunks when natural exit reuses the same pty id', async () => {
|
||||
const acceptedWrite = createDeferred<boolean>()
|
||||
vi.mocked(window.api.pty.writeAccepted).mockReturnValueOnce(acceptedWrite.promise)
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({})
|
||||
const chunk = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)
|
||||
await transport.connect({ url: '', callbacks: {} })
|
||||
|
||||
const accepted = transport.sendInputAccepted?.(`${chunk}stale-tail`)
|
||||
await flushAsyncTicks()
|
||||
expect(window.api.pty.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', chunk)
|
||||
|
||||
onExit?.({ id: 'pty-1', code: 0 })
|
||||
transport.attach({ existingPtyId: 'pty-1', callbacks: {} })
|
||||
expect(transport.sendInput('fresh')).toBe(true)
|
||||
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
await flushAsyncTicks()
|
||||
expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith('pty-1', 'fresh')
|
||||
|
||||
acceptedWrite.resolve(true)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(window.api.pty.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', chunk)
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('settles buffered input once and does not re-arm after disconnect', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
|
||||
expect(transport.sendInputImmediate('immediate')).toBe(true)
|
||||
const accepted = transport.sendInputAccepted?.('accepted')
|
||||
transport.disconnect()
|
||||
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
expect(transport.sendInput('after-disconnect')).toBe(false)
|
||||
expect(transport.sendInputImmediate('after-disconnect')).toBe(false)
|
||||
await expect(transport.sendInputAccepted?.('after-disconnect')).resolves.toBe(false)
|
||||
expect(window.api.pty.write).not.toHaveBeenCalled()
|
||||
expect(window.api.pty.writeAccepted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears preconnect input when attach observes a buffered exit', async () => {
|
||||
const ptyId = 'pty-exited-before-attach-with-input'
|
||||
const { bufferPreHandlerPtyExit, clearPreHandlerPtyState } =
|
||||
await import('./pty-pre-handler-buffer')
|
||||
bufferPreHandlerPtyExit(ptyId, 0)
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
const accepted = transport.sendInputAccepted?.('input')
|
||||
|
||||
transport.attach({ existingPtyId: ptyId, callbacks: {} })
|
||||
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
expect(transport.isConnected()).toBe(false)
|
||||
expect(transport.sendInput('after-exit')).toBe(false)
|
||||
clearPreHandlerPtyState(ptyId)
|
||||
})
|
||||
|
||||
it('clears preconnect input when attach throws before binding', async () => {
|
||||
vi.mocked(window.api.pty.onData).mockImplementationOnce(() => {
|
||||
throw new Error('dispatcher attach failed')
|
||||
})
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
const accepted = transport.sendInputAccepted?.('input')
|
||||
|
||||
expect(() => transport.attach({ existingPtyId: 'pty-attach-failure', callbacks: {} })).toThrow(
|
||||
'dispatcher attach failed'
|
||||
)
|
||||
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
expect(transport.isConnected()).toBe(false)
|
||||
expect(transport.sendInput('after-failure')).toBe(false)
|
||||
})
|
||||
|
||||
it('settles and drops preconnect input when the split spawn fails', async () => {
|
||||
vi.mocked(window.api.pty.spawn).mockRejectedValue(new Error('spawn failed'))
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
const onError = vi.fn()
|
||||
|
||||
expect(transport.sendInput('ordinary')).toBe(true)
|
||||
const accepted = transport.sendInputAccepted?.('accepted')
|
||||
await transport.connect({ url: '', callbacks: { onError } })
|
||||
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
expect(transport.sendInput('after-failure')).toBe(false)
|
||||
expect(transport.sendInputImmediate('after-failure')).toBe(false)
|
||||
await expect(transport.sendInputAccepted?.('after-failure')).resolves.toBe(false)
|
||||
expect(onError).toHaveBeenCalledWith('spawn failed')
|
||||
expect(window.api.pty.write).not.toHaveBeenCalled()
|
||||
expect(window.api.pty.writeAccepted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops later preconnect input when an acknowledged write fails', async () => {
|
||||
const spawn = createDeferred<{ id: string }>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise as never)
|
||||
vi.mocked(window.api.pty.writeAccepted).mockRejectedValue(new Error('write failed'))
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
|
||||
const connecting = transport.connect({ url: '', callbacks: {} })
|
||||
expect(transport.sendInput('first')).toBe(true)
|
||||
const accepted = transport.sendInputAccepted?.('second')
|
||||
expect(transport.sendInput('third')).toBe(true)
|
||||
|
||||
spawn.resolve({ id: 'pty-1' })
|
||||
await connecting
|
||||
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
expect(window.api.pty.write).toHaveBeenCalledOnce()
|
||||
expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'first')
|
||||
})
|
||||
|
||||
it('drops acknowledged preconnect input when an earlier ordinary write fails', async () => {
|
||||
const failure = new Error('ordinary write failed')
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
vi.mocked(window.api.pty.write).mockImplementationOnce(() => {
|
||||
throw failure
|
||||
})
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
|
||||
try {
|
||||
expect(transport.sendInput('first')).toBe(true)
|
||||
const accepted = transport.sendInputAccepted?.('second')
|
||||
await transport.connect({ url: '', callbacks: {} })
|
||||
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
expect(window.api.pty.writeAccepted).not.toHaveBeenCalled()
|
||||
expect(warn).toHaveBeenCalledWith('[pty-input-write-queue] drain failed:', failure)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds input retained before a split connects', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
|
||||
for (let index = 0; index < PTY_PRECONNECT_INPUT_MAX_ENTRIES; index += 1) {
|
||||
expect(transport.sendInput('x')).toBe(true)
|
||||
}
|
||||
expect(transport.sendInput('overflow')).toBe(false)
|
||||
|
||||
const oversized = createIpcPtyTransport({ bufferInputUntilConnect: true })
|
||||
expect(oversized.sendInput('x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS + 1))).toBe(false)
|
||||
await transport.destroy?.()
|
||||
await oversized.destroy?.()
|
||||
})
|
||||
|
||||
it('chunks large local IPC terminal input before renderer-to-main writes', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { PtyDataMeta } from './pty-dispatcher'
|
||||
import type { RemoteRuntimeSnapshotOutcome } from '../../runtime/remote-runtime-terminal-multiplexer'
|
||||
import type { PtyPreconnectInputEntry } from './pty-preconnect-input-buffer'
|
||||
|
||||
export type PtyBufferSnapshot = {
|
||||
data: string
|
||||
@@ -180,6 +181,8 @@ export type PtyTransport = {
|
||||
// (preserving order) and sends the reply immediately.
|
||||
sendInputImmediate: (data: string) => boolean
|
||||
sendInputAccepted?: (data: string) => Promise<boolean>
|
||||
/** Settles retained pre-connect input when a deferred spawn is abandoned before connect. */
|
||||
abandonPreconnectInput?: () => void
|
||||
claimViewport?: (cols: number, rows: number) => boolean
|
||||
/** Capability-negotiated paired-runtime delivery gate; false preserves legacy delivery. */
|
||||
setOutputPaused?: (paused: boolean) => boolean
|
||||
@@ -228,6 +231,12 @@ export type PtyTransport = {
|
||||
|
||||
export type IpcPtyTransportOptions = {
|
||||
cwd?: string
|
||||
/** Retain bounded user input while a visible split waits to start its PTY. */
|
||||
bufferInputUntilConnect?: boolean
|
||||
/** Seed a fresh transport with input handed off from a remounted deferred split. */
|
||||
preconnectInput?: readonly PtyPreconnectInputEntry[]
|
||||
/** Records newly retained input against a remount-safe deferred split handoff. */
|
||||
onPreconnectInput?: (input: PtyPreconnectInputEntry) => void
|
||||
cwdFallback?: 'worktree'
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { attachIpcPty } from './ipc-pty-attach'
|
||||
import { writeAcceptedIpcPtyInput } from './ipc-pty-accepted-input'
|
||||
import { connectIpcPty } from './ipc-pty-connect'
|
||||
import { createIpcPtySessionHandlers } from './ipc-pty-session-handlers'
|
||||
import { createPtyInputWriteQueue } from './pty-input-write-queue'
|
||||
import { createPtyOutputProcessor } from './pty-output-processor'
|
||||
import { createPtyPreconnectInputBuffer } from './pty-preconnect-input-buffer'
|
||||
import type { IpcPtyTransportOptions, PtyTransport } from './pty-transport-types'
|
||||
|
||||
export {
|
||||
@@ -33,7 +33,6 @@ export type {
|
||||
export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTransport {
|
||||
const {
|
||||
connectionId,
|
||||
cwd,
|
||||
shellOverride,
|
||||
onPtyExit,
|
||||
onTitleChange,
|
||||
@@ -46,18 +45,31 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
let connected = false
|
||||
let destroyed = false
|
||||
let ptyId: string | null = null
|
||||
let lifecycleGeneration = 0
|
||||
let lastExitGeneration: number | null = null
|
||||
let suppressAttentionEvents = false
|
||||
let storedCallbacks: Parameters<PtyTransport['connect']>[0]['callbacks'] = {}
|
||||
const preconnectInputBuffer =
|
||||
opts.bufferInputUntilConnect || opts.preconnectInput?.length
|
||||
? createPtyPreconnectInputBuffer(opts.preconnectInput)
|
||||
: null
|
||||
|
||||
const inputWriteQueue = createPtyInputWriteQueue({
|
||||
isWritable: (id) => connected && ptyId === id,
|
||||
isWritable: (id) => !destroyed && connected && ptyId === id,
|
||||
write: (id, data) => window.api.pty.write(id, data),
|
||||
writeAccepted: (id, data) => window.api.pty.writeAccepted(id, data),
|
||||
onDrainFailure: (id) => {
|
||||
if (ptyId === id) {
|
||||
storedCallbacks.onWriteUnavailable?.()
|
||||
}
|
||||
}
|
||||
})
|
||||
const advancePtyLifecycle = (): number => {
|
||||
lifecycleGeneration += 1
|
||||
lastExitGeneration = null
|
||||
inputWriteQueue.clear()
|
||||
return lifecycleGeneration
|
||||
}
|
||||
const outputProcessor = createPtyOutputProcessor({
|
||||
onTitleChange,
|
||||
onBell,
|
||||
@@ -76,8 +88,11 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
getCallbacks: () => storedCallbacks,
|
||||
getSuppressAttentionEvents: () => suppressAttentionEvents,
|
||||
markExited: () => {
|
||||
advancePtyLifecycle()
|
||||
lastExitGeneration = lifecycleGeneration
|
||||
connected = false
|
||||
ptyId = null
|
||||
preconnectInputBuffer?.clear()
|
||||
},
|
||||
onPtyExit
|
||||
})
|
||||
@@ -88,49 +103,101 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
const setCallbacks = (callbacks: typeof storedCallbacks): void => {
|
||||
storedCallbacks = callbacks
|
||||
}
|
||||
const flushPreconnectInput = async (): Promise<void> => {
|
||||
if (!preconnectInputBuffer?.isBuffering()) {
|
||||
return
|
||||
}
|
||||
const id = ptyId
|
||||
if (destroyed || !connected || !id) {
|
||||
preconnectInputBuffer.clear()
|
||||
return
|
||||
}
|
||||
await preconnectInputBuffer.flush({
|
||||
isCurrent: () => !destroyed && connected && ptyId === id,
|
||||
sendInput: (data) => inputWriteQueue.enqueue(id, data),
|
||||
sendInputImmediate: (data) => inputWriteQueue.enqueueQueryReply(id, data),
|
||||
...(connectionId
|
||||
? {}
|
||||
: {
|
||||
sendInputAccepted: (data: string) => inputWriteQueue.enqueueAccepted(id, data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
connect: (options) =>
|
||||
connectIpcPty(options, {
|
||||
transportOptions: opts,
|
||||
handlers,
|
||||
isDestroyed: () => destroyed,
|
||||
bind,
|
||||
isCurrent: (id) => connected && ptyId === id,
|
||||
setCallbacks,
|
||||
getCallbacks: () => storedCallbacks
|
||||
}),
|
||||
|
||||
attach: (options) =>
|
||||
attachIpcPty(options, {
|
||||
handlers,
|
||||
outputProcessor,
|
||||
isDestroyed: () => destroyed,
|
||||
bind,
|
||||
isCurrent: (id) => connected && ptyId === id,
|
||||
setCallbacks,
|
||||
setSuppressAttentionEvents: (value) => {
|
||||
suppressAttentionEvents = value
|
||||
connect: async (options) => {
|
||||
const connectGeneration = advancePtyLifecycle()
|
||||
try {
|
||||
return await connectIpcPty(options, {
|
||||
transportOptions: opts,
|
||||
handlers,
|
||||
isDestroyed: () => destroyed || lifecycleGeneration !== connectGeneration,
|
||||
isExpectedExitCurrent: () =>
|
||||
!destroyed &&
|
||||
lastExitGeneration === lifecycleGeneration &&
|
||||
lifecycleGeneration === connectGeneration + 1,
|
||||
ownsPtyId: (id) => !destroyed && connected && ptyId === id,
|
||||
bind,
|
||||
isCurrent: (id) => lifecycleGeneration === connectGeneration && connected && ptyId === id,
|
||||
setCallbacks,
|
||||
getCallbacks: () => storedCallbacks
|
||||
})
|
||||
} finally {
|
||||
if (lifecycleGeneration === connectGeneration) {
|
||||
await flushPreconnectInput()
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
|
||||
attach: (options) => {
|
||||
const attachGeneration = advancePtyLifecycle()
|
||||
try {
|
||||
attachIpcPty(options, {
|
||||
handlers,
|
||||
outputProcessor,
|
||||
isDestroyed: () => destroyed || lifecycleGeneration !== attachGeneration,
|
||||
bind,
|
||||
isCurrent: (id) => lifecycleGeneration === attachGeneration && connected && ptyId === id,
|
||||
setCallbacks,
|
||||
setSuppressAttentionEvents: (value) => {
|
||||
suppressAttentionEvents = value
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
preconnectInputBuffer?.clear()
|
||||
throw error
|
||||
}
|
||||
if (lifecycleGeneration === attachGeneration) {
|
||||
void flushPreconnectInput()
|
||||
}
|
||||
},
|
||||
|
||||
abandonPreconnectInput() {
|
||||
preconnectInputBuffer?.clear()
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
advancePtyLifecycle()
|
||||
preconnectInputBuffer?.clear()
|
||||
const id = ptyId
|
||||
connected = false
|
||||
ptyId = null
|
||||
handlers.clearAccumulatedState()
|
||||
inputWriteQueue.clear()
|
||||
if (ptyId) {
|
||||
const id = ptyId
|
||||
window.api.pty.kill(id)
|
||||
connected = false
|
||||
ptyId = null
|
||||
handlers.unregisterAll(id)
|
||||
storedCallbacks.onDisconnect?.()
|
||||
if (id) {
|
||||
try {
|
||||
window.api.pty.kill(id)
|
||||
} finally {
|
||||
handlers.unregisterAll(id)
|
||||
storedCallbacks.onDisconnect?.()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
detach(options) {
|
||||
advancePtyLifecycle()
|
||||
outputProcessor.disposePendingSideEffectGauge()
|
||||
handlers.clearAccumulatedState()
|
||||
inputWriteQueue.clear()
|
||||
preconnectInputBuffer?.clear()
|
||||
if (ptyId) {
|
||||
if (options?.preserveExitObserver === false) {
|
||||
handlers.unregisterAll(ptyId)
|
||||
@@ -144,26 +211,32 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
},
|
||||
|
||||
sendInput(data) {
|
||||
return connected && ptyId ? inputWriteQueue.enqueue(ptyId, data) : false
|
||||
if (!destroyed && preconnectInputBuffer?.isBuffering()) {
|
||||
return preconnectInputBuffer.enqueue(data, 'ordinary', opts.onPreconnectInput)
|
||||
}
|
||||
return !destroyed && connected && ptyId ? inputWriteQueue.enqueue(ptyId, data) : false
|
||||
},
|
||||
|
||||
sendInputImmediate(data) {
|
||||
return connected && ptyId ? inputWriteQueue.enqueueQueryReply(ptyId, data) : false
|
||||
if (!destroyed && preconnectInputBuffer?.isBuffering()) {
|
||||
return preconnectInputBuffer.enqueue(data, 'immediate', opts.onPreconnectInput)
|
||||
}
|
||||
return !destroyed && connected && ptyId
|
||||
? inputWriteQueue.enqueueQueryReply(ptyId, data)
|
||||
: false
|
||||
},
|
||||
|
||||
...(connectionId
|
||||
? {}
|
||||
: {
|
||||
async sendInputAccepted(data: string): Promise<boolean> {
|
||||
if (!connected || !ptyId) {
|
||||
if (!destroyed && preconnectInputBuffer?.isBuffering()) {
|
||||
return preconnectInputBuffer.enqueueAccepted(data, opts.onPreconnectInput)
|
||||
}
|
||||
if (destroyed || !connected || !ptyId) {
|
||||
return false
|
||||
}
|
||||
const id = ptyId
|
||||
await inputWriteQueue.waitForDrain()
|
||||
if (!connected || ptyId !== id) {
|
||||
return false
|
||||
}
|
||||
return writeAcceptedIpcPtyInput(id, data, () => connected && ptyId === id)
|
||||
return inputWriteQueue.enqueueAccepted(ptyId, data)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -192,7 +265,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
getLocalSessionMetadata: () =>
|
||||
connectionId
|
||||
? null
|
||||
: { ...(cwd ? { cwd } : {}), ...(shellOverride ? { shellOverride } : {}) },
|
||||
: { ...(opts.cwd ? { cwd: opts.cwd } : {}), ...(shellOverride ? { shellOverride } : {}) },
|
||||
resetCrossChunkParserState: outputProcessor.resetAgentStatusCarry,
|
||||
|
||||
destroy() {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
|
||||
import {
|
||||
clearPaneCwdDeferredSpawn,
|
||||
mergePaneCwdFromOsc7,
|
||||
resolveSplitCwd,
|
||||
type PaneCwdMap
|
||||
} from './resolve-split-cwd'
|
||||
|
||||
function installGetCwd(fn: (id: string) => Promise<string>): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shim for window.api.pty.getCwd
|
||||
@@ -123,3 +128,51 @@ describe('resolveSplitCwd', () => {
|
||||
expect(getCwd).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergePaneCwdFromOsc7', () => {
|
||||
it('preserves a deferred split fence until the PTY binds', () => {
|
||||
const pendingCwd = Promise.resolve('/resolved')
|
||||
expect(
|
||||
mergePaneCwdFromOsc7(
|
||||
{
|
||||
cwd: '/seed',
|
||||
confirmed: false,
|
||||
deferredSplitSpawn: true,
|
||||
pendingCwd
|
||||
},
|
||||
'/live',
|
||||
true
|
||||
)
|
||||
).toEqual({ cwd: '/live', confirmed: true, deferredSplitSpawn: true, pendingCwd })
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearPaneCwdDeferredSpawn', () => {
|
||||
it('clears a settled deferred entry after its promise settles', () => {
|
||||
const originalPromise = Promise.resolve('/resolved')
|
||||
const settledEntry = {
|
||||
cwd: '/resolved',
|
||||
confirmed: false,
|
||||
deferredSplitSpawn: true,
|
||||
pendingCwd: originalPromise
|
||||
}
|
||||
|
||||
expect(clearPaneCwdDeferredSpawn(settledEntry, originalPromise)).toEqual({
|
||||
cwd: '/resolved',
|
||||
confirmed: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a newer pending deferred lookup when an older callback arrives', () => {
|
||||
const olderPromise = Promise.resolve('/older')
|
||||
const newerPromise = new Promise<string>(() => {})
|
||||
const newerEntry = {
|
||||
cwd: '/newer',
|
||||
confirmed: false,
|
||||
deferredSplitSpawn: true,
|
||||
pendingCwd: newerPromise
|
||||
}
|
||||
|
||||
expect(clearPaneCwdDeferredSpawn(newerEntry, olderPromise)).toBe(newerEntry)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,57 @@
|
||||
// helper always finishes by returning the caller's worktree-root fallback.
|
||||
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
|
||||
|
||||
export type PaneCwdEntry = { cwd: string; confirmed: boolean }
|
||||
export type PaneCwdEntry = {
|
||||
cwd: string
|
||||
confirmed: boolean
|
||||
/** Keeps a cwd-deferred split attached until its PTY binds. */
|
||||
deferredSplitSpawn?: boolean
|
||||
pendingCwd?: Promise<string>
|
||||
}
|
||||
|
||||
export type PaneCwdMap = Map<number, PaneCwdEntry>
|
||||
|
||||
/** Updates OSC 7 state without dropping a split's pre-bind admission fence. */
|
||||
export function mergePaneCwdFromOsc7(
|
||||
existing: PaneCwdEntry | undefined,
|
||||
cwd: string,
|
||||
confirmed: boolean
|
||||
): PaneCwdEntry {
|
||||
return {
|
||||
cwd,
|
||||
confirmed,
|
||||
...(existing?.deferredSplitSpawn ? { deferredSplitSpawn: true } : {}),
|
||||
...(existing?.pendingCwd ? { pendingCwd: existing.pendingCwd } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the pre-bind lookup metadata once a split either binds or definitively fails. */
|
||||
export function clearPaneCwdDeferredSpawn(
|
||||
existing: PaneCwdEntry | undefined,
|
||||
expectedPendingCwd?: Promise<string>
|
||||
): PaneCwdEntry | undefined {
|
||||
if (!existing || (expectedPendingCwd && existing.pendingCwd !== expectedPendingCwd)) {
|
||||
return existing
|
||||
}
|
||||
if (!existing.deferredSplitSpawn && !existing.pendingCwd) {
|
||||
return existing
|
||||
}
|
||||
return { cwd: existing.cwd, confirmed: existing.confirmed }
|
||||
}
|
||||
|
||||
/** Settles a pane's deferred-split lookup in place once it binds or definitively fails. */
|
||||
export function settlePaneCwdDeferredSpawn(
|
||||
paneCwdMap: PaneCwdMap,
|
||||
paneId: number,
|
||||
expectedPendingCwd?: Promise<string>
|
||||
): void {
|
||||
const existing = paneCwdMap.get(paneId)
|
||||
const settled = clearPaneCwdDeferredSpawn(existing, expectedPendingCwd)
|
||||
if (settled && settled !== existing) {
|
||||
paneCwdMap.set(paneId, settled)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: sized to cover a cold `lsof -p <pid> -d cwd` on macOS (typically
|
||||
// 100–500ms, occasionally up to ~1s). Shorter budgets here would cause the
|
||||
// renderer to give up and fall back to the worktree root while the main
|
||||
|
||||
+91
-25
@@ -1,7 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import type { PaneCwdMap } from './resolve-split-cwd'
|
||||
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
|
||||
import { createDeferred } from './pty-connection-test-async'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
recordCreatedTerminalPaneSplit: vi.fn(),
|
||||
@@ -25,11 +27,6 @@ function makeManager(splitPane: ReturnType<typeof vi.fn>): PaneManager {
|
||||
return { splitPane } as unknown as PaneManager
|
||||
}
|
||||
|
||||
async function flushAsyncSplit(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('splitTerminalPaneWithInheritedCwd', () => {
|
||||
beforeEach(() => {
|
||||
mocks.recordCreatedTerminalPaneSplit.mockReset()
|
||||
@@ -90,37 +87,110 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the live manager after async cwd resolution', async () => {
|
||||
it('creates and records the split before asynchronous cwd resolution settles', async () => {
|
||||
const cwd = createDeferred<string>()
|
||||
const createdPane = { id: 2 }
|
||||
const staleSplitPane = vi.fn()
|
||||
const liveSplitPane = vi.fn(() => ({ id: 2 }))
|
||||
mocks.resolveSplitCwd.mockResolvedValue('/resolved')
|
||||
const liveSplitPane = vi.fn(
|
||||
(
|
||||
_paneId: number,
|
||||
_direction: 'vertical' | 'horizontal',
|
||||
_opts?: { cwdPromise?: Promise<string> }
|
||||
) => createdPane
|
||||
)
|
||||
let cwdSettled = false
|
||||
void cwd.promise.then(() => {
|
||||
cwdSettled = true
|
||||
})
|
||||
mocks.resolveSplitCwd.mockReturnValue(cwd.promise)
|
||||
|
||||
splitTerminalPaneWithInheritedCwd({
|
||||
worktreeId: 'worktree-1',
|
||||
tabId: 'tab-1',
|
||||
manager: makeManager(staleSplitPane),
|
||||
getManager: () => makeManager(liveSplitPane),
|
||||
paneTransports: new Map<number, PtyTransport>(),
|
||||
paneTransports: new Map([[1, { getPtyId: () => 'pty-1' } as PtyTransport]]),
|
||||
paneCwdMap: new Map(),
|
||||
fallbackCwd: '/fallback',
|
||||
pane: { id: 1, leafId: 'leaf-1' } as ManagedPane,
|
||||
direction: 'vertical',
|
||||
source: 'context_menu'
|
||||
source: 'keyboard'
|
||||
})
|
||||
|
||||
await flushAsyncSplit()
|
||||
|
||||
expect(cwdSettled).toBe(false)
|
||||
expect(mocks.resolveSplitCwd).toHaveBeenCalledWith({
|
||||
paneCwdMap: expect.any(Map),
|
||||
sourcePaneId: 1,
|
||||
sourcePtyId: 'pty-1',
|
||||
fallbackCwd: '/fallback'
|
||||
})
|
||||
expect(staleSplitPane).not.toHaveBeenCalled()
|
||||
expect(liveSplitPane).toHaveBeenCalledWith(1, 'vertical', { cwd: '/resolved' })
|
||||
expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalledWith(
|
||||
{ id: 2 },
|
||||
{ source: 'context_menu', direction: 'vertical' }
|
||||
)
|
||||
expect(liveSplitPane).toHaveBeenCalledWith(1, 'vertical', { cwdPromise: cwd.promise })
|
||||
expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalledWith(createdPane, {
|
||||
source: 'keyboard',
|
||||
direction: 'vertical'
|
||||
})
|
||||
|
||||
const spawnHints = liveSplitPane.mock.calls[0]?.[2] as
|
||||
| { cwdPromise?: Promise<string> }
|
||||
| undefined
|
||||
cwd.resolve('/resolved')
|
||||
|
||||
await expect(spawnHints?.cwdPromise).resolves.toBe('/resolved')
|
||||
})
|
||||
|
||||
it('does not split a stale manager when the live manager is gone', async () => {
|
||||
it('reuses one pending cwd lookup across rapid nested splits', () => {
|
||||
const cwd = createDeferred<string>()
|
||||
const firstCreatedPane = { id: 2, leafId: 'leaf-2' } as ManagedPane
|
||||
const secondCreatedPane = { id: 3, leafId: 'leaf-3' } as ManagedPane
|
||||
const splitPane = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstCreatedPane)
|
||||
.mockReturnValueOnce(secondCreatedPane)
|
||||
const manager = makeManager(splitPane)
|
||||
const paneCwdMap: PaneCwdMap = new Map()
|
||||
mocks.resolveSplitCwd.mockReturnValue(cwd.promise)
|
||||
|
||||
splitTerminalPaneWithInheritedCwd({
|
||||
worktreeId: 'worktree-1',
|
||||
tabId: 'tab-1',
|
||||
manager,
|
||||
paneTransports: new Map([[1, { getPtyId: () => 'pty-1' } as PtyTransport]]),
|
||||
paneCwdMap,
|
||||
fallbackCwd: '/fallback',
|
||||
pane: { id: 1, leafId: 'leaf-1' } as ManagedPane,
|
||||
direction: 'vertical',
|
||||
source: 'keyboard'
|
||||
})
|
||||
|
||||
paneCwdMap.set(firstCreatedPane.id, {
|
||||
cwd: '/fallback',
|
||||
confirmed: false,
|
||||
pendingCwd: cwd.promise
|
||||
})
|
||||
splitTerminalPaneWithInheritedCwd({
|
||||
worktreeId: 'worktree-1',
|
||||
tabId: 'tab-1',
|
||||
manager,
|
||||
paneTransports: new Map(),
|
||||
paneCwdMap,
|
||||
fallbackCwd: '/fallback',
|
||||
pane: firstCreatedPane,
|
||||
direction: 'horizontal',
|
||||
source: 'keyboard'
|
||||
})
|
||||
|
||||
expect(mocks.resolveSplitCwd).toHaveBeenCalledOnce()
|
||||
expect(splitPane).toHaveBeenNthCalledWith(1, 1, 'vertical', {
|
||||
cwdPromise: cwd.promise
|
||||
})
|
||||
expect(splitPane).toHaveBeenNthCalledWith(2, 2, 'horizontal', {
|
||||
cwdPromise: cwd.promise
|
||||
})
|
||||
})
|
||||
|
||||
it('does not resolve cwd or split a stale manager when the live manager is gone', () => {
|
||||
const staleSplitPane = vi.fn()
|
||||
mocks.resolveSplitCwd.mockResolvedValue('/resolved')
|
||||
|
||||
splitTerminalPaneWithInheritedCwd({
|
||||
worktreeId: 'worktree-1',
|
||||
@@ -135,12 +205,8 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
|
||||
source: 'context_menu'
|
||||
})
|
||||
|
||||
await flushAsyncSplit()
|
||||
|
||||
expect(staleSplitPane).not.toHaveBeenCalled()
|
||||
expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalledWith(undefined, {
|
||||
source: 'context_menu',
|
||||
direction: 'horizontal'
|
||||
})
|
||||
expect(mocks.resolveSplitCwd).not.toHaveBeenCalled()
|
||||
expect(mocks.recordCreatedTerminalPaneSplit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+13
-11
@@ -27,9 +27,13 @@ export function splitTerminalPaneWithInheritedCwd(args: {
|
||||
) {
|
||||
return
|
||||
}
|
||||
const manager = args.getManager ? args.getManager() : args.manager
|
||||
if (!manager) {
|
||||
return
|
||||
}
|
||||
const cached = args.paneCwdMap.get(args.pane.id)
|
||||
if (cached?.confirmed && cached.cwd) {
|
||||
const createdPane = args.manager.splitPane(args.pane.id, args.direction, { cwd: cached.cwd })
|
||||
const createdPane = manager.splitPane(args.pane.id, args.direction, { cwd: cached.cwd })
|
||||
recordCreatedTerminalPaneSplit(createdPane, {
|
||||
source: args.source,
|
||||
direction: args.direction
|
||||
@@ -37,19 +41,17 @@ export function splitTerminalPaneWithInheritedCwd(args: {
|
||||
return
|
||||
}
|
||||
const paneId = args.pane.id
|
||||
const resolveManager = (): PaneManager | null =>
|
||||
args.getManager ? args.getManager() : args.manager
|
||||
void (async () => {
|
||||
const cwd = await resolveSplitCwd({
|
||||
const cwdPromise =
|
||||
cached?.pendingCwd ??
|
||||
resolveSplitCwd({
|
||||
paneCwdMap: args.paneCwdMap,
|
||||
sourcePaneId: paneId,
|
||||
sourcePtyId: ptyId,
|
||||
fallbackCwd: args.fallbackCwd
|
||||
})
|
||||
const createdPane = resolveManager()?.splitPane(paneId, args.direction, { cwd })
|
||||
recordCreatedTerminalPaneSplit(createdPane, {
|
||||
source: args.source,
|
||||
direction: args.direction
|
||||
})
|
||||
})()
|
||||
const createdPane = manager.splitPane(paneId, args.direction, { cwdPromise })
|
||||
recordCreatedTerminalPaneSplit(createdPane, {
|
||||
source: args.source,
|
||||
direction: args.direction
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,6 +49,19 @@ function splitLayout(): TerminalLayoutSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
function unboundSplitLayout(): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: LEAF_1 },
|
||||
second: { type: 'leaf', leafId: LEAF_2 }
|
||||
},
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
|
||||
function createTerminalTab(id: string, ptyId: string | null, shellOverride?: string): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
@@ -119,6 +132,38 @@ function createStore(
|
||||
return store as unknown as TerminalPaneTabDetachStore
|
||||
}
|
||||
|
||||
type SourcePaneCwd = NonNullable<Parameters<typeof detachTerminalPaneToTab>[0]['sourcePaneCwd']>
|
||||
|
||||
function expectDeferredSplitDetachRejected(sourcePaneCwd: SourcePaneCwd): void {
|
||||
const store = createStore(unboundSplitLayout())
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
|
||||
getLeafId: vi.fn(() => LEAF_2),
|
||||
detachPaneForExternalMove: vi.fn(() => true)
|
||||
}
|
||||
const persistLayoutSnapshot = vi.fn()
|
||||
|
||||
const result = detachTerminalPaneToTab({
|
||||
getStore: () => store,
|
||||
manager,
|
||||
persistLayoutSnapshot,
|
||||
sourcePaneCwd,
|
||||
sourcePaneId: 2,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetGroupId: TARGET_GROUP_ID,
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(persistLayoutSnapshot).not.toHaveBeenCalled()
|
||||
expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled()
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
expect(store.setTabLayout).not.toHaveBeenCalled()
|
||||
expect(store.syncPaneDetachPtyOwnership).not.toHaveBeenCalled()
|
||||
expect(store.setActiveTab).not.toHaveBeenCalled()
|
||||
expect(store.setActiveTabType).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
describe('resolveTerminalTabStripDropTarget', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
@@ -239,6 +284,12 @@ describe('detachTerminalPaneToTab', () => {
|
||||
manager,
|
||||
getStore: () => store,
|
||||
persistLayoutSnapshot,
|
||||
sourcePaneCwd: {
|
||||
cwd: '/remote/repo',
|
||||
confirmed: false,
|
||||
deferredSplitSpawn: true,
|
||||
pendingCwd: Promise.resolve('/remote/repo/packages/app')
|
||||
},
|
||||
sourcePaneId: 2,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetGroupId: TARGET_GROUP_ID,
|
||||
@@ -403,6 +454,12 @@ describe('detachTerminalPaneToTab', () => {
|
||||
getStore: () => store,
|
||||
manager,
|
||||
persistLayoutSnapshot: vi.fn(),
|
||||
sourcePaneCwd: {
|
||||
cwd: '/remote/repo',
|
||||
confirmed: false,
|
||||
deferredSplitSpawn: true,
|
||||
pendingCwd: Promise.resolve('/remote/repo/packages/app')
|
||||
},
|
||||
sourcePaneId: 2,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetGroupId: TARGET_GROUP_ID,
|
||||
@@ -417,17 +474,31 @@ describe('detachTerminalPaneToTab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a detached null-PTY leaf eligible to finish its pending activation', () => {
|
||||
const store = createStore({
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: LEAF_1 },
|
||||
second: { type: 'leaf', leafId: LEAF_2 }
|
||||
},
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null
|
||||
it('rejects a deferred split while inherited cwd is pending', () => {
|
||||
expectDeferredSplitDetachRejected({
|
||||
cwd: '/remote/repo',
|
||||
deferredSplitSpawn: true,
|
||||
pendingCwd: new Promise<string>(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a pending cwd even when the deferred marker is absent', () => {
|
||||
expectDeferredSplitDetachRejected({
|
||||
cwd: '/remote/repo',
|
||||
pendingCwd: new Promise<string>(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
it('still rejects a deferred split after cwd resolves but before PTY bind', () => {
|
||||
expectDeferredSplitDetachRejected({
|
||||
cwd: '/remote/repo/packages/app',
|
||||
confirmed: false,
|
||||
deferredSplitSpawn: true
|
||||
})
|
||||
})
|
||||
|
||||
it('carries resolved cwd when detaching an unbound non-deferred pane', () => {
|
||||
const store = createStore(unboundSplitLayout())
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
|
||||
getLeafId: vi.fn(() => LEAF_2),
|
||||
@@ -438,6 +509,10 @@ describe('detachTerminalPaneToTab', () => {
|
||||
getStore: () => store,
|
||||
manager,
|
||||
persistLayoutSnapshot: vi.fn(),
|
||||
sourcePaneCwd: {
|
||||
cwd: '/remote/repo/packages/app',
|
||||
confirmed: false
|
||||
},
|
||||
sourcePaneId: 2,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetGroupId: TARGET_GROUP_ID,
|
||||
@@ -448,7 +523,8 @@ describe('detachTerminalPaneToTab', () => {
|
||||
expect(store.createTab).toHaveBeenCalledWith(WORKTREE_ID, TARGET_GROUP_ID, 'powershell.exe', {
|
||||
activate: true,
|
||||
pendingActivationSpawn: true,
|
||||
recordInteraction: true
|
||||
recordInteraction: true,
|
||||
startupCwd: '/remote/repo/packages/app'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager'
|
||||
import type { AppState } from '@/store'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import type { PaneCwdEntry } from './resolve-split-cwd'
|
||||
import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
|
||||
|
||||
const TAB_GROUP_STRIP_SELECTOR = '[data-tab-group-strip-id][data-worktree-id]'
|
||||
export {
|
||||
isTerminalTabStripDropTarget,
|
||||
resolveTerminalTabStripDropTarget
|
||||
} from './terminal-tab-strip-drop-target'
|
||||
export type { TerminalTabStripDropTarget } from './terminal-tab-strip-drop-target'
|
||||
|
||||
export type TerminalPaneTabDetachStore = Pick<
|
||||
AppState,
|
||||
@@ -24,11 +27,8 @@ type TerminalPaneTabDetachManager = {
|
||||
detachPaneForExternalMove: (paneId: number) => boolean
|
||||
}
|
||||
|
||||
export type TerminalTabStripDropTarget = PaneExternalDropTarget & {
|
||||
groupId: string
|
||||
insertionIndex?: number
|
||||
worktreeId: string
|
||||
}
|
||||
type SourcePaneCwd = Pick<PaneCwdEntry, 'cwd' | 'deferredSplitSpawn' | 'pendingCwd'> &
|
||||
Partial<Pick<PaneCwdEntry, 'confirmed'>>
|
||||
|
||||
export type DetachedTerminalPaneTab = {
|
||||
tab: TerminalTab
|
||||
@@ -36,153 +36,6 @@ export type DetachedTerminalPaneTab = {
|
||||
ptyId: string | null
|
||||
}
|
||||
|
||||
function pointWithinRect(clientX: number, clientY: number, rect: DOMRect): boolean {
|
||||
return (
|
||||
clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom
|
||||
)
|
||||
}
|
||||
|
||||
function rectFromBox(args: { left: number; top: number; width: number; height: number }): DOMRect {
|
||||
return {
|
||||
left: args.left,
|
||||
top: args.top,
|
||||
right: args.left + args.width,
|
||||
bottom: args.top + args.height,
|
||||
width: args.width,
|
||||
height: args.height
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
function clampIndex(index: number, max: number): number {
|
||||
return Math.min(Math.max(index, 0), max)
|
||||
}
|
||||
|
||||
function getTabElements(strip: HTMLElement): HTMLElement[] {
|
||||
return Array.from(strip.querySelectorAll<HTMLElement>('[data-tab-id]')).filter(
|
||||
(element) => typeof element.dataset.tabId === 'string' && element.dataset.tabId.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function getInsertionMarkerRect(
|
||||
tabRects: DOMRect[],
|
||||
insertionIndex: number,
|
||||
stripRect: DOMRect
|
||||
): DOMRect {
|
||||
const markerWidth = 2
|
||||
const clampedIndex = clampIndex(insertionIndex, tabRects.length)
|
||||
const rawLeft =
|
||||
clampedIndex < tabRects.length
|
||||
? (tabRects[clampedIndex]?.left ?? stripRect.left)
|
||||
: (tabRects.at(-1)?.right ?? stripRect.left) - markerWidth
|
||||
const left = Math.min(Math.max(rawLeft, stripRect.left), stripRect.right - markerWidth)
|
||||
return rectFromBox({ left, top: stripRect.top, width: markerWidth, height: stripRect.height })
|
||||
}
|
||||
|
||||
function resolveTabStripInsertion(args: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
groupTabOrderLength: number
|
||||
strip: HTMLElement
|
||||
stripRect: DOMRect
|
||||
}): { index: number; rect: DOMRect } | null {
|
||||
const tabs = getTabElements(args.strip)
|
||||
if (tabs.length === 0) {
|
||||
return null
|
||||
}
|
||||
const tabRects = tabs.map((tab) => tab.getBoundingClientRect())
|
||||
|
||||
for (let index = 0; index < tabRects.length; index += 1) {
|
||||
const tabRect = tabRects[index]
|
||||
if (!tabRect) {
|
||||
continue
|
||||
}
|
||||
if (args.clientX < tabRect.left) {
|
||||
const insertionIndex = clampIndex(index, args.groupTabOrderLength)
|
||||
return {
|
||||
index: insertionIndex,
|
||||
rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect)
|
||||
}
|
||||
}
|
||||
if (pointWithinRect(args.clientX, args.clientY, tabRect)) {
|
||||
const insertionIndex = clampIndex(
|
||||
index + (args.clientX < tabRect.left + tabRect.width / 2 ? 0 : 1),
|
||||
args.groupTabOrderLength
|
||||
)
|
||||
return {
|
||||
index: insertionIndex,
|
||||
rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const insertionIndex = args.groupTabOrderLength
|
||||
return {
|
||||
index: insertionIndex,
|
||||
rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect)
|
||||
}
|
||||
}
|
||||
|
||||
function getElementsFromPoint(clientX: number, clientY: number): Element[] {
|
||||
if (typeof document === 'undefined') {
|
||||
return []
|
||||
}
|
||||
const elements = document.elementsFromPoint?.(clientX, clientY)
|
||||
if (elements && elements.length > 0) {
|
||||
return elements
|
||||
}
|
||||
const element = document.elementFromPoint?.(clientX, clientY)
|
||||
return element ? [element] : []
|
||||
}
|
||||
|
||||
export function resolveTerminalTabStripDropTarget(args: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
groupsByWorktree: TerminalPaneTabDetachStore['groupsByWorktree']
|
||||
worktreeId: string
|
||||
}): TerminalTabStripDropTarget | null {
|
||||
const groups = args.groupsByWorktree[args.worktreeId] ?? []
|
||||
const groupById = new Map(groups.map((group) => [group.id, group]))
|
||||
const validGroupIds = new Set(groups.map((group) => group.id))
|
||||
if (validGroupIds.size === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const element of getElementsFromPoint(args.clientX, args.clientY)) {
|
||||
const strip = element.closest<HTMLElement>(TAB_GROUP_STRIP_SELECTOR)
|
||||
const groupId = strip?.dataset.tabGroupStripId
|
||||
const worktreeId = strip?.dataset.worktreeId
|
||||
if (!strip || !groupId || worktreeId !== args.worktreeId || !validGroupIds.has(groupId)) {
|
||||
continue
|
||||
}
|
||||
const rect = strip.getBoundingClientRect()
|
||||
if (!pointWithinRect(args.clientX, args.clientY, rect)) {
|
||||
continue
|
||||
}
|
||||
const group = groupById.get(groupId)
|
||||
const insertion = group
|
||||
? resolveTabStripInsertion({
|
||||
clientX: args.clientX,
|
||||
clientY: args.clientY,
|
||||
groupTabOrderLength: group.tabOrder?.length ?? 0,
|
||||
strip,
|
||||
stripRect: rect
|
||||
})
|
||||
: null
|
||||
return insertion
|
||||
? {
|
||||
id: groupId,
|
||||
groupId,
|
||||
insertionIndex: insertion.index,
|
||||
overlayKind: 'insertion',
|
||||
rect: insertion.rect,
|
||||
worktreeId
|
||||
}
|
||||
: { id: groupId, groupId, worktreeId, rect }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function withDetachedPtyFallback(args: {
|
||||
leafId: string
|
||||
ptyId: string | null
|
||||
@@ -200,13 +53,6 @@ function withDetachedPtyFallback(args: {
|
||||
}
|
||||
}
|
||||
|
||||
export function isTerminalTabStripDropTarget(
|
||||
target: PaneExternalDropTarget
|
||||
): target is TerminalTabStripDropTarget {
|
||||
const candidate = target as Partial<TerminalTabStripDropTarget>
|
||||
return typeof candidate.groupId === 'string' && typeof candidate.worktreeId === 'string'
|
||||
}
|
||||
|
||||
function moveCreatedTabToIndex(args: {
|
||||
groupId: string
|
||||
store: TerminalPaneTabDetachStore
|
||||
@@ -224,7 +70,7 @@ function moveCreatedTabToIndex(args: {
|
||||
return
|
||||
}
|
||||
const orderWithoutCreatedTab = (group.tabOrder ?? []).filter((id) => id !== args.tabId)
|
||||
const insertionIndex = clampIndex(args.targetIndex, orderWithoutCreatedTab.length)
|
||||
const insertionIndex = Math.min(Math.max(args.targetIndex, 0), orderWithoutCreatedTab.length)
|
||||
const nextOrder = [...orderWithoutCreatedTab]
|
||||
nextOrder.splice(insertionIndex, 0, args.tabId)
|
||||
args.store.reorderUnifiedTabs(args.groupId, nextOrder, { recordInteraction: false })
|
||||
@@ -236,6 +82,7 @@ export function detachTerminalPaneToTab(args: {
|
||||
manager: TerminalPaneTabDetachManager | null
|
||||
persistLayoutSnapshot: () => void
|
||||
sourcePaneId: number
|
||||
sourcePaneCwd?: SourcePaneCwd
|
||||
sourceTabId: string
|
||||
targetGroupId: string
|
||||
targetIndex?: number
|
||||
@@ -255,6 +102,15 @@ export function detachTerminalPaneToTab(args: {
|
||||
return null
|
||||
}
|
||||
|
||||
const persistedPtyId =
|
||||
initialStore.terminalLayoutsByTabId[args.sourceTabId]?.ptyIdsByLeafId?.[sourceLeafId]
|
||||
const cwdDeferred = Boolean(
|
||||
args.sourcePaneCwd?.pendingCwd || args.sourcePaneCwd?.deferredSplitSpawn
|
||||
)
|
||||
if (cwdDeferred && !persistedPtyId && !args.fallbackPtyId) {
|
||||
return null
|
||||
}
|
||||
|
||||
args.persistLayoutSnapshot()
|
||||
const store = args.getStore()
|
||||
const detached = detachTerminalLayoutLeaf(
|
||||
@@ -285,7 +141,12 @@ export function detachTerminalPaneToTab(args: {
|
||||
const tab = latestStore.createTab(args.worktreeId, args.targetGroupId, sourceShellOverride, {
|
||||
activate: true,
|
||||
initialPtyId: ptyId ?? undefined,
|
||||
...(!ptyId ? { pendingActivationSpawn: true } : {}),
|
||||
...(!ptyId
|
||||
? {
|
||||
pendingActivationSpawn: true,
|
||||
...(args.sourcePaneCwd?.cwd ? { startupCwd: args.sourcePaneCwd.cwd } : {})
|
||||
}
|
||||
: {}),
|
||||
recordInteraction: true
|
||||
})
|
||||
const afterCreateStore = args.getStore()
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager'
|
||||
import type { AppState } from '@/store'
|
||||
|
||||
const TAB_GROUP_STRIP_SELECTOR = '[data-tab-group-strip-id][data-worktree-id]'
|
||||
|
||||
export type TerminalTabStripDropTarget = PaneExternalDropTarget & {
|
||||
groupId: string
|
||||
insertionIndex?: number
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
function pointWithinRect(clientX: number, clientY: number, rect: DOMRect): boolean {
|
||||
return (
|
||||
clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom
|
||||
)
|
||||
}
|
||||
|
||||
function rectFromBox(args: { left: number; top: number; width: number; height: number }): DOMRect {
|
||||
return {
|
||||
left: args.left,
|
||||
top: args.top,
|
||||
right: args.left + args.width,
|
||||
bottom: args.top + args.height,
|
||||
width: args.width,
|
||||
height: args.height
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
function clampIndex(index: number, max: number): number {
|
||||
return Math.min(Math.max(index, 0), max)
|
||||
}
|
||||
|
||||
function getTabElements(strip: HTMLElement): HTMLElement[] {
|
||||
return Array.from(strip.querySelectorAll<HTMLElement>('[data-tab-id]')).filter(
|
||||
(element) => typeof element.dataset.tabId === 'string' && element.dataset.tabId.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function getInsertionMarkerRect(
|
||||
tabRects: DOMRect[],
|
||||
insertionIndex: number,
|
||||
stripRect: DOMRect
|
||||
): DOMRect {
|
||||
const markerWidth = 2
|
||||
const clampedIndex = clampIndex(insertionIndex, tabRects.length)
|
||||
const rawLeft =
|
||||
clampedIndex < tabRects.length
|
||||
? (tabRects[clampedIndex]?.left ?? stripRect.left)
|
||||
: (tabRects.at(-1)?.right ?? stripRect.left) - markerWidth
|
||||
const left = Math.min(Math.max(rawLeft, stripRect.left), stripRect.right - markerWidth)
|
||||
return rectFromBox({ left, top: stripRect.top, width: markerWidth, height: stripRect.height })
|
||||
}
|
||||
|
||||
function resolveTabStripInsertion(args: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
groupTabOrderLength: number
|
||||
strip: HTMLElement
|
||||
stripRect: DOMRect
|
||||
}): { index: number; rect: DOMRect } | null {
|
||||
const tabs = getTabElements(args.strip)
|
||||
if (tabs.length === 0) {
|
||||
return null
|
||||
}
|
||||
const tabRects = tabs.map((tab) => tab.getBoundingClientRect())
|
||||
|
||||
for (let index = 0; index < tabRects.length; index += 1) {
|
||||
const tabRect = tabRects[index]
|
||||
if (!tabRect) {
|
||||
continue
|
||||
}
|
||||
if (args.clientX < tabRect.left) {
|
||||
const insertionIndex = clampIndex(index, args.groupTabOrderLength)
|
||||
return {
|
||||
index: insertionIndex,
|
||||
rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect)
|
||||
}
|
||||
}
|
||||
if (pointWithinRect(args.clientX, args.clientY, tabRect)) {
|
||||
const insertionIndex = clampIndex(
|
||||
index + (args.clientX < tabRect.left + tabRect.width / 2 ? 0 : 1),
|
||||
args.groupTabOrderLength
|
||||
)
|
||||
return {
|
||||
index: insertionIndex,
|
||||
rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const insertionIndex = args.groupTabOrderLength
|
||||
return {
|
||||
index: insertionIndex,
|
||||
rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect)
|
||||
}
|
||||
}
|
||||
|
||||
function getElementsFromPoint(clientX: number, clientY: number): Element[] {
|
||||
if (typeof document === 'undefined') {
|
||||
return []
|
||||
}
|
||||
const elements = document.elementsFromPoint?.(clientX, clientY)
|
||||
if (elements && elements.length > 0) {
|
||||
return elements
|
||||
}
|
||||
const element = document.elementFromPoint?.(clientX, clientY)
|
||||
return element ? [element] : []
|
||||
}
|
||||
|
||||
export function resolveTerminalTabStripDropTarget(args: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
groupsByWorktree: AppState['groupsByWorktree']
|
||||
worktreeId: string
|
||||
}): TerminalTabStripDropTarget | null {
|
||||
const groups = args.groupsByWorktree[args.worktreeId] ?? []
|
||||
const groupById = new Map(groups.map((group) => [group.id, group]))
|
||||
const validGroupIds = new Set(groups.map((group) => group.id))
|
||||
if (validGroupIds.size === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const element of getElementsFromPoint(args.clientX, args.clientY)) {
|
||||
const strip = element.closest<HTMLElement>(TAB_GROUP_STRIP_SELECTOR)
|
||||
const groupId = strip?.dataset.tabGroupStripId
|
||||
const worktreeId = strip?.dataset.worktreeId
|
||||
if (!strip || !groupId || worktreeId !== args.worktreeId || !validGroupIds.has(groupId)) {
|
||||
continue
|
||||
}
|
||||
const rect = strip.getBoundingClientRect()
|
||||
if (!pointWithinRect(args.clientX, args.clientY, rect)) {
|
||||
continue
|
||||
}
|
||||
const group = groupById.get(groupId)
|
||||
const insertion = group
|
||||
? resolveTabStripInsertion({
|
||||
clientX: args.clientX,
|
||||
clientY: args.clientY,
|
||||
groupTabOrderLength: group.tabOrder?.length ?? 0,
|
||||
strip,
|
||||
stripRect: rect
|
||||
})
|
||||
: null
|
||||
return insertion
|
||||
? {
|
||||
id: groupId,
|
||||
groupId,
|
||||
insertionIndex: insertion.index,
|
||||
overlayKind: 'insertion',
|
||||
rect: insertion.rect,
|
||||
worktreeId
|
||||
}
|
||||
: { id: groupId, groupId, worktreeId, rect }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function isTerminalTabStripDropTarget(
|
||||
target: PaneExternalDropTarget
|
||||
): target is TerminalTabStripDropTarget {
|
||||
const candidate = target as Partial<TerminalTabStripDropTarget>
|
||||
return typeof candidate.groupId === 'string' && typeof candidate.worktreeId === 'string'
|
||||
}
|
||||
@@ -119,7 +119,12 @@ import {
|
||||
shouldSuppressTerminalModifierKeyboardEvent,
|
||||
TERMINAL_INTERRUPT_INPUT
|
||||
} from './xterm-bypass-policy'
|
||||
import type { PaneCwdMap } from './resolve-split-cwd'
|
||||
import {
|
||||
mergePaneCwdFromOsc7,
|
||||
settlePaneCwdDeferredSpawn,
|
||||
type PaneCwdMap
|
||||
} from './resolve-split-cwd'
|
||||
import type { PtyPreconnectInputEntry } from './pty-preconnect-input-buffer'
|
||||
import { installMouseHideWhileTyping } from './mouse-hide-while-typing'
|
||||
import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt'
|
||||
import { connectPanePty } from './pty-connection'
|
||||
@@ -132,6 +137,7 @@ import {
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { resolvePaneWslDistro } from './terminal-pane-wsl-distro'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { isTerminalTabPresent } from '@/store/slices/terminal-tab-retirement'
|
||||
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
|
||||
import { canReleaseReplayedScrollbackFromStore } from './replayed-scrollback-store-release'
|
||||
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
|
||||
@@ -155,6 +161,16 @@ import {
|
||||
import { acquireWebviewsDragPassthrough } from '../browser-pane/host-guest/webview-registry'
|
||||
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import {
|
||||
appendDeferredSplitPaneInput,
|
||||
beginDeferredSplitPaneHandoff,
|
||||
claimDeferredSplitPaneHandoff,
|
||||
clearDeferredSplitPaneHandoff,
|
||||
discardDeferredSplitPaneHandoffForKey,
|
||||
discardDeferredSplitPaneHandoffsForTab,
|
||||
releaseDeferredSplitPaneHandoff,
|
||||
type DeferredSplitPaneHandoffHandle
|
||||
} from './deferred-split-pane-handoff'
|
||||
import {
|
||||
seedStartupSessionRestoredBanner,
|
||||
type SessionRestoredBannerReason
|
||||
@@ -836,6 +852,23 @@ export function useTerminalPaneLifecycle({
|
||||
const mouseHideDisposables = mouseHideDisposablesRef.current
|
||||
const imeCompositionDisposables = imeCompositionDisposablesRef.current
|
||||
const imeNativeTextForwarderDisposables = imeNativeTextForwarderDisposablesRef.current
|
||||
// Numeric pane ids are mount-local; the handle itself is keyed by the
|
||||
// durable tab/leaf identity so a whole-tab remount can reclaim it.
|
||||
const deferredSplitHandoffs = new Map<number, DeferredSplitPaneHandoffHandle>()
|
||||
// A concrete PTY owns the input queue and settles the split admission fence,
|
||||
// so the deferred lookup stops being reusable. Both layout-binding variants
|
||||
// must run this: main routes live binds through the leaf-keyed one.
|
||||
const settleDeferredSplitOnBind = (paneId: number, ptyId: string | null): void => {
|
||||
if (!ptyId) {
|
||||
return
|
||||
}
|
||||
const deferredSplitHandoff = deferredSplitHandoffs.get(paneId)
|
||||
if (deferredSplitHandoff) {
|
||||
clearDeferredSplitPaneHandoff(deferredSplitHandoff)
|
||||
deferredSplitHandoffs.delete(paneId)
|
||||
}
|
||||
settlePaneCwdDeferredSpawn(paneCwdRef.current, paneId)
|
||||
}
|
||||
const worktreePath =
|
||||
useAppStore
|
||||
.getState()
|
||||
@@ -1005,8 +1038,22 @@ export function useTerminalPaneLifecycle({
|
||||
onShowSessionRestoredBanner,
|
||||
dispatchNotification,
|
||||
setCacheTimerStartedAt,
|
||||
syncPanePtyLayoutBinding,
|
||||
syncPanePtyLayoutBindingForLeaf,
|
||||
syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => {
|
||||
settleDeferredSplitOnBind(paneId, ptyId)
|
||||
syncPanePtyLayoutBinding(paneId, ptyId)
|
||||
},
|
||||
...(syncPanePtyLayoutBindingForLeaf
|
||||
? {
|
||||
syncPanePtyLayoutBindingForLeaf: (
|
||||
leafId: string,
|
||||
ptyId: string | null,
|
||||
sourcePaneId: number
|
||||
) => {
|
||||
settleDeferredSplitOnBind(sourcePaneId, ptyId)
|
||||
syncPanePtyLayoutBindingForLeaf(leafId, ptyId, sourcePaneId)
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
clearExitedPanePtyLayoutBinding,
|
||||
clearExitedPanePtyLayoutBindingForLeaf,
|
||||
onStartupBound,
|
||||
@@ -1051,8 +1098,35 @@ export function useTerminalPaneLifecycle({
|
||||
let releaseWebviewDragPassthrough: (() => void) | null = null
|
||||
|
||||
const manager = new PaneManager(container, {
|
||||
// `spawnHints.cwd` (from Split actions) lets the new PTY inherit the source pane's cwd — see docs/ssh-split-pane-inherit-cwd.md.
|
||||
// Split spawn hints let the renderer pane appear before a slow inherited-cwd lookup finishes.
|
||||
onPaneCreated: (pane, spawnHints) => {
|
||||
const paneKey = makePaneKey(tabId, pane.leafId)
|
||||
const restoredPtyId = ptyDeps.restoredPtyIdByLeafId?.[pane.leafId]
|
||||
const hasAuthoritativeSpawnHint = Boolean(
|
||||
spawnHints?.cwd || spawnHints?.ptyId || restoredPtyId
|
||||
)
|
||||
let effectiveSpawnHints = spawnHints
|
||||
let claimedDeferredSplitHandoff: ReturnType<typeof claimDeferredSplitPaneHandoff> = null
|
||||
let deferredSplitHandoff: DeferredSplitPaneHandoffHandle | undefined
|
||||
if (spawnHints?.cwdPromise && !hasAuthoritativeSpawnHint) {
|
||||
deferredSplitHandoff = beginDeferredSplitPaneHandoff(paneKey, spawnHints.cwdPromise)
|
||||
deferredSplitHandoffs.set(pane.id, deferredSplitHandoff)
|
||||
} else if (!hasAuthoritativeSpawnHint) {
|
||||
claimedDeferredSplitHandoff = claimDeferredSplitPaneHandoff(paneKey)
|
||||
if (claimedDeferredSplitHandoff) {
|
||||
deferredSplitHandoff = claimedDeferredSplitHandoff.handle
|
||||
deferredSplitHandoffs.set(pane.id, deferredSplitHandoff)
|
||||
effectiveSpawnHints = {
|
||||
...spawnHints,
|
||||
cwdPromise: claimedDeferredSplitHandoff.cwdPromise
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// A restored PTY or explicit spawn hint is authoritative; an older
|
||||
// deferred record must not be claimed by a later remount.
|
||||
discardDeferredSplitPaneHandoffForKey(paneKey)
|
||||
}
|
||||
const handoffForInput = deferredSplitHandoff
|
||||
// OSC 52 — TUI-initiated clipboard writes (Zellij/tmux/nvim/fzf/ssh).
|
||||
// Why: read settingsRef at fire time so mid-session gate toggles apply; return true in both paths so xterm doesn't fall through.
|
||||
const osc52Disposable = pane.terminal.parser.registerOscHandler(
|
||||
@@ -1072,11 +1146,39 @@ export function useTerminalPaneLifecycle({
|
||||
|
||||
// OSC 7 — shell-reported cwd; drives split-pane cwd inheritance. Install MUST stay before connectPanePty:
|
||||
// cold-restore replays PTY output synchronously from the first read, so a later handler misses the first OSC 7.
|
||||
if (!paneCwdRef.current.has(pane.id)) {
|
||||
const existingPaneCwd = paneCwdRef.current.get(pane.id)
|
||||
if (!existingPaneCwd) {
|
||||
paneCwdRef.current.set(pane.id, {
|
||||
cwd: resolvePaneSeedCwd(spawnHints?.cwd, ptyDeps.cwd),
|
||||
confirmed: false
|
||||
cwd: resolvePaneSeedCwd(effectiveSpawnHints?.cwd, ptyDeps.cwd),
|
||||
confirmed: false,
|
||||
...(effectiveSpawnHints?.cwdPromise
|
||||
? { deferredSplitSpawn: true, pendingCwd: effectiveSpawnHints.cwdPromise }
|
||||
: {})
|
||||
})
|
||||
} else if (effectiveSpawnHints?.cwdPromise && !existingPaneCwd.confirmed) {
|
||||
paneCwdRef.current.set(pane.id, {
|
||||
...existingPaneCwd,
|
||||
deferredSplitSpawn: true,
|
||||
pendingCwd: effectiveSpawnHints.cwdPromise
|
||||
})
|
||||
}
|
||||
if (effectiveSpawnHints?.cwdPromise) {
|
||||
const cwdPromise = effectiveSpawnHints.cwdPromise
|
||||
// A rejected lookup keeps the seed cwd; either way the settled identity
|
||||
// stays until bind/failure so a stale cleanup cannot clear a newer lookup.
|
||||
const applySettledCwd = (cwd: string | null): void => {
|
||||
const current = paneCwdRef.current.get(pane.id)
|
||||
if (!current || current.confirmed || current.pendingCwd !== cwdPromise) {
|
||||
return
|
||||
}
|
||||
paneCwdRef.current.set(pane.id, {
|
||||
cwd: cwd ?? current.cwd,
|
||||
confirmed: false,
|
||||
...(current.deferredSplitSpawn ? { deferredSplitSpawn: true } : {}),
|
||||
pendingCwd: cwdPromise
|
||||
})
|
||||
}
|
||||
void cwdPromise.then(applySettledCwd, () => applySettledCwd(null))
|
||||
}
|
||||
const osc7Disposable = pane.terminal.parser.registerOscHandler(
|
||||
7,
|
||||
@@ -1084,7 +1186,10 @@ export function useTerminalPaneLifecycle({
|
||||
const parsedCwd = parseOsc7(data, { uncHost: osc7UncHost })
|
||||
if (parsedCwd) {
|
||||
const confirmed = !isPaneReplaying(replayingPanesRef, pane.id)
|
||||
paneCwdRef.current.set(pane.id, { cwd: parsedCwd, confirmed })
|
||||
paneCwdRef.current.set(
|
||||
pane.id,
|
||||
mergePaneCwdFromOsc7(paneCwdRef.current.get(pane.id), parsedCwd, confirmed)
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -1424,12 +1529,39 @@ export function useTerminalPaneLifecycle({
|
||||
const panePtyBinding = connectPanePty(pane, manager, {
|
||||
...ptyDeps,
|
||||
...(onQueuedStartupSpawned ? { onQueuedStartupSpawned } : {}),
|
||||
...(effectiveSpawnHints?.cwdPromise
|
||||
? {
|
||||
onDeferredCwdSpawnFailed: () => {
|
||||
settlePaneCwdDeferredSpawn(
|
||||
paneCwdRef.current,
|
||||
pane.id,
|
||||
effectiveSpawnHints.cwdPromise
|
||||
)
|
||||
if (handoffForInput) {
|
||||
clearDeferredSplitPaneHandoff(handoffForInput)
|
||||
deferredSplitHandoffs.delete(pane.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(handoffForInput
|
||||
? {
|
||||
onPreconnectInput: (input: PtyPreconnectInputEntry) =>
|
||||
appendDeferredSplitPaneInput(handoffForInput, input)
|
||||
}
|
||||
: {}),
|
||||
...(claimedDeferredSplitHandoff?.preconnectInput.length
|
||||
? { preconnectInput: claimedDeferredSplitHandoff.preconnectInput }
|
||||
: {}),
|
||||
// Why: spread order matters — spawnHints.cwd (source pane) must override ptyDeps.cwd (worktree root) so splits boot in the live cwd.
|
||||
...(spawnHints?.cwd ? { cwd: spawnHints.cwd } : {}),
|
||||
restoredPtyIdByLeafId: spawnHints?.ptyId
|
||||
...(effectiveSpawnHints?.cwd ? { cwd: effectiveSpawnHints.cwd } : {}),
|
||||
...(effectiveSpawnHints?.cwdPromise
|
||||
? { cwdPromise: effectiveSpawnHints.cwdPromise }
|
||||
: {}),
|
||||
restoredPtyIdByLeafId: effectiveSpawnHints?.ptyId
|
||||
? {
|
||||
...ptyDeps.restoredPtyIdByLeafId,
|
||||
[pane.leafId]: spawnHints.ptyId
|
||||
[pane.leafId]: effectiveSpawnHints.ptyId
|
||||
}
|
||||
: ptyDeps.restoredPtyIdByLeafId,
|
||||
restoredLeafId: pane.leafId
|
||||
@@ -1539,6 +1671,17 @@ export function useTerminalPaneLifecycle({
|
||||
panePtyBindings.delete(paneId)
|
||||
}
|
||||
const leafId = closedPane?.leafId
|
||||
const deferredSplitHandoff = deferredSplitHandoffs.get(paneId)
|
||||
if (deferredSplitHandoff) {
|
||||
// Explicit pane removal is terminal for the split intent; only a
|
||||
// whole-tab remount is allowed to retain this record.
|
||||
clearDeferredSplitPaneHandoff(deferredSplitHandoff)
|
||||
deferredSplitHandoffs.delete(paneId)
|
||||
} else if (leafId) {
|
||||
// A close callback can outlive its mount-local numeric handle; the
|
||||
// durable leaf key still identifies the deferred split to discard.
|
||||
discardDeferredSplitPaneHandoffForKey(makePaneKey(tabId, leafId))
|
||||
}
|
||||
if (leafId && isRetiredSurface) {
|
||||
retireMountedTerminalPaneSurface({
|
||||
paneKey: makePaneKey(tabId, leafId),
|
||||
@@ -1971,13 +2114,20 @@ export function useTerminalPaneLifecycle({
|
||||
return () => {
|
||||
unregisterTerminalPaneSplitRequestHandler()
|
||||
window.removeEventListener(CLOSE_TERMINAL_PANE_EVENT, onCliClosePane)
|
||||
const currentWorktreeTabs = useAppStore.getState().tabsByWorktree[worktreeId]
|
||||
const tabStillExists = Boolean(
|
||||
const currentStore = useAppStore.getState()
|
||||
const currentWorktreeTabs = currentStore.tabsByWorktree[worktreeId]
|
||||
// Queued split cancellation stays worktree-scoped: a tab that merely moved
|
||||
// buckets must still cancel this worktree's queue.
|
||||
const tabRemainsInWorktree = Boolean(
|
||||
currentWorktreeTabs?.some((candidate) => candidate.id === tabId)
|
||||
)
|
||||
if (!tabStillExists) {
|
||||
if (!tabRemainsInWorktree) {
|
||||
cancelQueuedTerminalPaneSplitRequests(tabId, worktreeId)
|
||||
}
|
||||
// Handoff retention is deliberately broader: a tab move removes the old
|
||||
// worktree bucket before the replacement surface mounts, so use the shared
|
||||
// global ownership check to let an ID-less deferred split survive a rehome.
|
||||
const tabStillExists = isTerminalTabPresent(currentStore, tabId)
|
||||
unregisterRuntimeTab()
|
||||
if (resizeRaf !== null) {
|
||||
cancelAnimationFrame(resizeRaf)
|
||||
@@ -2047,8 +2197,19 @@ export function useTerminalPaneLifecycle({
|
||||
}
|
||||
})
|
||||
)
|
||||
for (const transport of paneTransports.values()) {
|
||||
for (const [paneId, transport] of paneTransports) {
|
||||
const ptyId = transport.getPtyId()
|
||||
const deferredSplitHandoff = deferredSplitHandoffs.get(paneId)
|
||||
if (deferredSplitHandoff) {
|
||||
if (tabStillExists && !ptyId) {
|
||||
// Keep only the transient launch record; the old transport and
|
||||
// xterm are still disposable during a whole-tab remount.
|
||||
releaseDeferredSplitPaneHandoff(deferredSplitHandoff)
|
||||
} else {
|
||||
clearDeferredSplitPaneHandoff(deferredSplitHandoff)
|
||||
}
|
||||
deferredSplitHandoffs.delete(paneId)
|
||||
}
|
||||
if (
|
||||
shouldDetachPaneTransportOnUnmount({
|
||||
tabStillExists,
|
||||
@@ -2067,6 +2228,11 @@ export function useTerminalPaneLifecycle({
|
||||
transport.destroy?.()
|
||||
}
|
||||
}
|
||||
if (!tabStillExists) {
|
||||
// Covers a pane whose transport was removed before this cleanup (for
|
||||
// example, a close raced the effect teardown).
|
||||
discardDeferredSplitPaneHandoffsForTab(tabId)
|
||||
}
|
||||
for (const panePtyBinding of panePtyBindings.values()) {
|
||||
panePtyBinding.dispose()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ManagedPane } from './pane-manager-types'
|
||||
import type { ManagedPane, PaneSplitOptions } from './pane-manager-types'
|
||||
import type { PaneManagerHost } from './pane-manager-host'
|
||||
import type { SplitPaneAroundLeafIdsOptions } from './pane-subtree-split'
|
||||
import {
|
||||
@@ -13,7 +13,7 @@ export function splitPaneOnManager(
|
||||
host: PaneManagerHost,
|
||||
paneId: number,
|
||||
direction: 'vertical' | 'horizontal',
|
||||
opts?: { ratio?: number; cwd?: string; leafId?: string; ptyId?: string }
|
||||
opts?: PaneSplitOptions
|
||||
): ManagedPane | null {
|
||||
return splitManagedPane({
|
||||
paneId,
|
||||
|
||||
@@ -20,9 +20,15 @@ import type { TerminalWebglAutoDecision } from './terminal-webgl-auto-policy'
|
||||
* hint is scoped to pane creation and does not live on the pane afterwards. */
|
||||
export type PaneSpawnHints = {
|
||||
cwd?: string
|
||||
cwdPromise?: Promise<string>
|
||||
ptyId?: string
|
||||
}
|
||||
|
||||
export type PaneSplitOptions = PaneSpawnHints & {
|
||||
ratio?: number
|
||||
leafId?: string
|
||||
}
|
||||
|
||||
export type ClosedPaneInfo = {
|
||||
paneId: number
|
||||
leafId: TerminalLeafId
|
||||
|
||||
@@ -131,7 +131,7 @@ export class PaneManager {
|
||||
splitPane(
|
||||
paneId: number,
|
||||
direction: 'vertical' | 'horizontal',
|
||||
opts?: { ratio?: number; cwd?: string; leafId?: string; ptyId?: string }
|
||||
opts?: Parameters<typeof splitPaneOnManager>[3]
|
||||
): ManagedPane | null {
|
||||
return splitPaneOnManager(this.host, paneId, direction, opts)
|
||||
}
|
||||
|
||||
@@ -118,6 +118,46 @@ describe('splitManagedPane', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('focuses the new pane before publishing an unresolved cwd spawn hint', () => {
|
||||
const existingPane = createPane(1, null)
|
||||
const newPane = createPane(2, null)
|
||||
const panes = new Map<number, ManagedPaneInternal>([[existingPane.id, existingPane]])
|
||||
const root = new MockElement(['root'])
|
||||
const existingContainer = existingPane.container as unknown as MockElement
|
||||
existingContainer.parentElement = root
|
||||
const cwdPromise = new Promise<string>(() => {
|
||||
// Keep CWD pending across every synchronous split assertion.
|
||||
})
|
||||
const setActivePaneId = vi.fn()
|
||||
const publishPaneCreated = vi.fn(() => {
|
||||
expect(newPane.terminal.focus).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
const result = splitManagedPane({
|
||||
paneId: existingPane.id,
|
||||
direction: 'vertical',
|
||||
opts: { cwdPromise },
|
||||
panes,
|
||||
root: root as unknown as HTMLElement,
|
||||
styleOptions: {},
|
||||
managerOptions: { linkOpenHint: () => '' },
|
||||
createPaneInternal: () => {
|
||||
panes.set(newPane.id, newPane)
|
||||
return newPane
|
||||
},
|
||||
createDivider: () => new MockElement(['pane-divider']) as unknown as HTMLElement,
|
||||
publishPaneCreated,
|
||||
getDragCallbacks: () => ({}) as never,
|
||||
setActivePaneId,
|
||||
isDestroyed: () => false
|
||||
})
|
||||
|
||||
expect(result?.id).toBe(newPane.id)
|
||||
expect(setActivePaneId).toHaveBeenCalledWith(newPane.id)
|
||||
expect(newPane.terminal.focus).toHaveBeenCalledOnce()
|
||||
expect(publishPaneCreated).toHaveBeenCalledWith(newPane, { cwdPromise })
|
||||
})
|
||||
|
||||
it('prepares every pane under a moved mounted subtree for split reparenting', () => {
|
||||
const fallbackPane = createPane(1, { dispose: vi.fn() })
|
||||
const siblingPane = createPane(2, { dispose: vi.fn() })
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ManagedPane,
|
||||
ManagedPaneInternal,
|
||||
PaneManagerOptions,
|
||||
PaneSplitOptions,
|
||||
PaneStyleOptions
|
||||
} from './pane-manager-types'
|
||||
import type { DragReorderCallbacks } from './pane-drag-reorder'
|
||||
@@ -30,7 +31,7 @@ type MovedPaneSplitState = {
|
||||
type SplitManagedPaneArgs = {
|
||||
paneId: number
|
||||
direction: 'vertical' | 'horizontal'
|
||||
opts?: { ratio?: number; cwd?: string; leafId?: string; ptyId?: string }
|
||||
opts?: PaneSplitOptions
|
||||
sourceContainer?: HTMLElement
|
||||
panes: Map<number, ManagedPaneInternal>
|
||||
root: HTMLElement
|
||||
@@ -148,6 +149,7 @@ function openSplitPane(
|
||||
// source cwd for local splits or attaches a runtime-spawned PTY for web splits.
|
||||
const spawnHints = {
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(args.opts?.cwdPromise ? { cwdPromise: args.opts.cwdPromise } : {}),
|
||||
...(args.opts?.ptyId ? { ptyId: args.opts.ptyId } : {})
|
||||
}
|
||||
args.publishPaneCreated(newPane, Object.keys(spawnHints).length > 0 ? spawnHints : undefined)
|
||||
|
||||
@@ -116,6 +116,17 @@ describe('terminal tab retirement planning', () => {
|
||||
expect(isTerminalTabPresent(state, 'tab-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes a tab after it is rehomed into a new worktree bucket', () => {
|
||||
const state = makeState({
|
||||
tabsByWorktree: {
|
||||
'wt-old': [],
|
||||
'wt-new': [makeTab('tab-rehomed', 'wt-new', null)]
|
||||
}
|
||||
})
|
||||
|
||||
expect(isTerminalTabPresent(state, 'tab-rehomed')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not retire a PTY still referenced by another live surface', () => {
|
||||
const shared = 'pty-in-transfer'
|
||||
const state = makeState({
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
const MAX_ERROR_TEXT_LENGTH = 200
|
||||
// Absolute POSIX/Windows paths, which routinely appear inside cleanup error text.
|
||||
const ABSOLUTE_PATH = /(?:[A-Za-z]:\\|\/)[\w.\-\\/]{2,}/g
|
||||
|
||||
function redactText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
return value.replace(ABSOLUTE_PATH, '<path>').slice(0, MAX_ERROR_TEXT_LENGTH)
|
||||
}
|
||||
|
||||
function redactSamples(samples: unknown): unknown {
|
||||
if (!Array.isArray(samples)) {
|
||||
return samples
|
||||
}
|
||||
return samples.map((sample) =>
|
||||
sample && typeof sample === 'object' && 'cleanupError' in sample
|
||||
? { ...sample, cleanupError: redactText((sample as { cleanupError: unknown }).cleanupError) }
|
||||
: sample
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips machine-identifying data so a report can be shared verbatim: the seeded
|
||||
* repo lives under an operator-overridable path, and cleanup/abort text is
|
||||
* unbounded free-form error output.
|
||||
*/
|
||||
export function sanitizeTerminalSplitLatencyReport(
|
||||
report: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...report,
|
||||
testRepoPath: '<test-repo>',
|
||||
abortReason: redactText(report.abortReason),
|
||||
warmupSamples: redactSamples(report.warmupSamples),
|
||||
measuredSamples: redactSamples(report.measuredSamples)
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the benchmark report so a passing run cannot silently lose its artifact. */
|
||||
export function writeTerminalSplitLatencyArtifact(outputPath: string, body: string): void {
|
||||
try {
|
||||
writeFileSync(outputPath, body, 'utf8')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`[terminal-split-activation-latency] unable to write ${outputPath}: ${message}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
sanitizeTerminalSplitLatencyReport,
|
||||
writeTerminalSplitLatencyArtifact
|
||||
} from './terminal-split-activation-latency-artifact'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (temporaryDirectories.length > 0) {
|
||||
const directory = temporaryDirectories.pop()
|
||||
if (directory) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('writeTerminalSplitLatencyArtifact', () => {
|
||||
it('writes the report body to the requested path', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-split-latency-artifact-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const outputPath = join(directory, 'report.json')
|
||||
const body = '{"status":"passed"}\n'
|
||||
|
||||
writeTerminalSplitLatencyArtifact(outputPath, body)
|
||||
|
||||
expect(existsSync(outputPath)).toBe(true)
|
||||
expect(readFileSync(outputPath, 'utf8')).toBe(body)
|
||||
})
|
||||
|
||||
it('throws when the report path cannot be written', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-split-latency-artifact-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const outputPath = join(directory, 'missing-parent', 'report.json')
|
||||
|
||||
expect(() => writeTerminalSplitLatencyArtifact(outputPath, '{}')).toThrow(
|
||||
`[terminal-split-activation-latency] unable to write ${outputPath}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeTerminalSplitLatencyReport', () => {
|
||||
it('replaces the machine-local test repo path', () => {
|
||||
expect(
|
||||
sanitizeTerminalSplitLatencyReport({ testRepoPath: '/var/folders/ab/T/orca-seeded-repo' })
|
||||
.testRepoPath
|
||||
).toBe('<test-repo>')
|
||||
})
|
||||
|
||||
it('redacts absolute paths and bounds free-form cleanup text', () => {
|
||||
const sanitized = sanitizeTerminalSplitLatencyReport({
|
||||
abortReason: 'ENOENT: /Users/someone/secret/dir missing',
|
||||
measuredSamples: [{ shortcutToFocusMs: 12, cleanupError: `x /tmp/a ${'y'.repeat(500)}` }]
|
||||
})
|
||||
|
||||
expect(sanitized.abortReason).toBe('ENOENT: <path> missing')
|
||||
const [sample] = sanitized.measuredSamples as { cleanupError: string }[]
|
||||
expect(sample?.cleanupError).not.toContain('/tmp/a')
|
||||
expect(sample?.cleanupError.length).toBeLessThanOrEqual(200)
|
||||
})
|
||||
|
||||
it('keeps timing fields and non-string cleanup values intact', () => {
|
||||
const sanitized = sanitizeTerminalSplitLatencyReport({
|
||||
headlineMs: { shortcutToFocusP50: 12 },
|
||||
measuredSamples: [{ shortcutToFocusMs: 12, cleanupError: null }]
|
||||
})
|
||||
|
||||
expect(sanitized.headlineMs).toEqual({ shortcutToFocusP50: 12 })
|
||||
expect(sanitized.measuredSamples).toEqual([{ shortcutToFocusMs: 12, cleanupError: null }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { ElectronApplication } from '@stablyai/playwright-test'
|
||||
import type { SplitLatencyMainProbeEvent } from './terminal-split-activation-latency-phases'
|
||||
|
||||
type MainProbeInvokeHandler = (event: unknown, args: Record<string, unknown>) => unknown
|
||||
|
||||
type SplitLatencyMainProbeState = {
|
||||
events: SplitLatencyMainProbeEvent[]
|
||||
nextOperationId: number
|
||||
cwdHandler: MainProbeInvokeHandler
|
||||
spawnHandler: MainProbeInvokeHandler
|
||||
writeAcceptedHandler: MainProbeInvokeHandler
|
||||
originalCwdHandler: MainProbeInvokeHandler
|
||||
originalSpawnHandler: MainProbeInvokeHandler
|
||||
originalWriteAcceptedHandler: MainProbeInvokeHandler
|
||||
writeListener: (event: unknown, args: { id?: unknown; data?: unknown }) => void
|
||||
}
|
||||
|
||||
export async function installSplitLatencyMainProbe(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate(({ ipcMain }) => {
|
||||
const scope = globalThis as typeof globalThis & {
|
||||
__terminalSplitLatencyMainProbe?: SplitLatencyMainProbeState
|
||||
}
|
||||
if (scope.__terminalSplitLatencyMainProbe) {
|
||||
throw new Error('Terminal split latency main probe is already installed')
|
||||
}
|
||||
const handlers = (
|
||||
ipcMain as unknown as { _invokeHandlers?: Map<string, MainProbeInvokeHandler> }
|
||||
)._invokeHandlers
|
||||
const originalCwdHandler = handlers?.get('pty:getCwd')
|
||||
const originalSpawnHandler = handlers?.get('pty:spawn')
|
||||
const originalWriteAcceptedHandler = handlers?.get('pty:writeAccepted')
|
||||
if (
|
||||
!handlers ||
|
||||
!originalCwdHandler ||
|
||||
!originalSpawnHandler ||
|
||||
!originalWriteAcceptedHandler
|
||||
) {
|
||||
throw new Error('Terminal split latency main probe could not find PTY invoke handlers')
|
||||
}
|
||||
const state = {
|
||||
events: [],
|
||||
nextOperationId: 1,
|
||||
originalCwdHandler,
|
||||
originalSpawnHandler,
|
||||
originalWriteAcceptedHandler
|
||||
} as unknown as SplitLatencyMainProbeState
|
||||
state.cwdHandler = async (event, args) => {
|
||||
const operationId = state.nextOperationId++
|
||||
const ptyId = typeof args?.id === 'string' ? args.id : null
|
||||
state.events.push({
|
||||
kind: 'cwd-request',
|
||||
operationId,
|
||||
atEpochMs: Date.now(),
|
||||
ptyId,
|
||||
writeChannel: null
|
||||
})
|
||||
try {
|
||||
return await state.originalCwdHandler(event, args)
|
||||
} finally {
|
||||
state.events.push({
|
||||
kind: 'cwd-settled',
|
||||
operationId,
|
||||
atEpochMs: Date.now(),
|
||||
ptyId,
|
||||
writeChannel: null
|
||||
})
|
||||
}
|
||||
}
|
||||
state.spawnHandler = async (event, args) => {
|
||||
const operationId = state.nextOperationId++
|
||||
state.events.push({
|
||||
kind: 'pty-spawn-request',
|
||||
operationId,
|
||||
atEpochMs: Date.now(),
|
||||
ptyId: null,
|
||||
writeChannel: null
|
||||
})
|
||||
try {
|
||||
const result = await state.originalSpawnHandler(event, args)
|
||||
const ptyId =
|
||||
result && typeof result === 'object' && 'id' in result && typeof result.id === 'string'
|
||||
? result.id
|
||||
: null
|
||||
state.events.push({
|
||||
kind: 'pty-spawn-result',
|
||||
operationId,
|
||||
atEpochMs: Date.now(),
|
||||
ptyId,
|
||||
writeChannel: null
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
state.events.push({
|
||||
kind: 'pty-spawn-result',
|
||||
operationId,
|
||||
atEpochMs: Date.now(),
|
||||
ptyId: null,
|
||||
writeChannel: null
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
state.writeListener = (_event, args) => {
|
||||
if (args?.data !== '\r') {
|
||||
return
|
||||
}
|
||||
state.events.push({
|
||||
kind: 'pty-write-cr',
|
||||
operationId: null,
|
||||
atEpochMs: Date.now(),
|
||||
ptyId: typeof args.id === 'string' ? args.id : null,
|
||||
writeChannel: 'pty:write'
|
||||
})
|
||||
}
|
||||
state.writeAcceptedHandler = (event, args) => {
|
||||
if (args?.data === '\r') {
|
||||
state.events.push({
|
||||
kind: 'pty-write-cr',
|
||||
operationId: null,
|
||||
atEpochMs: Date.now(),
|
||||
ptyId: typeof args.id === 'string' ? args.id : null,
|
||||
writeChannel: 'pty:writeAccepted'
|
||||
})
|
||||
}
|
||||
return state.originalWriteAcceptedHandler(event, args)
|
||||
}
|
||||
handlers.set('pty:getCwd', state.cwdHandler)
|
||||
handlers.set('pty:spawn', state.spawnHandler)
|
||||
handlers.set('pty:writeAccepted', state.writeAcceptedHandler)
|
||||
ipcMain.prependListener('pty:write', state.writeListener)
|
||||
scope.__terminalSplitLatencyMainProbe = state
|
||||
})
|
||||
}
|
||||
|
||||
export async function resetSplitLatencyMainProbe(electronApp: ElectronApplication): Promise<void> {
|
||||
await electronApp.evaluate(() => {
|
||||
const state = (
|
||||
globalThis as typeof globalThis & {
|
||||
__terminalSplitLatencyMainProbe?: SplitLatencyMainProbeState
|
||||
}
|
||||
).__terminalSplitLatencyMainProbe
|
||||
if (!state) {
|
||||
throw new Error('Terminal split latency main probe is not installed')
|
||||
}
|
||||
state.events.length = 0
|
||||
})
|
||||
}
|
||||
|
||||
export async function readSplitLatencyMainProbe(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<SplitLatencyMainProbeEvent[]> {
|
||||
return electronApp.evaluate(() => {
|
||||
const state = (
|
||||
globalThis as typeof globalThis & {
|
||||
__terminalSplitLatencyMainProbe?: SplitLatencyMainProbeState
|
||||
}
|
||||
).__terminalSplitLatencyMainProbe
|
||||
if (!state) {
|
||||
throw new Error('Terminal split latency main probe is not installed')
|
||||
}
|
||||
return [...state.events]
|
||||
})
|
||||
}
|
||||
|
||||
export async function disposeSplitLatencyMainProbe(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate(({ ipcMain }) => {
|
||||
const scope = globalThis as typeof globalThis & {
|
||||
__terminalSplitLatencyMainProbe?: SplitLatencyMainProbeState
|
||||
}
|
||||
const state = scope.__terminalSplitLatencyMainProbe
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
const handlers = (
|
||||
ipcMain as unknown as { _invokeHandlers?: Map<string, MainProbeInvokeHandler> }
|
||||
)._invokeHandlers
|
||||
if (handlers?.get('pty:getCwd') === state.cwdHandler) {
|
||||
handlers.set('pty:getCwd', state.originalCwdHandler)
|
||||
}
|
||||
if (handlers?.get('pty:spawn') === state.spawnHandler) {
|
||||
handlers.set('pty:spawn', state.originalSpawnHandler)
|
||||
}
|
||||
if (handlers?.get('pty:writeAccepted') === state.writeAcceptedHandler) {
|
||||
handlers.set('pty:writeAccepted', state.originalWriteAcceptedHandler)
|
||||
}
|
||||
ipcMain.removeListener('pty:write', state.writeListener)
|
||||
delete scope.__terminalSplitLatencyMainProbe
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
export type RendererPhaseStamps = {
|
||||
marker: string
|
||||
sourcePaneId: number
|
||||
sourcePtyId: string
|
||||
newPaneId: number | null
|
||||
newPtyId: string | null
|
||||
rendererTimeOriginEpochMs: number
|
||||
keydownAtMs: number | null
|
||||
focusAtMs: number | null
|
||||
cwdRequestAtMs: number | null
|
||||
cwdSettledAtMs: number | null
|
||||
ptySpawnRequestAtMs: number | null
|
||||
ptySpawnResultAtMs: number | null
|
||||
ptyBoundAtMs: number | null
|
||||
fixtureUnlockRequestedAtMs: number | null
|
||||
fixtureUnlockIpcWriteAtMs: number | null
|
||||
fixtureUnlockIpcWriteChannel: 'pty:write' | 'pty:writeAccepted' | null
|
||||
fixtureReadyParsedAtMs: number | null
|
||||
inputAtMs: number | null
|
||||
firstEchoAtMs: number | null
|
||||
}
|
||||
|
||||
export type SplitLatencyMainProbeEvent = {
|
||||
kind: 'cwd-request' | 'cwd-settled' | 'pty-spawn-request' | 'pty-spawn-result' | 'pty-write-cr'
|
||||
operationId: number | null
|
||||
atEpochMs: number
|
||||
ptyId: string | null
|
||||
writeChannel: 'pty:write' | 'pty:writeAccepted' | null
|
||||
}
|
||||
|
||||
export type SplitLatencySample = RendererPhaseStamps & {
|
||||
phase: 'warmup' | 'measured'
|
||||
iteration: number
|
||||
completedWithinTimeout: boolean
|
||||
paneCountAfterProbe: number
|
||||
ptyExitObserved: boolean
|
||||
cleanupError: string | null
|
||||
shortcutToFocusMs: number | null
|
||||
shortcutToCwdRequestMs: number | null
|
||||
cwdLookupMs: number | null
|
||||
cwdSettleToPtySpawnRequestMs: number | null
|
||||
ptySpawnRequestToResultMs: number | null
|
||||
ptySpawnResultToBindMs: number | null
|
||||
shortcutToPtyBindMs: number | null
|
||||
ptyBindToFixtureUnlockRequestMs: number | null
|
||||
fixtureUnlockRequestToIpcWriteMs: number | null
|
||||
fixtureUnlockIpcWriteToReadyParseMs: number | null
|
||||
fixtureReadyParseToInputMs: number | null
|
||||
shortcutToFirstEchoMs: number | null
|
||||
ptyBindToFirstEchoMs: number | null
|
||||
inputToFirstEchoMs: number | null
|
||||
missing: string[]
|
||||
success: boolean
|
||||
}
|
||||
|
||||
function elapsed(start: number | null, end: number | null): number | null {
|
||||
return start === null || end === null ? null : Math.max(0, end - start)
|
||||
}
|
||||
|
||||
export function mergeSplitLatencyMainProbeEvents(
|
||||
stamps: RendererPhaseStamps,
|
||||
events: readonly SplitLatencyMainProbeEvent[]
|
||||
): RendererPhaseStamps {
|
||||
const keydownEpochMs =
|
||||
stamps.keydownAtMs === null
|
||||
? Number.NEGATIVE_INFINITY
|
||||
: stamps.rendererTimeOriginEpochMs + stamps.keydownAtMs
|
||||
const afterKeydown = events.filter((event) => event.atEpochMs >= keydownEpochMs - 2)
|
||||
const cwdRequest = afterKeydown.find(
|
||||
(event) => event.kind === 'cwd-request' && event.ptyId === stamps.sourcePtyId
|
||||
)
|
||||
const cwdSettled = cwdRequest
|
||||
? afterKeydown.find(
|
||||
(event) => event.kind === 'cwd-settled' && event.operationId === cwdRequest.operationId
|
||||
)
|
||||
: undefined
|
||||
const spawnResult = afterKeydown.find(
|
||||
(event) => event.kind === 'pty-spawn-result' && event.ptyId === stamps.newPtyId
|
||||
)
|
||||
const spawnRequest = spawnResult
|
||||
? afterKeydown.find(
|
||||
(event) =>
|
||||
event.kind === 'pty-spawn-request' && event.operationId === spawnResult.operationId
|
||||
)
|
||||
: undefined
|
||||
const unlockRequestEpochMs =
|
||||
stamps.fixtureUnlockRequestedAtMs === null
|
||||
? Number.NEGATIVE_INFINITY
|
||||
: stamps.rendererTimeOriginEpochMs + stamps.fixtureUnlockRequestedAtMs
|
||||
const fixtureUnlockWrite = afterKeydown.find(
|
||||
(event) =>
|
||||
event.kind === 'pty-write-cr' &&
|
||||
event.ptyId === stamps.newPtyId &&
|
||||
event.atEpochMs >= unlockRequestEpochMs - 2
|
||||
)
|
||||
const toRendererTime = (event: SplitLatencyMainProbeEvent | undefined): number | null =>
|
||||
event ? event.atEpochMs - stamps.rendererTimeOriginEpochMs : null
|
||||
|
||||
return {
|
||||
...stamps,
|
||||
cwdRequestAtMs: toRendererTime(cwdRequest),
|
||||
cwdSettledAtMs: toRendererTime(cwdSettled),
|
||||
ptySpawnRequestAtMs: toRendererTime(spawnRequest),
|
||||
ptySpawnResultAtMs: toRendererTime(spawnResult),
|
||||
fixtureUnlockIpcWriteAtMs: toRendererTime(fixtureUnlockWrite),
|
||||
fixtureUnlockIpcWriteChannel: fixtureUnlockWrite?.writeChannel ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export function createSplitLatencySample(args: {
|
||||
phase: SplitLatencySample['phase']
|
||||
iteration: number
|
||||
stamps: RendererPhaseStamps
|
||||
completedWithinTimeout: boolean
|
||||
paneCountAfterProbe: number
|
||||
ptyExitObserved: boolean
|
||||
cleanupError: string | null
|
||||
}): SplitLatencySample {
|
||||
const { stamps } = args
|
||||
const missing = [
|
||||
...(stamps.keydownAtMs === null ? ['keydown'] : []),
|
||||
...(stamps.focusAtMs === null ? ['focus'] : []),
|
||||
...(stamps.cwdRequestAtMs === null ? ['cwd-request'] : []),
|
||||
...(stamps.cwdSettledAtMs === null ? ['cwd-settled'] : []),
|
||||
...(stamps.ptySpawnRequestAtMs === null ? ['pty-spawn-request'] : []),
|
||||
...(stamps.ptySpawnResultAtMs === null ? ['pty-spawn-result'] : []),
|
||||
...(stamps.ptyBoundAtMs === null ? ['pty-bind'] : []),
|
||||
...(stamps.fixtureUnlockRequestedAtMs === null ? ['fixture-unlock-request'] : []),
|
||||
...(stamps.fixtureUnlockIpcWriteAtMs === null ? ['fixture-unlock-ipc-write'] : []),
|
||||
...(stamps.fixtureReadyParsedAtMs === null ? ['fixture-ready-parse'] : []),
|
||||
...(stamps.inputAtMs === null ? ['input'] : []),
|
||||
...(stamps.firstEchoAtMs === null ? ['first-echo'] : []),
|
||||
...(stamps.newPtyId === stamps.sourcePtyId ? ['pty-identity'] : []),
|
||||
...(args.paneCountAfterProbe !== 2 ? [`pane-count:${args.paneCountAfterProbe}`] : []),
|
||||
...(!args.ptyExitObserved ? ['pty-exit'] : []),
|
||||
...(args.cleanupError ? ['cleanup'] : [])
|
||||
]
|
||||
return {
|
||||
...stamps,
|
||||
phase: args.phase,
|
||||
iteration: args.iteration,
|
||||
completedWithinTimeout: args.completedWithinTimeout,
|
||||
paneCountAfterProbe: args.paneCountAfterProbe,
|
||||
ptyExitObserved: args.ptyExitObserved,
|
||||
cleanupError: args.cleanupError,
|
||||
shortcutToFocusMs: elapsed(stamps.keydownAtMs, stamps.focusAtMs),
|
||||
shortcutToCwdRequestMs: elapsed(stamps.keydownAtMs, stamps.cwdRequestAtMs),
|
||||
cwdLookupMs: elapsed(stamps.cwdRequestAtMs, stamps.cwdSettledAtMs),
|
||||
cwdSettleToPtySpawnRequestMs: elapsed(stamps.cwdSettledAtMs, stamps.ptySpawnRequestAtMs),
|
||||
ptySpawnRequestToResultMs: elapsed(stamps.ptySpawnRequestAtMs, stamps.ptySpawnResultAtMs),
|
||||
ptySpawnResultToBindMs: elapsed(stamps.ptySpawnResultAtMs, stamps.ptyBoundAtMs),
|
||||
shortcutToPtyBindMs: elapsed(stamps.keydownAtMs, stamps.ptyBoundAtMs),
|
||||
ptyBindToFixtureUnlockRequestMs: elapsed(
|
||||
stamps.ptyBoundAtMs,
|
||||
stamps.fixtureUnlockRequestedAtMs
|
||||
),
|
||||
fixtureUnlockRequestToIpcWriteMs: elapsed(
|
||||
stamps.fixtureUnlockRequestedAtMs,
|
||||
stamps.fixtureUnlockIpcWriteAtMs
|
||||
),
|
||||
fixtureUnlockIpcWriteToReadyParseMs: elapsed(
|
||||
stamps.fixtureUnlockIpcWriteAtMs,
|
||||
stamps.fixtureReadyParsedAtMs
|
||||
),
|
||||
fixtureReadyParseToInputMs: elapsed(stamps.fixtureReadyParsedAtMs, stamps.inputAtMs),
|
||||
shortcutToFirstEchoMs: elapsed(stamps.keydownAtMs, stamps.firstEchoAtMs),
|
||||
ptyBindToFirstEchoMs: elapsed(stamps.ptyBoundAtMs, stamps.firstEchoAtMs),
|
||||
inputToFirstEchoMs: elapsed(stamps.inputAtMs, stamps.firstEchoAtMs),
|
||||
missing,
|
||||
success: args.completedWithinTimeout && missing.length === 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { summarizeLatencies, type LatencyDistribution } from './codex-composer-echo-latency-probe'
|
||||
import type { SplitLatencySample } from './terminal-split-activation-latency-phases'
|
||||
|
||||
export type BenchmarkRevisionIdentity = {
|
||||
headSha: string
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
export type SampleSummary = {
|
||||
counts: {
|
||||
requested: number
|
||||
attempted: number
|
||||
success: number
|
||||
missing: number
|
||||
unattempted: number
|
||||
missingEvents: {
|
||||
keydown: number
|
||||
focus: number
|
||||
cwdRequest: number
|
||||
cwdSettled: number
|
||||
ptySpawnRequest: number
|
||||
ptySpawnResult: number
|
||||
ptyBind: number
|
||||
fixtureUnlockRequest: number
|
||||
fixtureUnlockIpcWrite: number
|
||||
fixtureReadyParse: number
|
||||
input: number
|
||||
firstEcho: number
|
||||
paneCount: number
|
||||
ptyIdentity: number
|
||||
ptyExit: number
|
||||
cleanup: number
|
||||
}
|
||||
}
|
||||
distributions: {
|
||||
shortcutToFocusMs: LatencyDistribution
|
||||
shortcutToCwdRequestMs: LatencyDistribution
|
||||
cwdLookupMs: LatencyDistribution
|
||||
cwdSettleToPtySpawnRequestMs: LatencyDistribution
|
||||
ptySpawnRequestToResultMs: LatencyDistribution
|
||||
ptySpawnResultToBindMs: LatencyDistribution
|
||||
shortcutToPtyBindMs: LatencyDistribution
|
||||
ptyBindToFixtureUnlockRequestMs: LatencyDistribution
|
||||
fixtureUnlockRequestToIpcWriteMs: LatencyDistribution
|
||||
fixtureUnlockIpcWriteToReadyParseMs: LatencyDistribution
|
||||
fixtureReadyParseToInputMs: LatencyDistribution
|
||||
shortcutToFirstEchoMs: LatencyDistribution
|
||||
ptyBindToFirstEchoMs: LatencyDistribution
|
||||
inputToFirstEchoMs: LatencyDistribution
|
||||
}
|
||||
}
|
||||
|
||||
export type BrowserWindowState = {
|
||||
browserWindowVisible: boolean
|
||||
windowCount: number
|
||||
}
|
||||
|
||||
export type TerminalSplitLatencyReportConfig = {
|
||||
warmupCycles: number
|
||||
measuredCycles: number
|
||||
maxMeasuredCycles: number
|
||||
testTimeoutMs: number
|
||||
splitChord: string
|
||||
closeChord: string
|
||||
sampleTimeoutMs: number
|
||||
cleanupTimeoutMs: number
|
||||
processCwdCacheExpiryWaitMs: number
|
||||
}
|
||||
|
||||
export type BenchmarkReportResult = {
|
||||
report: Record<string, unknown>
|
||||
warmupSummary: SampleSummary
|
||||
measuredSummary: SampleSummary
|
||||
}
|
||||
|
||||
function valuesFor(
|
||||
samples: SplitLatencySample[],
|
||||
key: keyof SampleSummary['distributions']
|
||||
): number[] {
|
||||
return samples.flatMap((sample) => {
|
||||
const value = sample[key]
|
||||
return value === null ? [] : [value]
|
||||
})
|
||||
}
|
||||
|
||||
export function summarizeSamples(samples: SplitLatencySample[], requested: number): SampleSummary {
|
||||
const missingEvents = {
|
||||
keydown: samples.filter((sample) => sample.keydownAtMs === null).length,
|
||||
focus: samples.filter((sample) => sample.focusAtMs === null).length,
|
||||
cwdRequest: samples.filter((sample) => sample.cwdRequestAtMs === null).length,
|
||||
cwdSettled: samples.filter((sample) => sample.cwdSettledAtMs === null).length,
|
||||
ptySpawnRequest: samples.filter((sample) => sample.ptySpawnRequestAtMs === null).length,
|
||||
ptySpawnResult: samples.filter((sample) => sample.ptySpawnResultAtMs === null).length,
|
||||
ptyBind: samples.filter((sample) => sample.ptyBoundAtMs === null).length,
|
||||
fixtureUnlockRequest: samples.filter((sample) => sample.fixtureUnlockRequestedAtMs === null)
|
||||
.length,
|
||||
fixtureUnlockIpcWrite: samples.filter((sample) => sample.fixtureUnlockIpcWriteAtMs === null)
|
||||
.length,
|
||||
fixtureReadyParse: samples.filter((sample) => sample.fixtureReadyParsedAtMs === null).length,
|
||||
input: samples.filter((sample) => sample.inputAtMs === null).length,
|
||||
firstEcho: samples.filter((sample) => sample.firstEchoAtMs === null).length,
|
||||
paneCount: samples.filter((sample) => sample.paneCountAfterProbe !== 2).length,
|
||||
ptyIdentity: samples.filter((sample) => sample.newPtyId === sample.sourcePtyId).length,
|
||||
ptyExit: samples.filter((sample) => !sample.ptyExitObserved).length,
|
||||
cleanup: samples.filter((sample) => sample.cleanupError !== null).length
|
||||
}
|
||||
const success = samples.filter((sample) => sample.success).length
|
||||
return {
|
||||
counts: {
|
||||
requested,
|
||||
attempted: samples.length,
|
||||
success,
|
||||
missing: samples.length - success,
|
||||
unattempted: Math.max(0, requested - samples.length),
|
||||
missingEvents
|
||||
},
|
||||
distributions: {
|
||||
shortcutToFocusMs: summarizeLatencies(valuesFor(samples, 'shortcutToFocusMs')),
|
||||
shortcutToCwdRequestMs: summarizeLatencies(valuesFor(samples, 'shortcutToCwdRequestMs')),
|
||||
cwdLookupMs: summarizeLatencies(valuesFor(samples, 'cwdLookupMs')),
|
||||
cwdSettleToPtySpawnRequestMs: summarizeLatencies(
|
||||
valuesFor(samples, 'cwdSettleToPtySpawnRequestMs')
|
||||
),
|
||||
ptySpawnRequestToResultMs: summarizeLatencies(
|
||||
valuesFor(samples, 'ptySpawnRequestToResultMs')
|
||||
),
|
||||
ptySpawnResultToBindMs: summarizeLatencies(valuesFor(samples, 'ptySpawnResultToBindMs')),
|
||||
shortcutToPtyBindMs: summarizeLatencies(valuesFor(samples, 'shortcutToPtyBindMs')),
|
||||
ptyBindToFixtureUnlockRequestMs: summarizeLatencies(
|
||||
valuesFor(samples, 'ptyBindToFixtureUnlockRequestMs')
|
||||
),
|
||||
fixtureUnlockRequestToIpcWriteMs: summarizeLatencies(
|
||||
valuesFor(samples, 'fixtureUnlockRequestToIpcWriteMs')
|
||||
),
|
||||
fixtureUnlockIpcWriteToReadyParseMs: summarizeLatencies(
|
||||
valuesFor(samples, 'fixtureUnlockIpcWriteToReadyParseMs')
|
||||
),
|
||||
fixtureReadyParseToInputMs: summarizeLatencies(
|
||||
valuesFor(samples, 'fixtureReadyParseToInputMs')
|
||||
),
|
||||
shortcutToFirstEchoMs: summarizeLatencies(valuesFor(samples, 'shortcutToFirstEchoMs')),
|
||||
ptyBindToFirstEchoMs: summarizeLatencies(valuesFor(samples, 'ptyBindToFirstEchoMs')),
|
||||
inputToFirstEchoMs: summarizeLatencies(valuesFor(samples, 'inputToFirstEchoMs'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBenchmarkReport(args: {
|
||||
label: string
|
||||
revision: BenchmarkRevisionIdentity
|
||||
headfulRun: boolean
|
||||
windowState: BrowserWindowState
|
||||
documentVisibility: string
|
||||
testRepoPath: string
|
||||
warmupSamples: SplitLatencySample[]
|
||||
measuredSamples: SplitLatencySample[]
|
||||
abortError: Error | null
|
||||
config: TerminalSplitLatencyReportConfig
|
||||
}): BenchmarkReportResult {
|
||||
const warmupSummary = summarizeSamples(args.warmupSamples, args.config.warmupCycles)
|
||||
const measuredSummary = summarizeSamples(args.measuredSamples, args.config.measuredCycles)
|
||||
const runComplete =
|
||||
warmupSummary.counts.success === args.config.warmupCycles &&
|
||||
measuredSummary.counts.success === args.config.measuredCycles &&
|
||||
args.abortError === null
|
||||
const headlineMs = runComplete
|
||||
? {
|
||||
shortcutToFocusP50: measuredSummary.distributions.shortcutToFocusMs.p50,
|
||||
shortcutToFocusP95: measuredSummary.distributions.shortcutToFocusMs.p95,
|
||||
shortcutToFocusMax: measuredSummary.distributions.shortcutToFocusMs.max,
|
||||
shortcutToPtyBindP50: measuredSummary.distributions.shortcutToPtyBindMs.p50,
|
||||
shortcutToPtyBindP95: measuredSummary.distributions.shortcutToPtyBindMs.p95,
|
||||
shortcutToPtyBindMax: measuredSummary.distributions.shortcutToPtyBindMs.max,
|
||||
shortcutToFirstEchoP50: measuredSummary.distributions.shortcutToFirstEchoMs.p50,
|
||||
shortcutToFirstEchoP95: measuredSummary.distributions.shortcutToFirstEchoMs.p95,
|
||||
shortcutToFirstEchoMax: measuredSummary.distributions.shortcutToFirstEchoMs.max
|
||||
}
|
||||
: null
|
||||
return {
|
||||
report: {
|
||||
schemaVersion: 2,
|
||||
benchmark: 'terminal-split-activation-latency',
|
||||
label: args.label,
|
||||
revision: args.revision,
|
||||
status: runComplete ? 'passed' : 'failed',
|
||||
valid: runComplete,
|
||||
abortReason: args.abortError?.message ?? null,
|
||||
timestamp: new Date().toISOString(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
nodeVersion: process.version,
|
||||
headful: args.headfulRun,
|
||||
browserWindowVisible: args.windowState.browserWindowVisible,
|
||||
documentVisibility: args.documentVisibility,
|
||||
testRepoPath: args.testRepoPath,
|
||||
config: args.config,
|
||||
headlineMs,
|
||||
warmupSummary,
|
||||
measuredSummary,
|
||||
warmupSamples: args.warmupSamples,
|
||||
measuredSamples: args.measuredSamples
|
||||
},
|
||||
warmupSummary,
|
||||
measuredSummary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildBenchmarkReport, summarizeSamples } from './terminal-split-activation-latency-report'
|
||||
import {
|
||||
createSplitLatencySample,
|
||||
mergeSplitLatencyMainProbeEvents,
|
||||
type RendererPhaseStamps,
|
||||
type SplitLatencyMainProbeEvent
|
||||
} from './terminal-split-activation-latency-phases'
|
||||
|
||||
function createRendererStamps(): RendererPhaseStamps {
|
||||
return {
|
||||
marker: 'echo-marker',
|
||||
sourcePaneId: 1,
|
||||
sourcePtyId: 'pty-source',
|
||||
newPaneId: 2,
|
||||
newPtyId: 'pty-child',
|
||||
rendererTimeOriginEpochMs: 1_000,
|
||||
keydownAtMs: 10,
|
||||
focusAtMs: 20,
|
||||
cwdRequestAtMs: null,
|
||||
cwdSettledAtMs: null,
|
||||
ptySpawnRequestAtMs: null,
|
||||
ptySpawnResultAtMs: null,
|
||||
ptyBoundAtMs: 82,
|
||||
fixtureUnlockRequestedAtMs: 83,
|
||||
fixtureUnlockIpcWriteAtMs: null,
|
||||
fixtureUnlockIpcWriteChannel: null,
|
||||
fixtureReadyParsedAtMs: 100,
|
||||
inputAtMs: 101,
|
||||
firstEchoAtMs: 103
|
||||
}
|
||||
}
|
||||
|
||||
function createMainProbeEvents(): SplitLatencyMainProbeEvent[] {
|
||||
return [
|
||||
{
|
||||
kind: 'cwd-request',
|
||||
operationId: 99,
|
||||
atEpochMs: 1_010,
|
||||
ptyId: 'pty-other',
|
||||
writeChannel: null
|
||||
},
|
||||
{
|
||||
kind: 'cwd-request',
|
||||
operationId: 1,
|
||||
atEpochMs: 1_011,
|
||||
ptyId: 'pty-source',
|
||||
writeChannel: null
|
||||
},
|
||||
{
|
||||
kind: 'cwd-settled',
|
||||
operationId: 1,
|
||||
atEpochMs: 1_060,
|
||||
ptyId: 'pty-source',
|
||||
writeChannel: null
|
||||
},
|
||||
{
|
||||
kind: 'pty-spawn-request',
|
||||
operationId: 2,
|
||||
atEpochMs: 1_061,
|
||||
ptyId: null,
|
||||
writeChannel: null
|
||||
},
|
||||
{
|
||||
kind: 'pty-spawn-result',
|
||||
operationId: 3,
|
||||
atEpochMs: 1_070,
|
||||
ptyId: 'pty-other',
|
||||
writeChannel: null
|
||||
},
|
||||
{
|
||||
kind: 'pty-spawn-result',
|
||||
operationId: 2,
|
||||
atEpochMs: 1_080,
|
||||
ptyId: 'pty-child',
|
||||
writeChannel: null
|
||||
},
|
||||
{
|
||||
kind: 'pty-write-cr',
|
||||
operationId: null,
|
||||
atEpochMs: 1_081,
|
||||
ptyId: 'pty-other',
|
||||
writeChannel: 'pty:write'
|
||||
},
|
||||
{
|
||||
kind: 'pty-write-cr',
|
||||
operationId: null,
|
||||
atEpochMs: 1_084,
|
||||
ptyId: 'pty-child',
|
||||
writeChannel: 'pty:writeAccepted'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('terminal split activation latency report', () => {
|
||||
it('attributes main-process phases to the matching source and child PTYs', () => {
|
||||
const stamps = mergeSplitLatencyMainProbeEvents(createRendererStamps(), createMainProbeEvents())
|
||||
const sample = createSplitLatencySample({
|
||||
phase: 'measured',
|
||||
iteration: 0,
|
||||
stamps,
|
||||
completedWithinTimeout: true,
|
||||
paneCountAfterProbe: 2,
|
||||
ptyExitObserved: true,
|
||||
cleanupError: null
|
||||
})
|
||||
|
||||
expect(sample).toMatchObject({
|
||||
cwdRequestAtMs: 11,
|
||||
cwdSettledAtMs: 60,
|
||||
ptySpawnRequestAtMs: 61,
|
||||
ptySpawnResultAtMs: 80,
|
||||
fixtureUnlockIpcWriteAtMs: 84,
|
||||
fixtureUnlockIpcWriteChannel: 'pty:writeAccepted',
|
||||
shortcutToCwdRequestMs: 1,
|
||||
cwdLookupMs: 49,
|
||||
cwdSettleToPtySpawnRequestMs: 1,
|
||||
ptySpawnRequestToResultMs: 19,
|
||||
ptySpawnResultToBindMs: 2,
|
||||
fixtureUnlockRequestToIpcWriteMs: 1,
|
||||
fixtureUnlockIpcWriteToReadyParseMs: 16,
|
||||
success: true,
|
||||
missing: []
|
||||
})
|
||||
})
|
||||
|
||||
it('embeds revision identity and summarizes the attributed phases', () => {
|
||||
const stamps = mergeSplitLatencyMainProbeEvents(createRendererStamps(), createMainProbeEvents())
|
||||
const sample = createSplitLatencySample({
|
||||
phase: 'measured',
|
||||
iteration: 0,
|
||||
stamps,
|
||||
completedWithinTimeout: true,
|
||||
paneCountAfterProbe: 2,
|
||||
ptyExitObserved: true,
|
||||
cleanupError: null
|
||||
})
|
||||
const revision = { headSha: 'a'.repeat(40), dirty: false }
|
||||
const result = buildBenchmarkReport({
|
||||
label: 'candidate',
|
||||
revision,
|
||||
headfulRun: true,
|
||||
windowState: { browserWindowVisible: true, windowCount: 1 },
|
||||
documentVisibility: 'visible',
|
||||
testRepoPath: '/tmp/repo',
|
||||
warmupSamples: [{ ...sample, phase: 'warmup' }],
|
||||
measuredSamples: [sample],
|
||||
abortError: null,
|
||||
config: {
|
||||
warmupCycles: 1,
|
||||
measuredCycles: 1,
|
||||
maxMeasuredCycles: 200,
|
||||
testTimeoutMs: 30_000,
|
||||
splitChord: 'Meta+d',
|
||||
closeChord: 'Meta+w',
|
||||
sampleTimeoutMs: 15_000,
|
||||
cleanupTimeoutMs: 15_000,
|
||||
processCwdCacheExpiryWaitMs: 1_650
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.report).toMatchObject({
|
||||
schemaVersion: 2,
|
||||
revision,
|
||||
status: 'passed',
|
||||
valid: true
|
||||
})
|
||||
expect(result.measuredSummary.distributions.cwdLookupMs.p50).toBe(49)
|
||||
expect(result.measuredSummary.distributions.ptySpawnRequestToResultMs.p50).toBe(19)
|
||||
expect(result.measuredSummary.distributions.fixtureUnlockIpcWriteToReadyParseMs.p50).toBe(16)
|
||||
})
|
||||
|
||||
it('invalidates a sample when the actual fixture-unlock IPC write is missing', () => {
|
||||
const stamps = mergeSplitLatencyMainProbeEvents(
|
||||
createRendererStamps(),
|
||||
createMainProbeEvents().filter((event) => event.kind !== 'pty-write-cr')
|
||||
)
|
||||
const sample = createSplitLatencySample({
|
||||
phase: 'measured',
|
||||
iteration: 0,
|
||||
stamps,
|
||||
completedWithinTimeout: true,
|
||||
paneCountAfterProbe: 2,
|
||||
ptyExitObserved: true,
|
||||
cleanupError: null
|
||||
})
|
||||
|
||||
expect(sample.success).toBe(false)
|
||||
expect(sample.missing).toContain('fixture-unlock-ipc-write')
|
||||
expect(summarizeSamples([sample], 1).counts.missingEvents.fixtureUnlockIpcWrite).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,730 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
countVisibleTerminalPanes,
|
||||
focusActiveTerminalInput,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForPaneCount,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
buildBenchmarkReport,
|
||||
type BenchmarkRevisionIdentity,
|
||||
type BrowserWindowState,
|
||||
type TerminalSplitLatencyReportConfig
|
||||
} from './terminal-split-activation-latency-report'
|
||||
import {
|
||||
sanitizeTerminalSplitLatencyReport,
|
||||
writeTerminalSplitLatencyArtifact
|
||||
} from './terminal-split-activation-latency-artifact'
|
||||
import {
|
||||
disposeSplitLatencyMainProbe,
|
||||
installSplitLatencyMainProbe,
|
||||
readSplitLatencyMainProbe,
|
||||
resetSplitLatencyMainProbe
|
||||
} from './terminal-split-activation-latency-main-probe'
|
||||
import {
|
||||
createSplitLatencySample,
|
||||
mergeSplitLatencyMainProbeEvents,
|
||||
type RendererPhaseStamps,
|
||||
type SplitLatencySample
|
||||
} from './terminal-split-activation-latency-phases'
|
||||
|
||||
const BENCH_ENABLED = process.env.ORCA_TERMINAL_SPLIT_LATENCY_BENCH === '1'
|
||||
const BENCH_LABEL = process.env.ORCA_TERMINAL_SPLIT_LATENCY_LABEL?.trim() || 'local'
|
||||
const BENCH_OUTPUT_PATH = process.env.ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT?.trim() || null
|
||||
const WARMUP_CYCLES = 3
|
||||
const MIN_MEASURED_CYCLES = 20
|
||||
const MAX_MEASURED_CYCLES = 200
|
||||
const SAMPLE_TIMEOUT_MS = 15_000
|
||||
const CLEANUP_TIMEOUT_MS = 15_000
|
||||
const CONFIRM_CLICK_TIMEOUT_MS = 2_000
|
||||
const BENCH_SETUP_TIMEOUT_MS = 5 * 60 * 1000
|
||||
// Why: process-cwd caches each pid for 1500ms; this wait isolates cold lookups, not correctness.
|
||||
const PROCESS_CWD_CACHE_EXPIRY_WAIT_MS = 1_650
|
||||
const SOURCE_READY_MARKER = 'ORCA_SPLIT_LATENCY_SOURCE_READY'
|
||||
const IS_MAC = process.platform === 'darwin'
|
||||
const SPLIT_CHORD = IS_MAC ? 'Meta+d' : 'Control+Shift+d'
|
||||
const CLOSE_CHORD = IS_MAC ? 'Meta+w' : 'Control+w'
|
||||
|
||||
function readPositiveInt(name: string, fallback: number): number {
|
||||
const value = Number(process.env[name])
|
||||
return Number.isInteger(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
const MEASURED_CYCLES = Math.min(
|
||||
MAX_MEASURED_CYCLES,
|
||||
Math.max(
|
||||
MIN_MEASURED_CYCLES,
|
||||
readPositiveInt('ORCA_TERMINAL_SPLIT_LATENCY_CYCLES', MIN_MEASURED_CYCLES)
|
||||
)
|
||||
)
|
||||
const BENCH_TIMEOUT_MS =
|
||||
BENCH_SETUP_TIMEOUT_MS +
|
||||
WARMUP_CYCLES * (SAMPLE_TIMEOUT_MS + 4 * CLEANUP_TIMEOUT_MS + CONFIRM_CLICK_TIMEOUT_MS) +
|
||||
MEASURED_CYCLES *
|
||||
(SAMPLE_TIMEOUT_MS +
|
||||
4 * CLEANUP_TIMEOUT_MS +
|
||||
CONFIRM_CLICK_TIMEOUT_MS +
|
||||
PROCESS_CWD_CACHE_EXPIRY_WAIT_MS)
|
||||
const REPORT_CONFIG = {
|
||||
warmupCycles: WARMUP_CYCLES,
|
||||
measuredCycles: MEASURED_CYCLES,
|
||||
maxMeasuredCycles: MAX_MEASURED_CYCLES,
|
||||
testTimeoutMs: BENCH_TIMEOUT_MS,
|
||||
splitChord: SPLIT_CHORD,
|
||||
closeChord: CLOSE_CHORD,
|
||||
sampleTimeoutMs: SAMPLE_TIMEOUT_MS,
|
||||
cleanupTimeoutMs: CLEANUP_TIMEOUT_MS,
|
||||
processCwdCacheExpiryWaitMs: PROCESS_CWD_CACHE_EXPIRY_WAIT_MS
|
||||
} satisfies TerminalSplitLatencyReportConfig
|
||||
|
||||
type RendererProbe = {
|
||||
report: () => RendererPhaseStamps
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
type SplitLatencyProbeWindow = Window & {
|
||||
__terminalSplitLatencyProbe?: RendererProbe
|
||||
__terminalSplitLatencyPtyExitIds?: string[]
|
||||
__terminalSplitLatencyPtyExitDispose?: () => void
|
||||
}
|
||||
|
||||
function readBenchmarkRevisionIdentity(): BenchmarkRevisionIdentity {
|
||||
const headSha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
if (!/^[0-9a-f]{40}$/.test(headSha)) {
|
||||
throw new Error(`Unable to resolve exact benchmark revision: ${headSha || 'empty output'}`)
|
||||
}
|
||||
const dirty =
|
||||
execFileSync('git', ['status', '--porcelain'], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8'
|
||||
}).trim().length > 0
|
||||
return { headSha, dirty }
|
||||
}
|
||||
|
||||
function createEchoShellFixture(): { root: string; shellPath: string } {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-split-latency-'))
|
||||
const shellPath = path.join(root, 'split-echo-shell')
|
||||
writeFileSync(
|
||||
shellPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'stty raw -echo',
|
||||
'dd bs=1 count=1 of=/dev/null 2>/dev/null',
|
||||
`printf '%s' '${SOURCE_READY_MARKER}'`,
|
||||
'exec /bin/cat',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(shellPath, 0o755)
|
||||
return { root, shellPath }
|
||||
}
|
||||
|
||||
async function createSourceTab(
|
||||
page: Page,
|
||||
shellOverride: string
|
||||
): Promise<{ tabId: string; ptyId: string }> {
|
||||
const tabId = await page.evaluate((shellOverride) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
const worktreeId = state.activeWorktreeId
|
||||
if (!worktreeId) {
|
||||
throw new Error('No active worktree')
|
||||
}
|
||||
const tab = state.createTab(worktreeId, undefined, shellOverride, { activate: true })
|
||||
store.getState().setActiveTab(tab.id)
|
||||
store.getState().setActiveTabType('terminal')
|
||||
return tab.id
|
||||
}, shellOverride)
|
||||
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
await waitForPaneCount(page, 1, 30_000)
|
||||
const ptyId = await waitForActivePanePtyId(page, 30_000)
|
||||
await sendToTerminal(page, ptyId, '\r')
|
||||
await waitForTerminalOutput(page, SOURCE_READY_MARKER, 30_000)
|
||||
return { tabId, ptyId }
|
||||
}
|
||||
|
||||
async function readActivePaneId(page: Page, tabId: string): Promise<number> {
|
||||
const paneId = await page.evaluate((tabId) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
return manager?.getActivePane?.()?.id ?? null
|
||||
}, tabId)
|
||||
if (paneId === null) {
|
||||
throw new Error(`No active pane for source tab ${tabId}`)
|
||||
}
|
||||
return paneId
|
||||
}
|
||||
|
||||
async function installRendererProbe(
|
||||
page: Page,
|
||||
args: {
|
||||
tabId: string
|
||||
sourcePaneId: number
|
||||
sourcePtyId: string
|
||||
marker: string
|
||||
readyMarker: string
|
||||
isMac: boolean
|
||||
}
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ tabId, sourcePaneId, sourcePtyId, marker, readyMarker, isMac }) => {
|
||||
const targetWindow = window as SplitLatencyProbeWindow
|
||||
targetWindow.__terminalSplitLatencyProbe?.dispose()
|
||||
|
||||
const stamps: RendererPhaseStamps = {
|
||||
marker,
|
||||
sourcePaneId,
|
||||
sourcePtyId,
|
||||
newPaneId: null,
|
||||
newPtyId: null,
|
||||
rendererTimeOriginEpochMs: performance.timeOrigin,
|
||||
keydownAtMs: null,
|
||||
focusAtMs: null,
|
||||
cwdRequestAtMs: null,
|
||||
cwdSettledAtMs: null,
|
||||
ptySpawnRequestAtMs: null,
|
||||
ptySpawnResultAtMs: null,
|
||||
ptyBoundAtMs: null,
|
||||
fixtureUnlockRequestedAtMs: null,
|
||||
fixtureUnlockIpcWriteAtMs: null,
|
||||
fixtureUnlockIpcWriteChannel: null,
|
||||
fixtureReadyParsedAtMs: null,
|
||||
inputAtMs: null,
|
||||
firstEchoAtMs: null
|
||||
}
|
||||
let ptyBindingObserver: MutationObserver | null = null
|
||||
let parsedDisposable: { dispose: () => void } | null = null
|
||||
let fixtureReady = false
|
||||
let markerFeedQueued = false
|
||||
const originalStopImmediatePropagation = Event.prototype.stopImmediatePropagation
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
const matches = isMac
|
||||
? event.code === 'KeyD' && event.metaKey && !event.shiftKey && !event.altKey
|
||||
: event.code === 'KeyD' && event.ctrlKey && event.shiftKey && !event.altKey
|
||||
if (matches && stamps.keydownAtMs === null) {
|
||||
stamps.keydownAtMs = performance.now()
|
||||
}
|
||||
}
|
||||
const patchedStopImmediatePropagation = function (this: Event): void {
|
||||
// Why: terminal shortcuts stop same-target listeners before split work starts.
|
||||
if (this instanceof KeyboardEvent) {
|
||||
onKeyDown(this)
|
||||
}
|
||||
originalStopImmediatePropagation.call(this)
|
||||
}
|
||||
|
||||
const onFocusIn = (event: FocusEvent): void => {
|
||||
if (stamps.keydownAtMs === null || stamps.focusAtMs !== null) {
|
||||
return
|
||||
}
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement) || !target.matches('.xterm-helper-textarea')) {
|
||||
return
|
||||
}
|
||||
const paneElement = target.closest<HTMLElement>('.pane[data-pane-id]')
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
const pane = manager?.getPanes?.().find((candidate) => candidate.container === paneElement)
|
||||
if (!pane || pane.id === sourcePaneId) {
|
||||
return
|
||||
}
|
||||
|
||||
stamps.newPaneId = pane.id
|
||||
stamps.focusAtMs = performance.now()
|
||||
const maybeFeedMarker = (): void => {
|
||||
if (!fixtureReady || stamps.ptyBoundAtMs === null || markerFeedQueued) {
|
||||
return
|
||||
}
|
||||
markerFeedQueued = true
|
||||
queueMicrotask(() => {
|
||||
stamps.inputAtMs = performance.now()
|
||||
pane.terminal.input(marker, true)
|
||||
})
|
||||
}
|
||||
const observeParsedOutput = (): void => {
|
||||
const buffer = pane.terminal.buffer.active
|
||||
let text = ''
|
||||
for (let row = 0; row < buffer.length; row += 1) {
|
||||
text += buffer.getLine(row)?.translateToString(true) ?? ''
|
||||
}
|
||||
if (!fixtureReady && text.includes(readyMarker)) {
|
||||
fixtureReady = true
|
||||
stamps.fixtureReadyParsedAtMs = performance.now()
|
||||
maybeFeedMarker()
|
||||
}
|
||||
if (stamps.firstEchoAtMs === null && text.includes(marker)) {
|
||||
stamps.firstEchoAtMs = performance.now()
|
||||
}
|
||||
}
|
||||
parsedDisposable = pane.terminal.onWriteParsed(observeParsedOutput)
|
||||
observeParsedOutput()
|
||||
|
||||
const observePtyBinding = (): void => {
|
||||
const ptyId = pane.container.dataset.ptyId
|
||||
if (!ptyId || stamps.ptyBoundAtMs !== null) {
|
||||
return
|
||||
}
|
||||
stamps.newPtyId = ptyId
|
||||
stamps.ptyBoundAtMs = performance.now()
|
||||
ptyBindingObserver?.disconnect()
|
||||
queueMicrotask(() => {
|
||||
stamps.fixtureUnlockRequestedAtMs = performance.now()
|
||||
pane.terminal.input('\r', true)
|
||||
})
|
||||
}
|
||||
|
||||
if (pane.container.dataset.ptyId) {
|
||||
observePtyBinding()
|
||||
return
|
||||
}
|
||||
ptyBindingObserver = new MutationObserver(observePtyBinding)
|
||||
ptyBindingObserver.observe(pane.container, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-pty-id']
|
||||
})
|
||||
}
|
||||
|
||||
Event.prototype.stopImmediatePropagation = patchedStopImmediatePropagation
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
document.addEventListener('focusin', onFocusIn, { capture: true })
|
||||
targetWindow.__terminalSplitLatencyProbe = {
|
||||
report: () => ({ ...stamps }),
|
||||
dispose: () => {
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
document.removeEventListener('focusin', onFocusIn, { capture: true })
|
||||
if (Event.prototype.stopImmediatePropagation === patchedStopImmediatePropagation) {
|
||||
Event.prototype.stopImmediatePropagation = originalStopImmediatePropagation
|
||||
}
|
||||
ptyBindingObserver?.disconnect()
|
||||
parsedDisposable?.dispose()
|
||||
}
|
||||
}
|
||||
}, args)
|
||||
}
|
||||
|
||||
async function waitForRendererProbe(page: Page): Promise<boolean> {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(window as SplitLatencyProbeWindow).__terminalSplitLatencyProbe?.report().firstEchoAtMs !==
|
||||
null,
|
||||
null,
|
||||
{ timeout: SAMPLE_TIMEOUT_MS }
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function collectRendererProbe(page: Page): Promise<RendererPhaseStamps> {
|
||||
return page.evaluate(() => {
|
||||
const targetWindow = window as SplitLatencyProbeWindow
|
||||
const probe = targetWindow.__terminalSplitLatencyProbe
|
||||
if (!probe) {
|
||||
throw new Error('Terminal split latency probe was not installed')
|
||||
}
|
||||
const report = probe.report()
|
||||
probe.dispose()
|
||||
delete targetWindow.__terminalSplitLatencyProbe
|
||||
return report
|
||||
})
|
||||
}
|
||||
|
||||
async function closeSplitsAndRefocusSource(
|
||||
page: Page,
|
||||
tabId: string,
|
||||
sourcePaneId: number,
|
||||
closedPtyIds: string[]
|
||||
): Promise<{ closeCompletedAt: number; ptyExitObserved: boolean; cleanupError: string | null }> {
|
||||
let paneCount = await countVisibleTerminalPanes(page)
|
||||
if (paneCount < 1) {
|
||||
throw new Error('Source terminal disappeared during split benchmark')
|
||||
}
|
||||
while (paneCount > 1) {
|
||||
const expectedCount = paneCount - 1
|
||||
await focusActiveTerminalInput(page)
|
||||
await page.keyboard.press(CLOSE_CHORD)
|
||||
const confirmButton = page
|
||||
.locator(
|
||||
'[data-slot="dialog-content"][data-state="open"] [data-slot="dialog-footer"] [data-slot="button"][data-variant="destructive"]'
|
||||
)
|
||||
.last()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await confirmButton.isVisible().catch(() => false)) {
|
||||
await confirmButton.click({ timeout: CONFIRM_CLICK_TIMEOUT_MS })
|
||||
}
|
||||
return countVisibleTerminalPanes(page)
|
||||
},
|
||||
{
|
||||
timeout: CLEANUP_TIMEOUT_MS,
|
||||
message: `Split pane did not close to ${expectedCount} pane(s)`
|
||||
}
|
||||
)
|
||||
.toBe(expectedCount)
|
||||
paneCount = expectedCount
|
||||
}
|
||||
await waitForPaneCount(page, 1, CLEANUP_TIMEOUT_MS)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
({ tabId, sourcePaneId }) =>
|
||||
window.__paneManagers?.get(tabId)?.getActivePane?.()?.id === sourcePaneId,
|
||||
{ tabId, sourcePaneId }
|
||||
),
|
||||
{
|
||||
timeout: CLEANUP_TIMEOUT_MS,
|
||||
message: 'Source pane did not regain active ownership after close'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
const ptyExitResults = await Promise.all(
|
||||
closedPtyIds.map(async (ptyId) => ({ ptyId, observed: await waitForPtyExit(page, ptyId) }))
|
||||
)
|
||||
const missingPtyExitIds = ptyExitResults
|
||||
.filter((result) => !result.observed)
|
||||
.map((result) => result.ptyId)
|
||||
await focusActiveTerminalInput(page)
|
||||
const cleanupError =
|
||||
closedPtyIds.length === 0
|
||||
? 'Split cleanup could not identify a child PTY to verify its exit'
|
||||
: missingPtyExitIds.length > 0
|
||||
? `Closed split PTY did not emit exit: ${missingPtyExitIds.join(', ')}`
|
||||
: null
|
||||
return {
|
||||
closeCompletedAt: Date.now(),
|
||||
ptyExitObserved: cleanupError === null,
|
||||
cleanupError
|
||||
}
|
||||
}
|
||||
|
||||
async function readChildPtyIds(page: Page, tabId: string, sourcePtyId: string): Promise<string[]> {
|
||||
return page.evaluate(
|
||||
({ tabId, sourcePtyId }) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
return (manager?.getPanes?.() ?? [])
|
||||
.map((pane) => pane.container.dataset.ptyId ?? null)
|
||||
.filter((ptyId): ptyId is string => Boolean(ptyId) && ptyId !== sourcePtyId)
|
||||
},
|
||||
{ tabId, sourcePtyId }
|
||||
)
|
||||
}
|
||||
|
||||
async function runSplitCycle(
|
||||
electronApp: ElectronApplication,
|
||||
page: Page,
|
||||
args: {
|
||||
tabId: string
|
||||
sourcePaneId: number
|
||||
sourcePtyId: string
|
||||
phase: SplitLatencySample['phase']
|
||||
iteration: number
|
||||
}
|
||||
): Promise<{ sample: SplitLatencySample; closeCompletedAt: number; fatalError: Error | null }> {
|
||||
const marker = `ORCA_SPLIT_ECHO_${args.phase}_${args.iteration}_${randomUUID().replaceAll('-', '')}`
|
||||
await focusActiveTerminalInput(page)
|
||||
// Prevent an ID reused by a later PTY lifetime from matching an earlier exit.
|
||||
await resetPtyExitProbe(page)
|
||||
await resetSplitLatencyMainProbe(electronApp)
|
||||
await installRendererProbe(page, {
|
||||
tabId: args.tabId,
|
||||
sourcePaneId: args.sourcePaneId,
|
||||
sourcePtyId: args.sourcePtyId,
|
||||
marker,
|
||||
readyMarker: SOURCE_READY_MARKER,
|
||||
isMac: IS_MAC
|
||||
})
|
||||
await page.keyboard.press(SPLIT_CHORD)
|
||||
const completedWithinTimeout = await waitForRendererProbe(page)
|
||||
const rendererStamps = await collectRendererProbe(page)
|
||||
const stamps = mergeSplitLatencyMainProbeEvents(
|
||||
rendererStamps,
|
||||
await readSplitLatencyMainProbe(electronApp)
|
||||
)
|
||||
let paneCountAfterProbe = -1
|
||||
let closeCompletedAt = Date.now()
|
||||
let ptyExitObserved = false
|
||||
let cleanupError: Error | null = null
|
||||
try {
|
||||
paneCountAfterProbe = await countVisibleTerminalPanes(page)
|
||||
const childPtyIds = await readChildPtyIds(page, args.tabId, args.sourcePtyId)
|
||||
const closeResult = await closeSplitsAndRefocusSource(
|
||||
page,
|
||||
args.tabId,
|
||||
args.sourcePaneId,
|
||||
childPtyIds
|
||||
)
|
||||
closeCompletedAt = closeResult.closeCompletedAt
|
||||
ptyExitObserved = closeResult.ptyExitObserved
|
||||
cleanupError = closeResult.cleanupError ? new Error(closeResult.cleanupError) : null
|
||||
} catch (error) {
|
||||
cleanupError = error instanceof Error ? error : new Error(String(error))
|
||||
closeCompletedAt = Date.now()
|
||||
}
|
||||
const sample = createSplitLatencySample({
|
||||
phase: args.phase,
|
||||
iteration: args.iteration,
|
||||
stamps,
|
||||
completedWithinTimeout,
|
||||
paneCountAfterProbe,
|
||||
ptyExitObserved,
|
||||
cleanupError: cleanupError?.message ?? null
|
||||
})
|
||||
return { sample, closeCompletedAt, fatalError: cleanupError }
|
||||
}
|
||||
|
||||
async function waitForColdProcessCwdLookup(
|
||||
page: Page,
|
||||
priorCloseCompletedAt: number
|
||||
): Promise<void> {
|
||||
const remaining = PROCESS_CWD_CACHE_EXPIRY_WAIT_MS - (Date.now() - priorCloseCompletedAt)
|
||||
if (remaining > 0) {
|
||||
await page.waitForTimeout(remaining)
|
||||
}
|
||||
expect(Date.now() - priorCloseCompletedAt).toBeGreaterThanOrEqual(
|
||||
PROCESS_CWD_CACHE_EXPIRY_WAIT_MS
|
||||
)
|
||||
}
|
||||
|
||||
function logSample(sample: SplitLatencySample): void {
|
||||
console.log(
|
||||
`[terminal-split-activation-latency] ${sample.phase} ${sample.iteration + 1} ` +
|
||||
`success=${sample.success} missing=${sample.missing.join(',') || 'none'}`
|
||||
)
|
||||
}
|
||||
|
||||
async function installPtyExitProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const targetWindow = window as SplitLatencyProbeWindow
|
||||
targetWindow.__terminalSplitLatencyPtyExitDispose?.()
|
||||
targetWindow.__terminalSplitLatencyPtyExitIds = []
|
||||
targetWindow.__terminalSplitLatencyPtyExitDispose = window.api.pty.onExit(({ id }) => {
|
||||
const ids = targetWindow.__terminalSplitLatencyPtyExitIds ?? []
|
||||
if (!ids.includes(id)) {
|
||||
ids.push(id)
|
||||
}
|
||||
targetWindow.__terminalSplitLatencyPtyExitIds = ids
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function resetPtyExitProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
;(window as SplitLatencyProbeWindow).__terminalSplitLatencyPtyExitIds = []
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForPtyExit(page: Page, ptyId: string): Promise<boolean> {
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((expectedPtyId) => {
|
||||
const targetWindow = window as SplitLatencyProbeWindow
|
||||
return targetWindow.__terminalSplitLatencyPtyExitIds?.includes(expectedPtyId) ?? false
|
||||
}, ptyId),
|
||||
{ timeout: CLEANUP_TIMEOUT_MS, message: `Closed split PTY did not emit exit: ${ptyId}` }
|
||||
)
|
||||
.toBe(true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function disposePtyExitProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const targetWindow = window as SplitLatencyProbeWindow
|
||||
targetWindow.__terminalSplitLatencyPtyExitDispose?.()
|
||||
delete targetWindow.__terminalSplitLatencyPtyExitDispose
|
||||
delete targetWindow.__terminalSplitLatencyPtyExitIds
|
||||
})
|
||||
}
|
||||
|
||||
async function attachReport(testInfo: TestInfo, report: Record<string, unknown>): Promise<void> {
|
||||
const body = `${JSON.stringify(sanitizeTerminalSplitLatencyReport(report), null, 2)}\n`
|
||||
await testInfo.attach('terminal-split-activation-latency.json', {
|
||||
body,
|
||||
contentType: 'application/json'
|
||||
})
|
||||
if (BENCH_OUTPUT_PATH) {
|
||||
writeTerminalSplitLatencyArtifact(BENCH_OUTPUT_PATH, body)
|
||||
}
|
||||
console.log(
|
||||
`[terminal-split-activation-latency] ${JSON.stringify(sanitizeTerminalSplitLatencyReport(report))}`
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Terminal split activation latency benchmark @headful', () => {
|
||||
test.skip(!BENCH_ENABLED, 'One-off benchmark: set ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1')
|
||||
test.skip(process.platform === 'win32', 'Deterministic echo-shell fixture is POSIX-only')
|
||||
test.setTimeout(BENCH_TIMEOUT_MS)
|
||||
|
||||
test('records attributed CWD, spawn, bind, fixture-ready, input, and echo phases', async ({
|
||||
electronApp,
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
const headfulRun =
|
||||
process.env.ORCA_E2E_FORCE_HEADFUL === '1' || testInfo.project.metadata.orcaHeadful === true
|
||||
const windowState: BrowserWindowState = {
|
||||
browserWindowVisible: false,
|
||||
windowCount: 0
|
||||
}
|
||||
let documentVisibility = 'unavailable'
|
||||
let fixture: { root: string; shellPath: string } | null = null
|
||||
const warmupSamples: SplitLatencySample[] = []
|
||||
const measuredSamples: SplitLatencySample[] = []
|
||||
let revision: BenchmarkRevisionIdentity = { headSha: 'unavailable', dirty: true }
|
||||
let abortError: Error | null = null
|
||||
let reportAttached = false
|
||||
try {
|
||||
revision = readBenchmarkRevisionIdentity()
|
||||
expect(headfulRun, 'The latency benchmark must run with a visible BrowserWindow').toBe(true)
|
||||
const observedWindowState = await electronApp.evaluate(({ BrowserWindow }) => ({
|
||||
browserWindowVisible: BrowserWindow.getAllWindows()[0]?.isVisible() ?? false,
|
||||
windowCount: BrowserWindow.getAllWindows().length
|
||||
}))
|
||||
windowState.browserWindowVisible = observedWindowState.browserWindowVisible
|
||||
windowState.windowCount = observedWindowState.windowCount
|
||||
expect(windowState.windowCount).toBeGreaterThan(0)
|
||||
expect(windowState.browserWindowVisible).toBe(true)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
documentVisibility = await orcaPage.evaluate(() => document.visibilityState)
|
||||
return documentVisibility
|
||||
},
|
||||
{
|
||||
timeout: 15_000,
|
||||
message: 'Visible latency benchmark renderer remained hidden'
|
||||
}
|
||||
)
|
||||
.toBe('visible')
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
|
||||
fixture = createEchoShellFixture()
|
||||
const source = await createSourceTab(orcaPage, fixture.shellPath)
|
||||
const { tabId, ptyId: sourcePtyId } = source
|
||||
const sourcePaneId = await readActivePaneId(orcaPage, tabId)
|
||||
await installPtyExitProbe(orcaPage)
|
||||
await installSplitLatencyMainProbe(electronApp)
|
||||
let priorCloseCompletedAt = Date.now()
|
||||
|
||||
for (let iteration = 0; iteration < WARMUP_CYCLES; iteration += 1) {
|
||||
const result = await runSplitCycle(electronApp, orcaPage, {
|
||||
tabId,
|
||||
sourcePaneId,
|
||||
sourcePtyId,
|
||||
phase: 'warmup',
|
||||
iteration
|
||||
})
|
||||
warmupSamples.push(result.sample)
|
||||
logSample(result.sample)
|
||||
priorCloseCompletedAt = result.closeCompletedAt
|
||||
if (result.fatalError) {
|
||||
abortError = result.fatalError
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for (let iteration = 0; iteration < MEASURED_CYCLES && abortError === null; iteration += 1) {
|
||||
await waitForColdProcessCwdLookup(orcaPage, priorCloseCompletedAt)
|
||||
const result = await runSplitCycle(electronApp, orcaPage, {
|
||||
tabId,
|
||||
sourcePaneId,
|
||||
sourcePtyId,
|
||||
phase: 'measured',
|
||||
iteration
|
||||
})
|
||||
measuredSamples.push(result.sample)
|
||||
logSample(result.sample)
|
||||
priorCloseCompletedAt = result.closeCompletedAt
|
||||
if (result.fatalError) {
|
||||
abortError = result.fatalError
|
||||
}
|
||||
}
|
||||
|
||||
documentVisibility = await orcaPage
|
||||
.evaluate(() => document.visibilityState)
|
||||
.catch(() => 'unavailable' as const)
|
||||
const reportResult = buildBenchmarkReport({
|
||||
label: BENCH_LABEL,
|
||||
revision,
|
||||
headfulRun,
|
||||
windowState,
|
||||
documentVisibility,
|
||||
testRepoPath,
|
||||
warmupSamples,
|
||||
measuredSamples,
|
||||
abortError,
|
||||
config: REPORT_CONFIG
|
||||
})
|
||||
await attachReport(testInfo, reportResult.report)
|
||||
reportAttached = true
|
||||
testInfo.annotations.push({
|
||||
type: 'terminal-split-activation-latency',
|
||||
description:
|
||||
`success=${reportResult.measuredSummary.counts.success}/${MEASURED_CYCLES} ` +
|
||||
`focusP50=${reportResult.measuredSummary.distributions.shortcutToFocusMs.p50.toFixed(1)}ms ` +
|
||||
`echoP50=${reportResult.measuredSummary.distributions.shortcutToFirstEchoMs.p50.toFixed(1)}ms`
|
||||
})
|
||||
if (abortError) {
|
||||
throw abortError
|
||||
}
|
||||
expect(reportResult.warmupSummary.counts.success).toBe(WARMUP_CYCLES)
|
||||
expect(reportResult.measuredSummary.counts.success).toBe(MEASURED_CYCLES)
|
||||
} catch (error) {
|
||||
const failure = error instanceof Error ? error : new Error(String(error))
|
||||
if (!reportAttached) {
|
||||
abortError ??= failure
|
||||
const failureReport = buildBenchmarkReport({
|
||||
label: BENCH_LABEL,
|
||||
revision,
|
||||
headfulRun,
|
||||
windowState,
|
||||
documentVisibility,
|
||||
testRepoPath,
|
||||
warmupSamples,
|
||||
measuredSamples,
|
||||
abortError,
|
||||
config: REPORT_CONFIG
|
||||
})
|
||||
await attachReport(testInfo, failureReport.report).catch((attachError) => {
|
||||
const message = attachError instanceof Error ? attachError.message : String(attachError)
|
||||
console.error(
|
||||
`[terminal-split-activation-latency] unable to attach failure report: ${message}`
|
||||
)
|
||||
})
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
await disposeSplitLatencyMainProbe(electronApp).catch(() => undefined)
|
||||
await disposePtyExitProbe(orcaPage).catch(() => undefined)
|
||||
if (fixture) {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user