diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 77e1dbcb7f8..397772bfb7e 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -41,6 +41,11 @@ on: required: false default: false type: boolean + version_suffix: + description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only. + required: false + type: string + default: '' permissions: contents: write @@ -192,6 +197,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }} + VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }} run: | set -euo pipefail @@ -366,6 +372,18 @@ jobs: fi new="${base}-rc.$((highest_rc + 1))" fi + if [[ -n "${VERSION_SUFFIX:-}" ]]; then + # Why a dot-appended identifier (rc.N.perf): it sorts just + # above its own base rc.N but BELOW rc.N+1, so suffixed side- + # branch builds never outrank the main RC series and cannot + # hijack the update channel; clients find them by matching the + # identifier ("perf") in the prerelease components. + if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then + echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2 + exit 1 + fi + new="${new}.${VERSION_SUFFIX}" + fi ;; patch|minor|major) new="$(bump "$latest_stable" "$KIND")" @@ -808,7 +826,10 @@ jobs: TAG: ${{ needs.cut.outputs.tag }} run: | set -euo pipefail - if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + # Why the optional trailing identifier: suffixed side-branch RCs + # (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry + # identity as plain RCs. + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then identity=rc elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then identity=stable diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index e82502baa47..3638d40f00c 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -96,7 +96,10 @@ jobs: TAG: ${{ inputs.tag }} run: | set -euo pipefail - if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + # Why the optional trailing identifier: suffixed side-branch RCs + # (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry + # identity as plain RCs. + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then identity=rc elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then identity=stable diff --git a/.gitignore b/.gitignore index 50c46ec9f8d..ed81a687a69 100644 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,6 @@ src/renderer/src/i18n/locales/.zh-catalog-cache.json src/renderer/src/i18n/locales/.ko-catalog-cache.json src/renderer/src/i18n/locales/.ja-catalog-cache.json src/renderer/src/i18n/locales/.es-catalog-cache.json + +# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md +tools/benchmarks/results/terminal-pipeline-*.json diff --git a/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch b/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch new file mode 100644 index 00000000000..eab0cd58452 --- /dev/null +++ b/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch @@ -0,0 +1,55 @@ +diff --git a/lib/addon-serialize.js b/lib/addon-serialize.js +index d669293d06d801ef472d5c2454ce86876e5c321f..96bb66cdc05289b6a7ce5ed64fc833624c885c57 100644 +--- a/lib/addon-serialize.js ++++ b/lib/addon-serialize.js +@@ -1,2 +1,2 @@ +-!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=f;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function f(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,f=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,f=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(f(i,n)>8));if(of(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=f(i,u.relativeLuminance(o>>8));if(af(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t,e,r){return Math.max(e,Math.min(t,r))}class n{constructor(t){this._buffer=t}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,o=t.end.y,l=t.start.x,a=t.end.x;this._beforeSerialize(o-n,n,o);for(let e=n;e<=o;e++){const n=this._buffer.getLine(e);if(n){const o=e===t.start.y?l:0,u=e===t.end.y?a:n.length;for(let t=o;t0&&!l(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=e.getCell(e.length-1,this._thisRowLastChar),n=e.getCell(e.length-2,this._thisRowLastSecondChar),o=s.getCell(0,this._nextRowFirstChar),a=o.getWidth()>1;let u=!1;(o.getChars()&&a?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||0===i.getWidth())&&l(i,o)&&(u=!0),a&&(n.getChars()||0===n.getWidth())&&l(i,o)&&l(n,o)&&(u=!0)),u||(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0&&(r+="",r+=`[${e.length-this._nullCellCount}C`,r+=`[${this._nullCellCount}X`,r+=`[${e.length-this._nullCellCount}D`,r+=""),this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0)}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(h(t,e))return r;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){if(t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27),t.isBold()!==e.isBold()&&r.push(t.isBold()?1:22),a(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isDim()!==e.isDim()&&r.push(t.isDim()?2:22),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,this._cursorStyle);if(i?!l(this._cursorStyle,t):n.length>0){this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${n.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}i?this._nullCellCount+=t.getWidth():(this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._currentRow+=t.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth())}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(e=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let t=0;t{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,n=void 0===r?s:i(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-n,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new c(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new f(r,t,e),n=e.onlySelection??!1,o=e.range;if(o)return s.serialize({start:{x:o.startCol,y:(o.startLine,o.startLine)},end:{x:t.cols,y:(o.endLine,o.endLine)}});if(!n){const n=r.length,o=e.scrollback,l=void 0===o?n:i(o+t.rows,0,n);return s.serialize({start:{x:0,y:n-l},end:{x:t.cols,y:n-1}})}const l=this._terminal?.getSelectionPosition();return void 0!==l?s.serialize({start:{x:l.start.x,y:l.start.y},end:{x:l.end.x,y:l.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class f extends n{constructor(t,r,s){super(t),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(h(t,e))return;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=f})(),s})()); ++!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=f;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function f(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,f=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,f=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(f(i,n)>8));if(of(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=f(i,u.relativeLuminance(o>>8));if(af(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t,e,r){return Math.max(e,Math.min(t,r))}class n{constructor(t){this._buffer=t}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,o=t.end.y,l=t.start.x,a=t.end.x;this._beforeSerialize(o-n,n,o);for(let e=n;e<=o;e++){const n=this._buffer.getLine(e);if(n){const o=e===t.start.y?l:0,u=e===t.end.y?a:n.length;for(let t=o;t0&&!l(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=e.getCell(e.length-1,this._thisRowLastChar),n=e.getCell(e.length-2,this._thisRowLastSecondChar),o=s.getCell(0,this._nextRowFirstChar),a=o.getWidth()>1;let u=!1;(o.getChars()&&a?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||0===i.getWidth())&&l(i,o)&&(u=!0),a&&(n.getChars()||0===n.getWidth())&&l(i,o)&&l(n,o)&&(u=!0)),u||(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0&&(r+="",r+=`[${e.length-this._nullCellCount}C`,r+=`[${this._nullCellCount}X`,r+=`[${e.length-this._nullCellCount}D`,r+=""),this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0)}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(h(t,e))return r;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){if(t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27),/* PATCH(orca): bold(1)/dim(2) share reset 22 - clear before re-set, as one group */((b,d)=>{if(b||d){const c=b&&!t.isBold()||d&&!t.isDim();c&&r.push(22),t.isBold()&&(b||c)&&r.push(1),t.isDim()&&(d||c)&&r.push(2)}})(t.isBold()!==e.isBold(),t.isDim()!==e.isDim()),a(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,this._cursorStyle);if(i?!l(this._cursorStyle,t):n.length>0){this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${n.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}i?this._nullCellCount+=t.getWidth():(this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._currentRow+=t.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth())}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(e=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let t=0;t{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,n=void 0===r?s:i(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-n,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new c(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new f(r,t,e),n=e.onlySelection??!1,o=e.range;if(o)return s.serialize({start:{x:o.startCol,y:(o.startLine,o.startLine)},end:{x:t.cols,y:(o.endLine,o.endLine)}});if(!n){const n=r.length,o=e.scrollback,l=void 0===o?n:i(o+t.rows,0,n);return s.serialize({start:{x:0,y:n-l},end:{x:t.cols,y:n-1}})}const l=this._terminal?.getSelectionPosition();return void 0!==l?s.serialize({start:{x:l.start.x,y:l.start.y},end:{x:l.end.x,y:l.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class f extends n{constructor(t,r,s){super(t),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(h(t,e))return;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"
":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=f})(),s})()); + //# sourceMappingURL=addon-serialize.js.map +\ No newline at end of file +diff --git a/lib/addon-serialize.mjs b/lib/addon-serialize.mjs +index a5c4c4dd05b7efcc23d0e45661ffd4e42da80f21..475e45b3ed228027531deea5530bd03859f4a4e0 100644 +--- a/lib/addon-serialize.mjs ++++ b/lib/addon-serialize.mjs +@@ -15,5 +15,5 @@ + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + var m=0,b=0,_=0,p=0;var g;(t=>{function a(r,l,s,i){return i!==void 0?`#${w(r)}${w(l)}${w(s)}${w(i)}`:`#${w(r)}${w(l)}${w(s)}`}t.toCss=a;function n(r,l,s,i=255){return(r<<24|l<<16|s<<8|i)>>>0}t.toRgba=n;function e(r,l,s,i){return{css:t.toCss(r,l,s,i),rgba:t.toRgba(r,l,s,i)}}t.toColor=e})(g||={});var N;(i=>{function a(o,u){if(p=(u.rgba&255)/255,p===1)return{css:u.css,rgba:u.rgba};let f=u.rgba>>24&255,C=u.rgba>>16&255,c=u.rgba>>8&255,h=o.rgba>>24&255,d=o.rgba>>16&255,I=o.rgba>>8&255;m=h+Math.round((f-h)*p),b=d+Math.round((C-d)*p),_=I+Math.round((c-I)*p);let L=g.toCss(m,b,_),E=g.toRgba(m,b,_);return{css:L,rgba:E}}i.blend=a;function n(o){return(o.rgba&255)===255}i.isOpaque=n;function e(o,u,f){let C=B.ensureContrastRatio(o.rgba,u.rgba,f);if(C)return g.toColor(C>>24&255,C>>16&255,C>>8&255)}i.ensureContrastRatio=e;function t(o){let u=(o.rgba|255)>>>0;return[m,b,_]=B.toChannels(u),{css:g.toCss(m,b,_),rgba:u}}i.opaque=t;function r(o,u){return p=Math.round(u*255),[m,b,_]=B.toChannels(o.rgba),{css:g.toCss(m,b,_,p),rgba:g.toRgba(m,b,_,p)}}i.opacity=r;function l(o,u){return p=o.rgba&255,r(o,p*u/255)}i.multiplyOpacity=l;function s(o){return[o.rgba>>24&255,o.rgba>>16&255,o.rgba>>8&255]}i.toColorRGB=s})(N||={});var x;(t=>{let a,n;try{let r=document.createElement("canvas");r.width=1,r.height=1;let l=r.getContext("2d",{willReadFrequently:!0});l&&(a=l,a.globalCompositeOperation="copy",n=a.createLinearGradient(0,0,1,1))}catch{}function e(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),g.toColor(m,b,_);case 5:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),p=parseInt(r.slice(4,5).repeat(2),16),g.toColor(m,b,_,p);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let l=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(l)return m=parseInt(l[1],10),b=parseInt(l[2],10),_=parseInt(l[3],10),p=Math.round((l[5]===void 0?1:parseFloat(l[5]))*255),g.toColor(m,b,_,p);if(r==="transparent")return{css:"transparent",rgba:0};if(!a||!n)throw new Error("css.toColor: Unsupported css format");if(a.fillStyle=n,a.fillStyle=r,typeof a.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(a.fillRect(0,0,1,1),[m,b,_,p]=a.getImageData(0,0,1,1).data,p!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:g.toRgba(m,b,_,p),css:r}}t.toColor=e})(x||={});var v;(e=>{function a(t){return n(t>>16&255,t>>8&255,t&255)}e.relativeLuminance=a;function n(t,r,l){let s=t/255,i=r/255,o=l/255,u=s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4),f=i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4),C=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4);return u*.2126+f*.7152+C*.0722}e.relativeLuminance2=n})(v||={});var B;(l=>{function a(s,i){if(p=(i&255)/255,p===1)return i;let o=i>>24&255,u=i>>16&255,f=i>>8&255,C=s>>24&255,c=s>>16&255,h=s>>8&255;return m=C+Math.round((o-C)*p),b=c+Math.round((u-c)*p),_=h+Math.round((f-h)*p),g.toRgba(m,b,_)}l.blend=a;function n(s,i,o){let u=v.relativeLuminance(s>>8),f=v.relativeLuminance(i>>8);if(R(u,f)>8));if(I>8));return I>E?d:L}return d}let c=t(s,i,o),h=R(u,v.relativeLuminance(c>>8));if(h>8));return h>I?c:d}return c}}l.ensureContrastRatio=n;function e(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I0||h>0||d>0);)c-=Math.max(0,Math.ceil(c*.1)),h-=Math.max(0,Math.ceil(h*.1)),d-=Math.max(0,Math.ceil(d*.1)),I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));return(c<<24|h<<16|d<<8|255)>>>0}l.reduceLuminance=e;function t(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I>>0}l.increaseLuminance=t;function r(s){return[s>>24&255,s>>16&255,s>>8&255,s&255]}l.toChannels=r})(B||={});function w(a){let n=a.toString(16);return n.length<2?"0"+n:n}function R(a,n){return a{let a=[x.toColor("#2e3436"),x.toColor("#cc0000"),x.toColor("#4e9a06"),x.toColor("#c4a000"),x.toColor("#3465a4"),x.toColor("#75507b"),x.toColor("#06989a"),x.toColor("#d3d7cf"),x.toColor("#555753"),x.toColor("#ef2929"),x.toColor("#8ae234"),x.toColor("#fce94f"),x.toColor("#729fcf"),x.toColor("#ad7fa8"),x.toColor("#34e2e2"),x.toColor("#eeeeec")],n=[0,95,135,175,215,255];for(let e=0;e<216;e++){let t=n[e/36%6|0],r=n[e/6%6|0],l=n[e%6];a.push({css:g.toCss(t,r,l),rgba:g.toRgba(t,r,l)})}for(let e=0;e<24;e++){let t=8+e*10;a.push({css:g.toCss(t,t,t),rgba:g.toRgba(t,t,t)})}return a})());function A(a,n,e){return Math.max(n,Math.min(a,e))}function z(a){switch(a){case"&":return"&";case"<":return"<"}return a}var S=class{constructor(n){this._buffer=n}serialize(n,e){let t=this._buffer.getNullCell(),r=this._buffer.getNullCell(),l=t,s=n.start.y,i=n.end.y,o=n.start.x,u=n.end.x;this._beforeSerialize(i-s,s,i);for(let f=s;f<=i;f++){let C=this._buffer.getLine(f);if(C){let c=f===n.start.y?o:0,h=f===n.end.y?u:C.length;for(let d=c;d0&&!F(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`\x1B[${this._nullCellCount}X`);let r="";if(!t){e-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);let l=this._buffer.getLine(e),s=this._buffer.getLine(e+1);if(!s.isWrapped)r=`\r +-`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{r="";let i=l.getCell(l.length-1,this._thisRowLastChar),o=l.getCell(l.length-2,this._thisRowLastSecondChar),u=s.getCell(0,this._nextRowFirstChar),f=u.getWidth()>1,C=!1;(u.getChars()&&f?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||i.getWidth()===0)&&F(i,u)&&(C=!0),f&&(o.getChars()||o.getWidth()===0)&&F(i,u)&&F(o,u)&&(C=!0)),C||(r="-".repeat(this._nullCellCount+1),r+="\x1B[1D\x1B[1X",this._nullCellCount>0&&(r+="\x1B[A",r+=`\x1B[${l.length-this._nullCellCount}C`,r+=`\x1B[${this._nullCellCount}X`,r+=`\x1B[${l.length-this._nullCellCount}D`,r+="\x1B[B"),this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0)}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(e,t){let r=[];if(U(e,t))return r;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i)if(e.isAttributeDefault())t.isAttributeDefault()||r.push(0);else{if(l){let o=e.getFgColor();e.isFgRGB()?r.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?r.push(38,5,o):r.push(o&8?90+(o&7):30+(o&7)):r.push(39)}if(s){let o=e.getBgColor();e.isBgRGB()?r.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?r.push(48,5,o):r.push(o&8?100+(o&7):40+(o&7)):r.push(49)}if(i){if(e.isInverse()!==t.isInverse()&&r.push(e.isInverse()?7:27),e.isBold()!==t.isBold()&&r.push(e.isBold()?1:22),O(e,t))e.isUnderline()!==t.isUnderline()&&r.push(e.isUnderline()?4:24);else{let o=e.getUnderlineStyle();if(o===0)r.push(24);else if(o===1&&e.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+o),!e.isUnderlineColorDefault()){let u=e.getUnderlineColor();e.isUnderlineColorRGB()?r.push("58:2::"+(u>>>16&255)+":"+(u>>>8&255)+":"+(u&255)):r.push("58:5:"+u)}}e.isOverline()!==t.isOverline()&&r.push(e.isOverline()?53:55),e.isBlink()!==t.isBlink()&&r.push(e.isBlink()?5:25),e.isInvisible()!==t.isInvisible()&&r.push(e.isInvisible()?8:28),e.isItalic()!==t.isItalic()&&r.push(e.isItalic()?3:23),e.isDim()!==t.isDim()&&r.push(e.isDim()?2:22),e.isStrikethrough()!==t.isStrikethrough()&&r.push(e.isStrikethrough()?9:29)}}return r}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,this._cursorStyle);if(i?!F(this._cursorStyle,e):o.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l,this._currentRow+=`\x1B[${o.join(";")}m`;let f=this._buffer.getLine(r);f!==void 0&&(f.getCell(l,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=l)}i?this._nullCellCount+=e.getWidth():(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),this._currentRow+=e.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l+e.getWidth())}_serializeString(e){let t=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(t=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let i=0;i{c>0?r+=`\x1B[${c}C`:c<0&&(r+=`\x1B[${-c}D`)};u&&((c=>{c>0?r+=`\x1B[${c}B`:c<0&&(r+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let l=this._terminal._core._inputHandler._curAttrData,s=this._diffStyle(l,this._cursorStyle);return s.length>0&&(r+=`\x1B[${s.join(";")}m`),r}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,t){let r=e.length,l=t===void 0?r:A(t+n.rows,0,r);return this._serializeBufferByRange(n,e,{start:r-l,end:r-1},!1)}_serializeBufferByRange(n,e,t,r){return new y(e,n).serialize({start:{x:0,y:typeof t.start=="number"?t.start:t.start.line},end:{x:n.cols,y:typeof t.end=="number"?t.end:t.end.line}},r)}_serializeBufferAsHTML(n,e){let t=n.buffer.active,r=new D(t,n,e),l=e.onlySelection??!1,s=e.range;if(s)return r.serialize({start:{x:s.startCol,y:(typeof s.startLine=="number",s.startLine)},end:{x:n.cols,y:(typeof s.endLine=="number",s.endLine)}});if(!l){let o=t.length,u=e.scrollback,f=u===void 0?o:A(u+n.rows,0,o);return r.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?r.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,t=e.scrollTop,r=e.scrollBottom;return t!==0||r!==n.rows-1?`\x1B[${t+1};${r+1}r`:""}_serializeModes(n){let e="",t=n.modes;if(t.applicationCursorKeysMode&&(e+="\x1B[?1h"),t.applicationKeypadMode&&(e+="\x1B[?66h"),t.bracketedPasteMode&&(e+="\x1B[?2004h"),t.insertMode&&(e+="\x1B[4h"),t.originMode&&(e+="\x1B[?6h"),t.reverseWraparoundMode&&(e+="\x1B[?45h"),t.sendFocusMode&&(e+="\x1B[?1004h"),t.wraparoundMode===!1&&(e+="\x1B[?7l"),t.mouseTrackingMode!=="none")switch(t.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return t.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let t=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${t}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},D=class extends S{constructor(e,t,r){super(e);this._terminal=t;this._options=r;this._currentRow="";this._htmlContent="";t._core._themeService?this._ansiColors=t._core._themeService.colors.ansi:this._ansiColors=k}_beforeSerialize(e,t,r){this._htmlContent+="
";let l="#000000",s="#ffffff";(this._options.includeGlobalBackground??!1)&&(l=this._terminal.options.theme?.foreground??"#ffffff",s=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+l+";"),i.push("background-color: "+s+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,t){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,t){let r=t?e.getFgColor():e.getBgColor();if(t?e.isFgRGB():e.isBgRGB())return"#"+[r>>16&255,r>>8&255,r&255].map(s=>s.toString(16).padStart(2,"0")).join("");if(t?e.isFgPalette():e.isBgPalette())return this._ansiColors[r].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let t=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[t>>16&255,t>>8&255,t&255].map(l=>l.toString(16).padStart(2,"0")).join(""):this._ansiColors[t].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,t){let r=[];if(U(e,t))return;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i){let o=this._getHexColor(e,!0);o&&r.push("color: "+o+";");let u=this._getHexColor(e,!1);u&&r.push("background-color: "+u+";"),e.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&r.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&r.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let C=this._getUnderlineColor(e);C&&r.push("text-decoration-color: "+C+";")}return e.isInvisible()&&r.push("visibility: hidden;"),e.isItalic()&&r.push("font-style: italic;"),e.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,t);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=z(e.getChars())}_serializeString(){return this._htmlContent}};export{D as HTMLSerializeHandler,H as SerializeAddon}; ++`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{r="";let i=l.getCell(l.length-1,this._thisRowLastChar),o=l.getCell(l.length-2,this._thisRowLastSecondChar),u=s.getCell(0,this._nextRowFirstChar),f=u.getWidth()>1,C=!1;(u.getChars()&&f?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||i.getWidth()===0)&&F(i,u)&&(C=!0),f&&(o.getChars()||o.getWidth()===0)&&F(i,u)&&F(o,u)&&(C=!0)),C||(r="-".repeat(this._nullCellCount+1),r+="\x1B[1D\x1B[1X",this._nullCellCount>0&&(r+="\x1B[A",r+=`\x1B[${l.length-this._nullCellCount}C`,r+=`\x1B[${this._nullCellCount}X`,r+=`\x1B[${l.length-this._nullCellCount}D`,r+="\x1B[B"),this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0)}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(e,t){let r=[];if(U(e,t))return r;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i)if(e.isAttributeDefault())t.isAttributeDefault()||r.push(0);else{if(l){let o=e.getFgColor();e.isFgRGB()?r.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?r.push(38,5,o):r.push(o&8?90+(o&7):30+(o&7)):r.push(39)}if(s){let o=e.getBgColor();e.isBgRGB()?r.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?r.push(48,5,o):r.push(o&8?100+(o&7):40+(o&7)):r.push(49)}if(i){if(e.isInverse()!==t.isInverse()&&r.push(e.isInverse()?7:27),/* PATCH(orca): bold(1)/dim(2) share reset 22 - clear before re-set, as one group */((b,d)=>{if(b||d){const c=b&&!e.isBold()||d&&!e.isDim();c&&r.push(22),e.isBold()&&(b||c)&&r.push(1),e.isDim()&&(d||c)&&r.push(2)}})(e.isBold()!==t.isBold(),e.isDim()!==t.isDim()),O(e,t))e.isUnderline()!==t.isUnderline()&&r.push(e.isUnderline()?4:24);else{let o=e.getUnderlineStyle();if(o===0)r.push(24);else if(o===1&&e.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+o),!e.isUnderlineColorDefault()){let u=e.getUnderlineColor();e.isUnderlineColorRGB()?r.push("58:2::"+(u>>>16&255)+":"+(u>>>8&255)+":"+(u&255)):r.push("58:5:"+u)}}e.isOverline()!==t.isOverline()&&r.push(e.isOverline()?53:55),e.isBlink()!==t.isBlink()&&r.push(e.isBlink()?5:25),e.isInvisible()!==t.isInvisible()&&r.push(e.isInvisible()?8:28),e.isItalic()!==t.isItalic()&&r.push(e.isItalic()?3:23),e.isStrikethrough()!==t.isStrikethrough()&&r.push(e.isStrikethrough()?9:29)}}return r}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,this._cursorStyle);if(i?!F(this._cursorStyle,e):o.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l,this._currentRow+=`\x1B[${o.join(";")}m`;let f=this._buffer.getLine(r);f!==void 0&&(f.getCell(l,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=l)}i?this._nullCellCount+=e.getWidth():(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),this._currentRow+=e.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l+e.getWidth())}_serializeString(e){let t=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(t=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let i=0;i{c>0?r+=`\x1B[${c}C`:c<0&&(r+=`\x1B[${-c}D`)};u&&((c=>{c>0?r+=`\x1B[${c}B`:c<0&&(r+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let l=this._terminal._core._inputHandler._curAttrData,s=this._diffStyle(l,this._cursorStyle);return s.length>0&&(r+=`\x1B[${s.join(";")}m`),r}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,t){let r=e.length,l=t===void 0?r:A(t+n.rows,0,r);return this._serializeBufferByRange(n,e,{start:r-l,end:r-1},!1)}_serializeBufferByRange(n,e,t,r){return new y(e,n).serialize({start:{x:0,y:typeof t.start=="number"?t.start:t.start.line},end:{x:n.cols,y:typeof t.end=="number"?t.end:t.end.line}},r)}_serializeBufferAsHTML(n,e){let t=n.buffer.active,r=new D(t,n,e),l=e.onlySelection??!1,s=e.range;if(s)return r.serialize({start:{x:s.startCol,y:(typeof s.startLine=="number",s.startLine)},end:{x:n.cols,y:(typeof s.endLine=="number",s.endLine)}});if(!l){let o=t.length,u=e.scrollback,f=u===void 0?o:A(u+n.rows,0,o);return r.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?r.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,t=e.scrollTop,r=e.scrollBottom;return t!==0||r!==n.rows-1?`\x1B[${t+1};${r+1}r`:""}_serializeModes(n){let e="",t=n.modes;if(t.applicationCursorKeysMode&&(e+="\x1B[?1h"),t.applicationKeypadMode&&(e+="\x1B[?66h"),t.bracketedPasteMode&&(e+="\x1B[?2004h"),t.insertMode&&(e+="\x1B[4h"),t.originMode&&(e+="\x1B[?6h"),t.reverseWraparoundMode&&(e+="\x1B[?45h"),t.sendFocusMode&&(e+="\x1B[?1004h"),t.wraparoundMode===!1&&(e+="\x1B[?7l"),t.mouseTrackingMode!=="none")switch(t.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return t.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let t=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${t}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},D=class extends S{constructor(e,t,r){super(e);this._terminal=t;this._options=r;this._currentRow="";this._htmlContent="";t._core._themeService?this._ansiColors=t._core._themeService.colors.ansi:this._ansiColors=k}_beforeSerialize(e,t,r){this._htmlContent+="
";let l="#000000",s="#ffffff";(this._options.includeGlobalBackground??!1)&&(l=this._terminal.options.theme?.foreground??"#ffffff",s=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+l+";"),i.push("background-color: "+s+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,t){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,t){let r=t?e.getFgColor():e.getBgColor();if(t?e.isFgRGB():e.isBgRGB())return"#"+[r>>16&255,r>>8&255,r&255].map(s=>s.toString(16).padStart(2,"0")).join("");if(t?e.isFgPalette():e.isBgPalette())return this._ansiColors[r].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let t=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[t>>16&255,t>>8&255,t&255].map(l=>l.toString(16).padStart(2,"0")).join(""):this._ansiColors[t].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,t){let r=[];if(U(e,t))return;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i){let o=this._getHexColor(e,!0);o&&r.push("color: "+o+";");let u=this._getHexColor(e,!1);u&&r.push("background-color: "+u+";"),e.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&r.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&r.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let C=this._getUnderlineColor(e);C&&r.push("text-decoration-color: "+C+";")}return e.isInvisible()&&r.push("visibility: hidden;"),e.isItalic()&&r.push("font-style: italic;"),e.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,t);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=z(e.getChars())}_serializeString(){return this._htmlContent}};export{D as HTMLSerializeHandler,H as SerializeAddon}; + //# sourceMappingURL=addon-serialize.mjs.map +diff --git a/src/SerializeAddon.ts b/src/SerializeAddon.ts +index e1728feb219c362dfa2ecb602ff99f830d520757..672fdd5b86d73d8694446138835bbeccd43cfaae 100644 +--- a/src/SerializeAddon.ts ++++ b/src/SerializeAddon.ts +@@ -310,7 +310,20 @@ class StringSerializeHandler extends BaseSerializeHandler { + } + if (flagsChanged) { + if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); } +- if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); } ++ // PATCH(orca): bold (1) and dim (2) share the single reset param 22, so ++ // they must be diffed as one intensity group with the clearing 22 emitted ++ // BEFORE any re-set. Upstream's independent per-flag diff could emit ++ // "1;22" (bold set, then wiped by dim's clear — \x1b[2mA\x1b[22m\x1b[1mB ++ // loses B's bold on round-trip) or a bare "22" that drops a still-set ++ // bold/dim, garbling Orca's hidden-terminal snapshot restores. ++ const boldChanged = cell.isBold() !== oldCell.isBold(); ++ const dimChanged = cell.isDim() !== oldCell.isDim(); ++ if (boldChanged || dimChanged) { ++ const clearsIntensity = (boldChanged && !cell.isBold()) || (dimChanged && !cell.isDim()); ++ if (clearsIntensity) { sgrSeq.push(22); } ++ if (cell.isBold() && (boldChanged || clearsIntensity)) { sgrSeq.push(1); } ++ if (cell.isDim() && (dimChanged || clearsIntensity)) { sgrSeq.push(2); } ++ } + if (!equalUnderline(cell, oldCell)) { + const style = cell.getUnderlineStyle(); + if (style === UnderlineStyle.NONE) { +@@ -337,7 +350,7 @@ class StringSerializeHandler extends BaseSerializeHandler { + if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); } + if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); } + if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); } +- if (cell.isDim() !== oldCell.isDim()) { sgrSeq.push(cell.isDim() ? 2 : 22); } ++ // PATCH(orca): dim handled in the intensity group above. + if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); } + } + } diff --git a/config/scripts/check-terminal-perf-report-budgets.mjs b/config/scripts/check-terminal-perf-report-budgets.mjs index 693822373f0..dd7185189d8 100644 --- a/config/scripts/check-terminal-perf-report-budgets.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.mjs @@ -18,6 +18,7 @@ if (reportPaths.length === 0) { const BUDGETS = { maxMedianKeyLatencyMs: 75, maxWorstKeyLatencyMs: 300, + maxRevisitLatencyMs: 300, maxTimerDriftMs: 150, maxScrollLatencyMs: 150, maxRestoreLatencyMs: 1000, @@ -80,6 +81,12 @@ function validateRow(row) { BUDGETS.maxWorstKeyLatencyMs, 'ms' ) + addBudgetCheck( + 'revisit latency', + parseMs(row.revisit, 'revisit', row, failures), + BUDGETS.maxRevisitLatencyMs, + 'ms' + ) addBudgetCheck( 'timer drift', parseMs(row.maxTimerDrift, 'maxTimerDrift', row, failures), @@ -113,6 +120,14 @@ function validateRow(row) { parseCount(row.rendererDroppedBacklogs, 'rendererDroppedBacklogs', row, failures), BUDGETS.maxRendererDroppedBacklogs ) + // Why: parked-memory rows carry heap/view-count metrics with no latency + // budget; recognize them so memory-only scenarios pass the gate instead of + // tripping the "no recognized budget metrics" guard. + for (const fieldName of ['heapUsedMB', 'liveTerminals', 'livePaneManagers']) { + if (parseCount(row[fieldName], fieldName, row, failures) != null) { + checkedMetricCount += 1 + } + } if (checkedMetricCount === 0) { failures.push(`${row.source} ${row.scenario}: no recognized budget metrics found`) } diff --git a/config/scripts/check-terminal-perf-report-budgets.test.mjs b/config/scripts/check-terminal-perf-report-budgets.test.mjs index d1fd378b498..e6129975151 100644 --- a/config/scripts/check-terminal-perf-report-budgets.test.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.test.mjs @@ -58,6 +58,7 @@ describe('check-terminal-perf-report-budgets', () => { 'frames=180', 'median=2.9ms', 'worst=5.9ms', + 'revisit=42.0ms', 'maxTimerDrift=12.1ms', 'scroll=149.9ms', 'restore=642.0ms', @@ -82,6 +83,7 @@ describe('check-terminal-perf-report-budgets', () => { 'frames=60', 'median=76.0ms', 'worst=301.0ms', + 'revisit=301.0ms', 'maxTimerDrift=151.0ms', 'scroll=151.0ms', 'restore=1001.0ms', @@ -96,6 +98,7 @@ describe('check-terminal-perf-report-budgets', () => { expect(result.status).toBe(1) expect(result.stderr).toContain('median typing latency 76ms exceeded budget 75ms') expect(result.stderr).toContain('worst typing latency 301ms exceeded budget 300ms') + expect(result.stderr).toContain('revisit latency 301ms exceeded budget 300ms') expect(result.stderr).toContain('timer drift 151ms exceeded budget 150ms') expect(result.stderr).toContain('scroll latency 151ms exceeded budget 150ms') expect(result.stderr).toContain('restore latency 1001ms exceeded budget 1000ms') @@ -116,6 +119,31 @@ describe('check-terminal-perf-report-budgets', () => { expect(result.stderr).toContain('no recognized budget metrics found') }) + it('accepts revisit-only marker rows as budgeted perf evidence', () => { + const reportPath = writeReport('panes=19 revisit=25.7ms heldAckChars=2097184') + + const output = execFileSync(process.execPath, [scriptPath, reportPath], { + cwd: process.cwd(), + encoding: 'utf8' + }) + + expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).') + }) + + it('accepts parked-memory rows that carry only heap and view-count metrics', () => { + const reportPath = writeReport( + 'panes=8 parkedTabs=8 heapUsedMB=87.8 liveTerminals=1 livePaneManagers=1', + 'opencode-parked-memory' + ) + + const output = execFileSync(process.execPath, [scriptPath, reportPath], { + cwd: process.cwd(), + encoding: 'utf8' + }) + + expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).') + }) + it('fails OpenCode annotation rows that contain no budget metrics', () => { const reportPath = writeReport('panes=1 frames=60') diff --git a/config/scripts/generate-terminal-perf-html-report.mjs b/config/scripts/generate-terminal-perf-html-report.mjs new file mode 100644 index 00000000000..577592169bc --- /dev/null +++ b/config/scripts/generate-terminal-perf-html-report.mjs @@ -0,0 +1,483 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { + budgetFailures, + collectTerminalPerfRows, + compareScenarios, + escapeHtml, + formatLargeValue, + formatMs, + readJsonReport, + scenarioTitle +} from './terminal-perf-report-rows.mjs' +import { basename, dirname } from 'node:path' + +const DEFAULT_OUTPUT_PATH = 'test-results/terminal-perf-impact-report.html' + +// Why: every tracked metric is lower-is-better, so delta coloring and the +// regression table share one direction rule. +const MS_METRICS = [ + { key: 'medianMs', label: 'Typing median', chart: true }, + { key: 'worstMs', label: 'Typing worst', chart: true }, + { key: 'scrollMs', label: 'Active scroll', chart: true }, + { key: 'restoreMs', label: 'Restore', chart: true }, + { key: 'revisitMs', label: 'Revisit marker', chart: true }, + { key: 'maxTimerDriftMs', label: 'Timer drift', chart: false } +] + +const COUNT_METRICS = [ + { key: 'rendererPeakQueuedChars', label: 'Renderer peak queued chars' }, + { key: 'mainPeakInFlightChars', label: 'Main in-flight chars' }, + { key: 'mainPeakPendingChars', label: 'Main pending chars' }, + { key: 'hiddenSkippedChars', label: 'Hidden skipped chars' }, + { key: 'rendererDroppedBacklogs', label: 'Renderer dropped backlogs' }, + // Why: parked-memory scenarios are table-only — heap/view counts have no + // ms trend story, so they stay out of the charts. + { key: 'heapUsedMB', label: 'Renderer JS heap (MB)' }, + { key: 'liveTerminals', label: 'Live xterm instances' }, + { key: 'livePaneManagers', label: 'Live pane managers' } +] + +const SERIES_COLORS = { + medianMs: '#2563eb', + worstMs: '#dc2626', + scrollMs: '#d97706', + restoreMs: '#7c3aed', + revisitMs: '#0d9488' +} + +const LABELED_INPUT_RE = /^([\w .#@()+-]+)=(.+)$/ + +export function parseHtmlReportArgs(argv, env = process.env) { + const args = [...argv] + if (args[0] === '--') { + args.shift() + } + + const inputs = [] + let outputPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_OUTPUT_PATH + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--output' || arg === '-o') { + const next = args[index + 1] + if (!next || next.startsWith('-')) { + throw new Error(`${arg} requires a path`) + } + outputPath = next + index += 1 + continue + } + if (arg.startsWith('--output=')) { + outputPath = arg.slice('--output='.length) + continue + } + const labeled = arg.match(LABELED_INPUT_RE) + if (labeled) { + inputs.push({ label: labeled[1], path: labeled[2] }) + } else { + inputs.push({ label: basename(arg).replace(/\.json$/i, ''), path: arg }) + } + } + + if (inputs.length === 0) { + throw new Error( + 'Usage: node config/scripts/generate-terminal-perf-html-report.mjs [label=]... --output ' + ) + } + return { inputs, outputPath } +} + +// ── Trend data ──────────────────────────────────────────────────────────── + +function buildMatrix(revisions) { + const scenarios = new Map() + for (const revision of revisions) { + for (const row of revision.rows) { + if (!scenarios.has(row.scenario)) { + scenarios.set(row.scenario, new Map()) + } + scenarios.get(row.scenario).set(revision.label, row) + } + } + const orderedScenarios = [...scenarios.keys()].sort(compareScenarios) + return { scenarios, orderedScenarios } +} + +function niceCeil(value) { + if (value <= 0) { + return 1 + } + const magnitude = 10 ** Math.floor(Math.log10(value)) + for (const step of [1, 2, 2.5, 5, 10]) { + if (value <= step * magnitude) { + return step * magnitude + } + } + return 10 * magnitude +} + +// ── Rendering ───────────────────────────────────────────────────────────── + +function renderTrendChart({ scenario, byRevision, revisions, title }) { + const metrics = MS_METRICS.filter( + (metric) => + metric.chart && + revisions.some((revision) => byRevision.get(revision.label)?.[metric.key] != null) + ) + if (metrics.length === 0) { + return '' + } + const width = 560 + const height = 230 + const pad = { left: 52, right: 14, top: 30, bottom: 38 } + const plotW = width - pad.left - pad.right + const plotH = height - pad.top - pad.bottom + const maxValue = Math.max( + 1, + ...metrics.flatMap((metric) => + revisions.map((revision) => byRevision.get(revision.label)?.[metric.key] ?? 0) + ) + ) + const yMax = niceCeil(maxValue * 1.15) + const xFor = (index) => + pad.left + (revisions.length === 1 ? plotW / 2 : (plotW * index) / (revisions.length - 1)) + const yFor = (value) => pad.top + plotH - (plotH * value) / yMax + + const parts = [] + parts.push( + `` + ) + parts.push(`${escapeHtml(title)}`) + // Horizontal gridlines + y labels + const ticks = 4 + for (let tick = 0; tick <= ticks; tick += 1) { + const value = (yMax * tick) / ticks + const y = yFor(value) + parts.push( + `` + ) + parts.push( + `${value % 1 === 0 ? value : value.toFixed(1)}` + ) + } + // X labels + revisions.forEach((revision, index) => { + parts.push( + `${escapeHtml(revision.label)}` + ) + }) + // Series + for (const metric of metrics) { + const color = SERIES_COLORS[metric.key] ?? '#475569' + const points = revisions + .map((revision, index) => ({ index, value: byRevision.get(revision.label)?.[metric.key] })) + .filter((point) => point.value != null) + if (points.length === 0) { + continue + } + const path = points + .map( + (point, order) => + `${order === 0 ? 'M' : 'L'}${xFor(point.index).toFixed(1)},${yFor(point.value).toFixed(1)}` + ) + .join(' ') + parts.push(``) + for (const point of points) { + const x = xFor(point.index) + const y = yFor(point.value) + parts.push(``) + parts.push( + `${point.value % 1 === 0 ? point.value : point.value.toFixed(1)}` + ) + } + } + parts.push('') + + const legend = metrics + .map((metric) => { + const color = SERIES_COLORS[metric.key] ?? '#475569' + return `${escapeHtml(metric.label)}` + }) + .join('') + return `
${parts.join('')}
${legend} ms — lower is better
` +} + +function deltaCell(baseline, latest, { lowerIsBetter = true, zeroBudget = false } = {}) { + if (baseline == null || latest == null) { + return '—' + } + const diff = latest - baseline + const pct = baseline === 0 ? null : (diff / baseline) * 100 + let cls = 'neutral' + if (zeroBudget) { + cls = latest > 0 ? 'worse' : 'better' + } else if (pct != null && Math.abs(pct) >= 5) { + cls = diff < 0 === lowerIsBetter ? 'better' : 'worse' + } else if (baseline === 0 && diff !== 0) { + cls = diff < 0 === lowerIsBetter ? 'better' : 'worse' + } + const pctLabel = + pct == null ? (diff === 0 ? '±0%' : 'new') : `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%` + const diffLabel = `${diff >= 0 ? '+' : ''}${Math.abs(diff) >= 100 ? Math.round(diff) : diff.toFixed(1)}` + return `${escapeHtml(pctLabel)} (${escapeHtml(diffLabel)})` +} + +function renderScenarioTable({ scenario, byRevision, revisions, title }) { + const metricRows = [] + const allMetrics = [...MS_METRICS, ...COUNT_METRICS] + for (const metric of allMetrics) { + const values = revisions.map((revision) => byRevision.get(revision.label)?.[metric.key]) + if (values.every((value) => value == null)) { + continue + } + const isMs = MS_METRICS.includes(metric) + const format = isMs ? formatMs : formatLargeValue + const cells = values + .map((value) => `${value == null ? '—' : escapeHtml(format(value))}`) + .join('') + const baseline = values.find((value) => value != null) + const latest = values.toReversed().find((value) => value != null) + metricRows.push( + `${escapeHtml(metric.label)}${cells}${deltaCell(baseline, latest, { + zeroBudget: metric.key === 'rendererDroppedBacklogs' + })}` + ) + } + if (metricRows.length === 0) { + return '' + } + const headers = revisions.map((revision) => `${escapeHtml(revision.label)}`).join('') + return `
+

${escapeHtml(title)} ${escapeHtml(scenario)}

+ +${headers} +${metricRows.join('')} +
MetricΔ first → last
+
` +} + +function renderHeadline(revisions, matrix) { + if (revisions.length < 2) { + return '' + } + const first = revisions[0] + const last = revisions.at(-1) + const cards = [] + for (const scenario of matrix.orderedScenarios) { + const byRevision = matrix.scenarios.get(scenario) + const baseRow = byRevision.get(first.label) + const lastRow = byRevision.get(last.label) + if (!baseRow || !lastRow || baseRow.medianMs == null || lastRow.medianMs == null) { + continue + } + const diff = lastRow.medianMs - baseRow.medianMs + const pct = baseRow.medianMs === 0 ? 0 : (diff / baseRow.medianMs) * 100 + const cls = Math.abs(pct) < 5 ? 'neutral' : diff < 0 ? 'better' : 'worse' + cards.push(`
+
${escapeHtml(scenarioTitle(scenario, lastRow))}
+
${escapeHtml(formatMs(baseRow.medianMs))} → ${escapeHtml(formatMs(lastRow.medianMs))}
+
typing median, ${escapeHtml(first.label)} → ${escapeHtml(last.label)} (${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%)
+
`) + } + if (cards.length === 0) { + return '' + } + return `

Baseline vs latest

${cards.join('')}
` +} + +function renderBudgets(latestRevision) { + const failures = [] + for (const row of latestRevision.rows) { + for (const failure of budgetFailures(row)) { + failures.push(`${row.scenario}: ${failure}`) + } + } + const status = + failures.length === 0 ? 'Pass' : 'Fail' + const failureList = + failures.length === 0 + ? '' + : `
    ${failures.map((failure) => `
  • ${escapeHtml(failure)}
  • `).join('')}
` + return `

Budget status — ${escapeHtml(latestRevision.label)}

+

${latestRevision.rows.length} scenario rows checked: ${status}

${failureList}
` +} + +function renderInputsMeta(revisions) { + const items = revisions + .map((revision) => { + const stats = revision.stats + const statsLabel = stats + ? ` — ${stats.expected ?? 0} passed, ${stats.unexpected ?? 0} failed, ${stats.flaky ?? 0} flaky` + : '' + const failNote = + stats && stats.unexpected > 0 + ? ' (failed assertions at this revision; metrics still recorded)' + : '' + return `
  • ${escapeHtml(revision.label)} — ${revision.rows.length} scenario rows (${escapeHtml(revision.path)})${escapeHtml(statsLabel)}${failNote}
  • ` + }) + .join('') + return `
      ${items}
    ` +} + +function renderRawDetails(revisions) { + return revisions + .map((revision) => { + const rows = revision.rows + .map( + (row) => + `${escapeHtml(row.scenario)}${row.panes ?? '—'}${escapeHtml(formatMs(row.medianMs))}${escapeHtml(formatMs(row.worstMs))}${escapeHtml(formatMs(row.scrollMs))}${escapeHtml(formatMs(row.restoreMs))}${escapeHtml(formatMs(row.revisitMs))}${escapeHtml(formatLargeValue(row.rendererPeakQueuedChars))}${escapeHtml(formatLargeValue(row.hiddenSkippedChars))}${row.rendererDroppedBacklogs ?? '—'}` + ) + .join('') + return `
    Raw rows — ${escapeHtml(revision.label)} + + +${rows}
    ScenarioPanesMedianWorstScrollRestoreRevisitRenderer peakHidden skippedDrops
    ` + }) + .join('') +} + +const PAGE_CSS = ` +:root { color-scheme: light; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px auto; max-width: 1240px; padding: 0 16px; color: #0f172a; background: #f8fafc; } +h1 { font-size: 24px; margin-bottom: 4px; } +h2 { font-size: 18px; margin: 28px 0 10px; } +h3 { font-size: 15px; margin: 18px 0 6px; } +.meta { color: #64748b; font-size: 13px; } +.inputs { font-size: 13px; color: #334155; padding-left: 20px; } +.meta-warn { color: #b45309; } +.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; } +.card { background: #fff; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; padding: 10px 12px; } +.card.better { border-left-color: #16a34a; } +.card.worse { border-left-color: #dc2626; } +.card.neutral { border-left-color: #94a3b8; } +.card-title { font-size: 12px; color: #64748b; } +.card-value { font-size: 18px; font-weight: 600; margin: 2px 0; } +.card-sub { font-size: 11px; color: #94a3b8; } +.charts { display: grid; grid-template-columns: repeat(auto-fill, minmax(560px, 1fr)); gap: 14px; } +.chart-card { margin: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; } +.trend-chart { width: 100%; height: auto; } +.chart-title { font-size: 13px; font-weight: 600; fill: #0f172a; } +.gridline { stroke: #e2e8f0; stroke-width: 1; } +.axis-label { font-size: 10px; fill: #64748b; } +.point-label { font-size: 10px; font-weight: 600; } +.legend { font-size: 11px; color: #475569; margin-top: 2px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } +.legend-item { display: inline-flex; align-items: center; gap: 4px; } +.legend-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; } +.legend-unit { color: #94a3b8; margin-left: auto; } +.scenario-block { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; margin: 10px 0; } +.scenario-id { font-size: 11px; color: #94a3b8; font-weight: 400; margin-left: 6px; } +table.trend-table { border-collapse: collapse; width: 100%; font-size: 12px; } +table.trend-table th, table.trend-table td { border-bottom: 1px solid #e2e8f0; padding: 5px 8px; text-align: right; white-space: nowrap; } +table.trend-table th:first-child, table.trend-table td:first-child { text-align: left; } +table.trend-table thead th { color: #475569; font-weight: 600; background: #f1f5f9; } +td.delta.better { color: #15803d; font-weight: 600; } +td.delta.worse { color: #b91c1c; font-weight: 600; } +td.delta.neutral { color: #64748b; } +.delta-abs { font-weight: 400; color: #94a3b8; } +.pass { color: #15803d; font-weight: 700; } +.fail { color: #b91c1c; font-weight: 700; } +details { margin: 8px 0; } +summary { cursor: pointer; font-size: 13px; color: #334155; } +` + +function renderHtml({ generatedAt, revisions }) { + const matrix = buildMatrix(revisions) + const charts = + revisions.length >= 2 + ? matrix.orderedScenarios + .map((scenario) => { + const byRevision = matrix.scenarios.get(scenario) + const anyRow = [...byRevision.values()][0] + return renderTrendChart({ + scenario, + byRevision, + revisions, + title: scenarioTitle(scenario, anyRow) + }) + }) + .filter(Boolean) + .join('') + : '' + const tables = matrix.orderedScenarios + .map((scenario) => { + const byRevision = matrix.scenarios.get(scenario) + const anyRow = [...byRevision.values()][0] + return renderScenarioTable({ + scenario, + byRevision, + revisions, + title: scenarioTitle(scenario, anyRow) + }) + }) + .filter(Boolean) + .join('') + + return ` + + + + +Terminal Performance Over Time + + + +

    Terminal Performance Over Time

    +

    Generated ${escapeHtml(generatedAt)} from ${revisions.length} benchmark run(s), ordered oldest (baseline) to newest. All metrics: lower is better.

    +${renderInputsMeta(revisions)} +${renderHeadline(revisions, matrix)} +${charts ? `

    Trends across revisions

    ${charts}
    ` : ''} +

    Metric detail by scenario

    ${tables}
    +${renderBudgets(revisions.at(-1))} +

    Raw data

    ${renderRawDetails(revisions)}
    + + +` +} + +export function generateTerminalPerfHtmlReport({ + inputs, + inputPaths, + outputPath, + now = new Date() +}) { + // Why: older callers (the scale report gate) pass bare inputPaths. + const resolvedInputs = + inputs ?? + (inputPaths ?? []).map((path) => ({ + label: basename(path).replace(/\.json$/i, ''), + path + })) + const revisions = resolvedInputs.map(({ label, path }) => { + const report = readJsonReport(path) + return { + label, + path, + stats: report.stats ?? null, + rows: collectTerminalPerfRows(report, label) + } + }) + const totalRows = revisions.reduce((sum, revision) => sum + revision.rows.length, 0) + if (totalRows === 0) { + throw new Error('No opencode terminal perf annotations found in the provided reports') + } + const html = renderHtml({ generatedAt: now.toISOString(), revisions }) + mkdirSync(dirname(outputPath), { recursive: true }) + writeFileSync(outputPath, html) + const latestFailures = revisions + .at(-1) + .rows.reduce((sum, row) => sum + budgetFailures(row).length, 0) + return { outputPath, rowCount: totalRows, budgetFailureCount: latestFailures } +} + +const isMain = process.argv[1] && import.meta.filename === process.argv[1] +if (isMain) { + try { + const { inputs, outputPath } = parseHtmlReportArgs(process.argv.slice(2)) + const result = generateTerminalPerfHtmlReport({ inputs, outputPath }) + console.log( + `Terminal perf HTML report saved to ${result.outputPath} (${result.rowCount} rows, ${result.budgetFailureCount} budget failures).` + ) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} diff --git a/config/scripts/generate-terminal-perf-html-report.test.mjs b/config/scripts/generate-terminal-perf-html-report.test.mjs new file mode 100644 index 00000000000..a804bb6639d --- /dev/null +++ b/config/scripts/generate-terminal-perf-html-report.test.mjs @@ -0,0 +1,272 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + generateTerminalPerfHtmlReport, + parseHtmlReportArgs +} from './generate-terminal-perf-html-report.mjs' + +const tempDirs = [] + +function makeTempDir() { + const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-html-')) + tempDirs.push(dir) + return dir +} + +function writeReport( + annotationDescription, + annotationType = 'opencode-scale-same-workspace-25', + reportName = 'report.json' +) { + const dir = makeTempDir() + const reportPath = join(dir, reportName) + writeFileSync( + reportPath, + JSON.stringify({ + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: annotationType, + description: annotationDescription + }, + { + type: 'browser-unrelated', + description: 'median=999.0ms' + } + ] + } + ] + } + ] + } + ] + }) + ) + return reportPath +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { force: true, recursive: true }) + } +}) + +describe('generate-terminal-perf-html-report', () => { + it('parses labeled and bare input paths plus output flags', () => { + expect(parseHtmlReportArgs(['--', 'a.json', 'b.json', '--output', 'out.html'])).toEqual({ + inputs: [ + { label: 'a', path: 'a.json' }, + { label: 'b', path: 'b.json' } + ], + outputPath: 'out.html' + }) + expect(parseHtmlReportArgs(['main=runs/0-main.json', '#5038 final=runs/4-final.json'])).toEqual( + { + inputs: [ + { label: 'main', path: 'runs/0-main.json' }, + { label: '#5038 final', path: 'runs/4-final.json' } + ], + outputPath: 'test-results/terminal-perf-impact-report.html' + } + ) + expect( + parseHtmlReportArgs(['a.json'], { ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'env.html' }) + ).toEqual({ + inputs: [{ label: 'a', path: 'a.json' }], + outputPath: 'env.html' + }) + expect(() => parseHtmlReportArgs(['--output'])).toThrow('--output requires a path') + expect(() => parseHtmlReportArgs([])).toThrow('Usage:') + }) + + it('writes a single-run report with scenario tables and budget status', () => { + const reportPath = writeReport( + [ + 'panes=25', + 'frames=60', + 'median=12.4ms', + 'worst=44.8ms', + 'revisit=28.6ms', + 'scroll=61.0ms', + 'restore=320.0ms', + 'maxTimerDrift=8.0ms', + 'rendererPeakQueuedChars=2048', + 'mainPeakInFlightChars=4096', + 'heldAckChars=1024', + 'hiddenSkippedChars=512', + 'rendererDroppedBacklogs=0' + ].join(' ') + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ + inputPaths: [reportPath], + outputPath, + now: new Date('2026-06-09T10:00:00.000Z') + }) + + const html = readFileSync(outputPath, 'utf8') + expect(result).toEqual({ budgetFailureCount: 0, outputPath, rowCount: 1 }) + expect(html).toContain('') + expect(html).toContain('Terminal Performance Over Time') + expect(html).toContain('2026-06-09T10:00:00.000Z') + expect(html).toContain('Same workspace panes — 25 panes') + expect(html).toContain('opencode-scale-same-workspace-25') + expect(html).toContain('28.6ms') + expect(html).toContain('Pass') + // Why: one run has no over-time story; the trend section must not render. + expect(html).not.toContain('Trends across revisions') + expect(html).not.toContain('browser-unrelated') + }) + + it('renders parked-memory heap and live view counts as table metrics', () => { + const reportPath = writeReport( + 'panes=8 parkedTabs=8 heapUsedMB=142.5 liveTerminals=1 livePaneManagers=1', + 'opencode-parked-memory' + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath }) + + const html = readFileSync(outputPath, 'utf8') + // Why: heapUsedMB has no budget — a memory row alone must not fail gates. + expect(result.budgetFailureCount).toBe(0) + expect(html).toContain('Parked hidden terminal memory — 8 panes') + expect(html).toContain('Renderer JS heap (MB)') + expect(html).toContain('142.5') + expect(html).toContain('Live xterm instances') + expect(html).toContain('Live pane managers') + }) + + it('marks over-budget rows as failures for the latest run', () => { + const reportPath = writeReport( + [ + 'panes=100', + 'median=80.0ms', + 'worst=301.0ms', + 'revisit=301.0ms', + 'rendererPeakQueuedChars=2097153', + 'rendererDroppedBacklogs=1' + ].join(' '), + 'opencode-scale-cross-workspace-100' + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath }) + + const html = readFileSync(outputPath, 'utf8') + expect(result.budgetFailureCount).toBe(5) + expect(html).toContain('Fail') + expect(html).toContain('medianMs 80 > 75') + expect(html).toContain('Cross-workspace hidden panes') + }) + + it('renders ordered revisions with trend charts and baseline deltas', () => { + const mainReport = writeReport( + 'panes=25 median=50.0ms worst=120.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'main.json' + ) + const middleReport = writeReport( + 'panes=25 median=30.0ms worst=140.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'backpressure.json' + ) + const finalReport = writeReport( + 'panes=25 median=20.0ms worst=100.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'final.json' + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ + inputs: [ + { label: 'main', path: mainReport }, + { label: 'backpressure', path: middleReport }, + { label: 'final', path: finalReport } + ], + outputPath + }) + + const html = readFileSync(outputPath, 'utf8') + expect(result.rowCount).toBe(3) + expect(html).toContain('Baseline vs latest') + expect(html).toContain('Trends across revisions') + expect(html).toContain('trend-chart') + expect(html).toContain('>main<') + expect(html).toContain('>backpressure<') + expect(html).toContain('>final<') + // Why: median 50 -> 20 is a 60% improvement and must read as better. + expect(html).toContain('delta better') + expect(html).toContain('-60%') + expect(html).toContain('50.0ms → 20.0ms') + }) + + it('renders missing scenarios at older revisions as gaps, not zeros', () => { + const mainReport = writeReport( + 'panes=25 median=50.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'main.json' + ) + const finalReport = makeTempDir() + const finalPath = join(finalReport, 'final.json') + writeFileSync( + finalPath, + JSON.stringify({ + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: 'opencode-scale-same-workspace-25', + description: 'panes=25 median=40.0ms rendererDroppedBacklogs=0' + }, + { + type: 'opencode-revisit-pressure', + description: 'panes=19 median=3.0ms revisit=4.4ms rendererDroppedBacklogs=0' + } + ] + } + ] + } + ] + } + ] + }) + ) + const outputPath = join(makeTempDir(), 'report.html') + + generateTerminalPerfHtmlReport({ + inputs: [ + { label: 'main', path: mainReport }, + { label: 'final', path: finalPath } + ], + outputPath + }) + + const html = readFileSync(outputPath, 'utf8') + expect(html).toContain('Revisit under pressure') + expect(html).toContain('—') + }) + + it('fails when reports contain no terminal perf annotations', () => { + const reportPath = writeReport('median=12.0ms', 'browser-unrelated') + + expect(() => + generateTerminalPerfHtmlReport({ + inputPaths: [reportPath], + outputPath: join(makeTempDir(), 'report.html') + }) + ).toThrow('No opencode terminal perf annotations found') + }) +}) diff --git a/config/scripts/release-rc-history.mjs b/config/scripts/release-rc-history.mjs index e3cdea2d870..c6a0e7b4782 100644 --- a/config/scripts/release-rc-history.mjs +++ b/config/scripts/release-rc-history.mjs @@ -25,7 +25,11 @@ export function rcNumberFromTag(base, tag) { } const suffix = tag.slice(prefix.length) - return /^\d+$/.test(suffix) ? Number(suffix) : null + // Why the optional .identifier: suffixed side-branch RCs (v1.2.3-rc.4.perf) + // must advance the shared rc counter, or the next suffixed cut recomputes + // an existing tag and the workflow refuses to re-cut over it. + const match = /^(\d+)(?:\.[0-9A-Za-z]+)?$/.exec(suffix) + return match ? Number(match[1]) : null } export function rcNumberFromReleaseSubject(base, subject) { diff --git a/config/scripts/run-multi-workspace-typing-bench.mjs b/config/scripts/run-multi-workspace-typing-bench.mjs new file mode 100644 index 00000000000..7208afb470a --- /dev/null +++ b/config/scripts/run-multi-workspace-typing-bench.mjs @@ -0,0 +1,63 @@ +/** + * Entry point for the multi-workspace typing-latency bench + * (tests/e2e/terminal-multi-workspace-typing-latency.spec.ts). + * + * Usage: + * pnpm bench:multi-workspace-typing [-- --panes 8 --rate-kbps 512 \ + * --keys 48 --cadence-ms 250 --cpu-workers 4 --label before-fix] + * + * Results land in tools/benchmarks/results/multi-workspace-typing-*.json. + * Run once per build/config with distinct --label values, then diff the + * totalMs/inputHalfMs/echoHalfMs percentiles. + */ +import { spawn } from 'node:child_process' + +const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx' + +const knobByFlag = { + '--panes': 'ORCA_TYPING_BENCH_LOAD_PANES', + '--rate-kbps': 'ORCA_TYPING_BENCH_RATE_KBPS', + '--keys': 'ORCA_TYPING_BENCH_KEYS', + '--cadence-ms': 'ORCA_TYPING_BENCH_KEY_CADENCE_MS', + '--cpu-workers': 'ORCA_TYPING_BENCH_CPU_WORKERS', + '--label': 'ORCA_TYPING_BENCH_LABEL' +} + +const env = { ...process.env, ORCA_TYPING_BENCH: '1' } +const passthroughArgs = [] +const argv = process.argv.slice(2) +for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--') { + continue + } + const knob = knobByFlag[argv[i]] + if (knob) { + env[knob] = argv[++i] + } else { + passthroughArgs.push(argv[i]) + } +} + +const child = spawn( + npxCommand, + [ + 'playwright', + 'test', + 'tests/e2e/terminal-multi-workspace-typing-latency.spec.ts', + '--config', + 'tests/playwright.config.ts', + '--project', + 'electron-headless', + '--workers=1', + ...passthroughArgs + ], + { stdio: 'inherit', env } +) + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + process.exit(code ?? 1) +}) diff --git a/config/scripts/run-terminal-scale-perf-report-gate.mjs b/config/scripts/run-terminal-scale-perf-report-gate.mjs index eef60041da1..59b1b443fa9 100644 --- a/config/scripts/run-terminal-scale-perf-report-gate.mjs +++ b/config/scripts/run-terminal-scale-perf-report-gate.mjs @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' const DEFAULT_REPORT_PATH = 'test-results/terminal-scale-perf-report.json' +const DEFAULT_HTML_REPORT_PATH = 'test-results/terminal-perf-impact-report.html' export function parseReportGateArgs(argv, env = process.env) { const forwardedArgs = [...argv] @@ -114,7 +115,20 @@ export function runTerminalScalePerfReportGate({ spawnSyncImpl, env ) - return exitCode(budgetResult) + const budgetExitCode = exitCode(budgetResult) + if (budgetExitCode !== 0) { + return budgetExitCode + } + + const htmlReportPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_HTML_REPORT_PATH + const htmlResult = runNodeScript( + 'config/scripts/generate-terminal-perf-html-report.mjs', + [reportPath, '--output', htmlReportPath], + 'inherit', + spawnSyncImpl, + env + ) + return exitCode(htmlResult) } if (process.argv[1] === import.meta.filename) { diff --git a/config/scripts/run-terminal-scale-perf-report-gate.test.mjs b/config/scripts/run-terminal-scale-perf-report-gate.test.mjs index 929e2d5b9ba..e4d7be8861e 100644 --- a/config/scripts/run-terminal-scale-perf-report-gate.test.mjs +++ b/config/scripts/run-terminal-scale-perf-report-gate.test.mjs @@ -77,7 +77,8 @@ describe('run-terminal-scale-perf-report-gate', () => { expect(calls.map((call) => call.args[0])).toEqual([ 'config/scripts/run-terminal-scale-perf-e2e.mjs', 'config/scripts/summarize-terminal-perf-report.mjs', - 'config/scripts/check-terminal-perf-report-budgets.mjs' + 'config/scripts/check-terminal-perf-report-budgets.mjs', + 'config/scripts/generate-terminal-perf-html-report.mjs' ]) expect(calls[0].args).toEqual([ 'config/scripts/run-terminal-scale-perf-e2e.mjs', @@ -92,6 +93,12 @@ describe('run-terminal-scale-perf-report-gate', () => { 'config/scripts/check-terminal-perf-report-budgets.mjs', reportPath ]) + expect(calls[3].args).toEqual([ + 'config/scripts/generate-terminal-perf-html-report.mjs', + reportPath, + '--output', + 'test-results/terminal-perf-impact-report.html' + ]) }) it('uses the report path from env when no flag is provided', () => { @@ -107,6 +114,28 @@ describe('run-terminal-scale-perf-report-gate', () => { expect(calls[1].args).toEqual(['config/scripts/summarize-terminal-perf-report.mjs', reportPath]) }) + it('uses the HTML report path from env when provided', () => { + const reportPath = tempReportPath() + const { calls, spawnSyncImpl } = makeSpawnSync() + + const status = runTerminalScalePerfReportGate({ + env: { + ...process.env, + ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'tmp/terminal-report.html', + ORCA_E2E_TERMINAL_PERF_REPORT_PATH: reportPath + }, + spawnSyncImpl + }) + + expect(status).toBe(0) + expect(calls[3].args).toEqual([ + 'config/scripts/generate-terminal-perf-html-report.mjs', + reportPath, + '--output', + 'tmp/terminal-report.html' + ]) + }) + it('preserves the report when Playwright clears the target report directory', () => { const reportPath = tempReportPath() const { spawnSyncImpl } = makeSpawnSync({ diff --git a/config/scripts/summarize-terminal-perf-report.mjs b/config/scripts/summarize-terminal-perf-report.mjs index 6d2adea54f9..2e4f77732b3 100644 --- a/config/scripts/summarize-terminal-perf-report.mjs +++ b/config/scripts/summarize-terminal-perf-report.mjs @@ -25,6 +25,7 @@ function printMarkdownTable(rows) { ['Frames', 'frames'], ['Median', 'median'], ['Worst', 'worst'], + ['Revisit', 'revisit'], ['Scroll', 'scroll'], ['Restore', 'restore'], ['Max Drift', 'maxTimerDrift'], diff --git a/config/scripts/terminal-perf-report-rows.mjs b/config/scripts/terminal-perf-report-rows.mjs new file mode 100644 index 00000000000..c1f562f3b9e --- /dev/null +++ b/config/scripts/terminal-perf-report-rows.mjs @@ -0,0 +1,199 @@ +import { readFileSync } from 'node:fs' + +const BUDGETS = { + medianMs: 75, + worstMs: 300, + revisitMs: 300, + maxTimerDriftMs: 150, + scrollMs: 150, + restoreMs: 1000, + rendererQueuedChars: 2 * 1024 * 1024, + rendererPeakQueuedChars: 2 * 1024 * 1024, + rendererDroppedBacklogs: 0 +} + +const SCENARIO_LABELS = [ + ['opencode-scale-same-workspace', 'Same workspace panes'], + ['opencode-scale-cross-workspace', 'Cross-workspace hidden panes'], + ['opencode-scale-pressure', 'ACK-backpressured PTYs'], + ['opencode-scale-hidden-pressure', 'Hidden real PTYs'], + ['opencode-cross-workspace-typing', 'Cross-workspace typing'], + ['opencode-main-pressure', 'Main renderer pressure'], + ['opencode-hidden-pressure', 'Hidden pressure'], + ['opencode-revisit-pressure', 'Revisit under pressure'], + // Why: the prefix also matches opencode-parked-memory-disabled, so both + // parked-memory scenarios group under one label. + ['opencode-parked-memory', 'Parked hidden terminal memory'] +] + +export function readJsonReport(path) { + const raw = readFileSync(path, 'utf8') + const start = raw.indexOf('{') + const end = raw.lastIndexOf('}') + if (start === -1 || end <= start) { + throw new Error(`${path}: no JSON object found`) + } + return JSON.parse(raw.slice(start, end + 1)) +} + +function parseAnnotationDescription(description) { + const values = {} + for (const part of description.split(/\s+/)) { + const index = part.indexOf('=') + if (index === -1) { + continue + } + values[part.slice(0, index)] = part.slice(index + 1) + } + return values +} + +export function collectTerminalPerfRows(report, source) { + const rows = [] + const visitSuite = (suite) => { + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) { + for (const annotation of test.annotations ?? []) { + if (!annotation.type.startsWith('opencode-')) { + continue + } + rows.push( + normalizeRow({ + source, + scenario: annotation.type, + ...parseAnnotationDescription(annotation.description ?? '') + }) + ) + } + } + } + for (const child of suite.suites ?? []) { + visitSuite(child) + } + } + for (const suite of report.suites ?? []) { + visitSuite(suite) + } + return rows +} + +function parseMs(value) { + const match = String(value ?? '').match(/^(-?\d+(?:\.\d+)?)ms$/) + return match ? Number(match[1]) : null +} + +function parseCount(value) { + if (value == null || value === '') { + return null + } + const count = Number(value) + return Number.isFinite(count) ? count : null +} + +function normalizeRow(row) { + return { + ...row, + group: scenarioGroup(row.scenario), + panes: parseCount(row.panes), + frames: parseCount(row.frames), + medianMs: parseMs(row.median), + worstMs: parseMs(row.worst), + revisitMs: parseMs(row.revisit), + maxTimerDriftMs: parseMs(row.maxTimerDrift), + scrollMs: parseMs(row.scroll), + restoreMs: parseMs(row.restore), + rendererQueuedChars: parseCount(row.rendererQueuedChars), + rendererPeakQueuedChars: parseCount(row.rendererPeakQueuedChars), + rendererDroppedBacklogs: parseCount(row.rendererDroppedBacklogs), + mainPeakPendingChars: parseCount(row.mainPeakPendingChars), + mainPeakInFlightChars: parseCount(row.mainPeakInFlightChars), + heldAckChars: parseCount(row.heldAckChars), + hiddenSkippedChars: parseCount(row.hiddenSkippedChars), + // Why: parked-memory annotations report a fractional MB heap figure plus + // live renderer view counts; Number() keeps the MB float intact. + heapUsedMB: parseCount(row.heapUsedMB), + liveTerminals: parseCount(row.liveTerminals), + livePaneManagers: parseCount(row.livePaneManagers) + } +} + +export function scenarioGroup(scenario) { + for (const [prefix, label] of SCENARIO_LABELS) { + if (scenario.startsWith(prefix)) { + return label + } + } + return 'Other terminal scenarios' +} + +function scenarioSortKey(scenario) { + const prefixIndex = SCENARIO_LABELS.findIndex(([prefix]) => scenario.startsWith(prefix)) + const paneMatch = scenario.match(/-(\d+)$/) + return [ + prefixIndex === -1 ? SCENARIO_LABELS.length : prefixIndex, + paneMatch ? Number(paneMatch[1]) : 0, + scenario + ] +} + +export function compareScenarios(a, b) { + const ka = scenarioSortKey(a) + const kb = scenarioSortKey(b) + if (ka[0] !== kb[0]) { + return ka[0] - kb[0] + } + if (ka[1] !== kb[1]) { + return ka[1] - kb[1] + } + return ka[2] < kb[2] ? -1 : ka[2] > kb[2] ? 1 : 0 +} + +export function scenarioTitle(scenario, row) { + const group = scenarioGroup(scenario) + if (row?.panes != null) { + return `${group} — ${row.panes} panes` + } + return group +} + +export function budgetFailures(row) { + const failures = [] + for (const [key, budget] of Object.entries(BUDGETS)) { + const value = row[key] + if (value == null) { + continue + } + if (value > budget) { + failures.push(`${key} ${value} > ${budget}`) + } + } + return failures +} + +export function formatMs(value) { + if (value == null) { + return '—' + } + return `${value.toFixed(1)}ms` +} + +export function formatLargeValue(value) { + if (value == null) { + return '—' + } + if (value >= 1024 * 1024) { + return `${(value / (1024 * 1024)).toFixed(2)}M` + } + if (value >= 1024) { + return `${Math.round(value / 1024)}k` + } + return String(value) +} + +export function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') +} diff --git a/docs/reference/terminal-hidden-view-parking.md b/docs/reference/terminal-hidden-view-parking.md new file mode 100644 index 00000000000..eeaf17877ea --- /dev/null +++ b/docs/reference/terminal-hidden-view-parking.md @@ -0,0 +1,115 @@ +# Terminal Hidden View Parking + +Status: Shipped — Phase 1 of the terminal model/view architecture, kill switch +`terminalHiddenViewParking` (default on). See +[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) for the +invariants this design extends and the full phase list. + +## Problem + +Hidden terminal panes keep a full renderer xterm instance alive (buffer, +scrollback, DOM, addons). At many-worktree scale this is the dominant renderer +memory cost, and it forces every hidden byte through renderer-side write/skip +decisions. The main-process model (daemon + runtime headless emulators) already +ingests every byte and can serve restorable snapshots, so the renderer view for +a long-hidden pane is redundant state. + +A previous attempt shipped and was reverted the same day. The post-mortem +finding: parking unmounted the pane component, which also tore down the +renderer's PTY byte parsers — and those parsers are the only source of bell +notifications, title-transition agent-complete notifications, and tab titles. +A parked worktree whose agent finished would never notify. This design keeps +those side effects alive while parked. + +## Design + +### Park policy (renderer) + +A pure policy module decides which hidden terminal tabs may park: + +- Cold-park hysteresis: a tab must be hidden for 30s before parking. +- Hot-retain working set: recently visible worktrees/tabs are retained + (5 minutes, bounded count) so quick tab switches never pay a re-hydrate. +- Eligibility excludes: visible panes, hidden-measuring startup probes, + activity-portal panes, tabs with pending startup commands or pending + activation spawns, floating-panel tabs, and any tab whose PTY is not + snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded). +- Kill switch: `settings.terminalHiddenViewParking === false` disables parking + entirely. + +### Park mechanics + +Parking a tab unmounts its `TerminalPane` React subtree (the overlay layer +renders null for parked tabs). This is the same teardown that tab-group moves +already exercise: transports detach but the PTY session, daemon model, and tab +state all survive. The xterm instance, its buffers, DOM, and WebGL/addon +resources are released. + +### Parked watcher (the piece the reverted attempt lacked) + +While a tab is parked, a pane-less watcher +(`parked-terminal-byte-watcher.ts`) keeps the pane's side effects alive. Its +consumption mode is decided once at watcher start: + +- **Main side-effect authority on (default):** the watcher is purely + fact-driven — it registers exactly one `pty:sideEffect` fact consumer and + parses no bytes. Titles, agent working/idle/exited transitions, BEL + attention, and PR links arrive as main-tracker facts and drive the same + policy callbacks a mounted pane uses. With the hidden-delivery gate also on, + the watcher marks the PTY hidden so main stops renderer byte delivery + entirely; the DECSET 2031 color-scheme subscribe arrives as main's + `2031-subscribe` fact and the watcher replies out-of-band via + `transport.sendInput`. +- **Kill switch off:** the watcher subscribes to raw bytes through the + dispatcher sidecar mechanism (the same mechanism background agent launches + use) and runs the transport-level byte parsers with no xterm — OSC 0/1/2 + titles (all-titles ordering, live-path normalization), the title-transition + agent tracker (completion notification, prompt-cache timer), the OSC-aware + stateful BEL detector, the GitHub PR link scan, and a dedicated DECSET 2031 + byte responder (`parked-terminal-mode2031-responder.ts`, whose + `subscribeToPtyData` registration doubles as the delivery-interest signal). + +The two modes drive one shared policy-callback block, so flipping the kill +switch never changes notification semantics. Main's synthetic +agent-title/permission frames feed the main tracker directly and arrive as +facts; the legacy synthetic `pty:data` copy exists only in kill-switch-off +mode. + +Out of scope while parked: OSC 52 clipboard writes. Terminal queries inside +hidden-dropped chunks are answered by main's model responder +([`terminal-query-authority.md`](./terminal-query-authority.md)); in +kill-switch-off byte mode only the 2031 reply is answered and Command Code +output is not scraped, matching the pre-gate status quo. + +### Reveal + +Revealing a parked tab remounts the pane subtree and rides the existing +reattach path: fresh xterm via `openTerminal` (unicode provider activation +before any write), daemon model snapshot > relay replay > cold restore +precedence, replay-guarded so snapshot-embedded queries never answer, then +`POST_REPLAY_REATTACH_RESET` hygiene, fit, and PTY resize. The watcher is +disposed before the pane handlers re-register. + +## Invariants + +1. PTY reads never stop; parking only changes renderer-side view lifetime. +2. Bell, agent-completion, title, and PR-link side effects keep working while + parked (watcher parity tests). +3. Reveal shows model-correct output (visual gates: hidden TUI restore, long + table, rendering golden) and accepts input immediately. +4. Sleep/wake, pane close, and PTY restart while parked must not leak watchers + or strand parked state. +5. Memory: parked tabs hold no xterm buffers; renderer memory scales with + visible panes. + +## Relation to later phases (all shipped) + +Side-effect authority in main (Phase 3) replaced the watcher's byte parsing +with the `pty:sideEffect` fact consumer; the hidden-delivery gate (Phase 4) +stops hidden byte delivery in main, moving the parked 2031 reply from the +byte sidecar to the `2031-subscribe` fact; the model query responder +(Phase 5) answers queries in hidden-dropped chunks. The watcher's byte-parser +mode survives only behind the kill switches. Parking still excludes +remote-runtime and SSH PTYs (no local snapshot to restore from); the watcher +would return as a byte parser only if remote-runtime tabs — whose bytes never +transit local main — ever became parkable. diff --git a/docs/reference/terminal-model-view-contract.md b/docs/reference/terminal-model-view-contract.md new file mode 100644 index 00000000000..79fb55575f3 --- /dev/null +++ b/docs/reference/terminal-model-view-contract.md @@ -0,0 +1,218 @@ +# Terminal Model/View Contract + +## Goal + +Terminal output should have one authoritative model path and many disposable +views. A renderer xterm is the fast interactive view, but it must not be the +only place hidden, remote, mobile, SSH, or CLI-visible terminal state exists. + +This contract defines the boundary the shipped terminal stack implements — and +that future terminal work must preserve — without changing the query-response +behavior that real shells and TUIs depend on. See [Architecture +Status](#architecture-status) for the shipped phases. + +## Terms + +- **PTY stream:** Ordered bytes read from a local PTY, daemon PTY, SSH relay PTY, + or remote runtime PTY. +- **Terminal model:** Main/runtime-owned state derived from PTY bytes. Today this + is mostly the headless emulator plus retained read transcript state. +- **Terminal view:** A renderer xterm, mobile subscriber, remote desktop + subscriber, or CLI read page consuming model state and live output. +- **Snapshot:** A bounded model serialization that can restore a view without + replaying an unbounded byte log. +- **Transcript:** The retained output contract for `orca terminal read`; it is + line/cursor oriented and distinct from a screen snapshot. + +## Non-Negotiable Invariants + +1. PTY reads do not stop to protect renderer performance. Backpressure may bound + delivery to views, but terminal state, notifications, titles, and agent + status keep advancing from the PTY stream. +2. Active visible terminal input/output stays on the lowest-latency path. Bulk + hidden or background output must not delay keystroke-sized foreground redraws. +3. Hidden views do not own unbounded output memory. Main's hidden-delivery + gate drops renderer-bound bytes for hidden-marked PTYs after model + ingestion and emits an out-of-band restore marker + (`pty:modelRestoreNeeded`) so the view restores from the model on reveal. + With the gate's kill switches off, hidden bytes ride a bounded renderer + queue whose overflow latches the same model restore. +4. Returning to a hidden or slept terminal must show model-correct output. A + stale or replaced view may be cleared and replayed from a snapshot, but it + must not show a warning fallback when model recovery is available. +5. Snapshots and live bytes have ordering metadata. A view restore must not + duplicate bytes already included in the snapshot or drop bytes that arrived + after it. Main buffer snapshots report the pending-delivery start sequence + (`pendingDeliveryStartSeq`) so the renderer reconciles live chunks racing a + restore without misreading foreign sequence domains as duplicates. +6. Terminal query authority is singular and structural: the party that + writes a chunk into a live terminal answers its queries. Visible renderer + and remote views keep xterm authority. Chunks dropped by the + hidden-delivery gate are answered exactly once by the main model + responder, from runtime-emulator state plus renderer-pushed view + attributes. Replayed, seeded, or snapshot bytes are answered by no one. + The daemon emulator never answers. (Amended by Phase 5 — see + [`terminal-query-authority.md`](./terminal-query-authority.md).) +7. The transcript contract stays separate from screen restore. `orca terminal + read` must preserve bounded previews, cursor pagination, partial-line rules, + truncation flags, and total counts even if view snapshots change shape. +8. Local, daemon, SSH, remote runtime, mobile, and CLI paths must either satisfy + the same model/view contract or explicitly report that model recovery is + unavailable. + +## Current Owners + +| Responsibility | Current owner | +| --- | --- | +| PTY byte source and local/SSH delivery | `src/main/ipc/pty.ts` | +| Hidden-delivery gate (hidden marks, delivery interest, drop accounting, restore markers) | `src/main/ipc/pty-hidden-delivery-gate.ts`, drop sites in `src/main/ipc/pty.ts` and `src/main/ssh/ssh-relay-session.ts` | +| Side-effect parsing and the `pty:sideEffect` facts channel | `src/shared/terminal-output-side-effects.ts` driven from `OrcaRuntimeService.onPtyData`; renderer policy in `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts` | +| Model query responder and view-attribute bridge | `src/main/runtime/terminal-model-query-authority.ts`, `src/main/daemon/terminal-view-attribute-responder.ts`, `src/main/runtime/terminal-view-attribute-store.ts` | +| Hidden view parking policy and parked watcher | `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts`, `parked-terminal-byte-watcher.ts` | +| Daemon PTY state and headless snapshots | `src/main/daemon/headless-emulator.ts` | +| Runtime headless state, retained reads, mobile/session tabs | `src/main/runtime/orca-runtime.ts` | +| Remote terminal subscribe/multiplex/ACK semantics | `src/main/runtime/rpc/methods/terminal.ts` | +| Renderer xterm view and hidden restore behavior | `src/renderer/src/components/terminal-pane/pty-connection.ts` | +| Remote desktop runtime xterm transport | `src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts` | + +## Snapshot Contract + +A model snapshot must include: + +- terminal dimensions used to produce the snapshot; +- enough ANSI state to rehydrate xterm before snapshot content; +- bounded screen and scrollback content; +- title and cwd metadata when known; +- source metadata that distinguishes headless/model snapshots from renderer + fallback snapshots; +- monotonic ordering metadata for live-output reconciliation when available. + +A snapshot must not: + +- include unbounded transcript history; +- answer terminal queries while replaying into the model; +- overwrite newer live view output with older model output; +- hide that recovery was unavailable for a PTY surface. + +## View Contract + +A renderer or remote view may: + +- write active visible output immediately; +- budget visible inactive output; +- stop receiving hidden output entirely while main's hidden-delivery gate owns + the bytes (model restore on reveal); +- request fresh snapshots for restore, mobile subscription, or explicit remote + snapshot recovery. + +A view must: + +- keep live-output buffers bounded while a snapshot is in flight; +- apply generation or sequence checks before replaying a snapshot; +- refresh/repaint after replay when xterm/WebGL needs an explicit paint; +- keep side effects such as title, bell, cwd, and agent status flowing from the + PTY/model path (the `pty:sideEffect` facts channel) even while renderer byte + delivery is budgeted, gated, or parked. + +## Transcript Contract + +The retained read transcript is not a screen dump. It must preserve: + +- uncursored bounded latest preview behavior; +- cursor reads over completed retained lines; +- `oldestCursor`, `nextCursor`, `latestCursor`, and `returnedLineCount`; +- partial-line duplication rules; +- `truncated`, `limited`, and total count metadata; +- bounded memory for long partial lines and large output bursts. + +Snapshot optimizations must be tested against this transcript contract instead +of assuming xterm scrollback serialization can replace it. + +## Required Contract Tests + +Before moving more runtime behavior behind the model/view boundary, add or +extend tests that prove: + +- headless snapshots rehydrate rich alternate-screen TUI state; +- the daemon emulator never answers DA, DSR, OSC 11, or theme-sensitive + queries (the `session.test.ts` pins are permanent); +- the main runtime responder answers queries only from live chunks the + hidden-delivery gate dropped — never delivered, replayed, seeded, or + remote-subscribed chunks; +- hidden renderer overflow restores from model state without duplicate live + output; +- sleep/wake and worktree revisit restore from model-correct state; +- SSH-backed PTYs follow the same snapshot and ordering semantics as local PTYs; +- remote runtime multiplex output remains ACK bounded and can request recovery + snapshots; +- mobile subscribers receive bounded snapshots without unbounded pending live + output; +- retained terminal reads remain pageable and bounded after large output. + +Current coverage is spread across: + +- `src/main/daemon/headless-emulator.test.ts` +- `src/main/daemon/session.test.ts` +- `src/main/ipc/pty.test.ts` (hidden-gate drops, restore markers, + `pendingDeliveryStartSeq`) +- `src/main/ipc/pty-hidden-delivery-gate.test.ts` +- `src/main/runtime/mobile-subscribe-integration.test.ts` +- `src/main/runtime/rpc/terminal-subscribe-buffer.test.ts` +- `src/main/runtime/rpc/terminal-multiplex.test.ts` +- `src/main/runtime/orca-runtime.test.ts` +- `src/main/runtime/terminal-query-responder.test.ts` +- `src/shared/terminal-output-side-effects.test.ts` +- `src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts` +- `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts` +- `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts` +- `src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts` +- `src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts` +- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts` +- `tests/e2e/terminal-hidden-view-parking.spec.ts` +- `tests/e2e/terminal-parked-memory.spec.ts` +- `tests/e2e/terminal-sleep-wake-restore.spec.ts` +- `tests/e2e/terminal-output-scheduler.spec.ts` +- `tests/e2e/artificial-opencode-terminal-load.spec.ts` + +## Architecture Status + +All six phases of the terminal model/view architecture are shipped; the kill +switches noted in parentheses default on: + +1. **Hidden view parking** — "Park hidden terminal views behind a byte + watcher": hidden terminal tabs unmount their xterm after a cold-park + hysteresis; a pane-less watcher keeps bell/title/agent/PR side effects + alive while parked (`terminalHiddenViewParking`). See + [`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md). +2. **Parked memory benchmarks** — "Benchmark parked hidden terminal memory": + renderer heap and live-terminal counts gate parking in the perf suite + (`tests/e2e/terminal-parked-memory.spec.ts`). +3. **Side-effect authority in main** — "Track terminal titles in main with + all-titles ordering", "Move terminal side-effect authority to a main facts + channel", "Complete terminal side-effect facts coverage", "Finish terminal + side-effect authority migration": every local/daemon/SSH PTY byte is + side-effect-parsed once in main and delivered as `pty:sideEffect` facts + (`terminalMainSideEffectAuthority`). See + [`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md). +4. **Hidden delivery gate** — "Gate PTY delivery to hidden terminal views": + main drops renderer-bound bytes for hidden-marked PTYs after model + ingestion; delivery-interest registrations exempt sidecar byte consumers, + out-of-band restore markers latch model restore, and + `pendingDeliveryStartSeq` reconciles live output racing a restore + (`terminalHiddenDeliveryGate`). +5. **Model query authority** — "Answer hidden terminal queries from the + model", "Bridge renderer view attributes to the model responder", "Align + query authority contract and spawn-time ownership": hidden-dropped queries + are answered by the runtime emulator plus renderer-pushed view attributes, + and hidden-at-spawn PTYs are marked before byte one + (`terminalModelQueryAuthority`). See + [`terminal-query-authority.md`](./terminal-query-authority.md). +6. **Skip grammar deletion** — "Delete the hidden renderer skip grammar": the + renderer's per-chunk hidden-skip eligibility grammar and the 10s codex + startup query window are deleted; the kill-switch-off fallback is the + bounded background queue with overflow-latched model restore. + +Treat every hidden/slept/revisited TUI glitch as a contract failure, not as a +local repaint quirk. Renderer fallback paths retire only when their kill +switches do, and only after the equivalent model path has platform and TUI +golden coverage. diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md new file mode 100644 index 00000000000..e05b5768836 --- /dev/null +++ b/docs/reference/terminal-query-authority.md @@ -0,0 +1,326 @@ +# Terminal Query Authority + +Status: Shipped — Phase 5 of the terminal model/view architecture, kill +switch `terminalModelQueryAuthority` (default on). Builds on +[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) (this +phase **amends invariant 6**), +[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md) +(Phase 3), and the Phase-4 hidden-delivery gate +(`src/main/ipc/pty-hidden-delivery-gate.ts`). + +## Problem + +Phase 4 drops renderer-bound bytes for hidden-gated PTYs after model ingestion +(`src/main/ipc/pty.ts:1426,1515`, `src/main/ssh/ssh-relay-session.ts:931`). +Queries embedded in dropped bytes get no reply: DA1 (ConPTY 1.22+ blocks +waiting for it — `terminal-conpty-device-attributes.ts:22`), CPR probes hang +TUIs, OSC 10/11 leaves `claude /theme` blind while hidden. The pre-Phase-4 +hidden skip latch had the same hole (only mode 2031 and the 10s codex startup +window answered), so this is not a regression — it is the long-standing gap +this phase closes. + +Contract invariant 6 ("the model must never answer queries") was written +against a real bug: the daemon emulator replying ahead of the renderer with +default-xterm values (the OSC-11 default-black-background race, +`headless-emulator.ts:86-97`, pinned by `session.test.ts:163-190`). The danger +was never "the model answers" — it was **two answerers for the same bytes**, +one of them with wrong values. Phase 5 keeps the singularity and fixes the +values. + +## Decision: the delivery decision is the reply decision + +Main answers a query **iff main dropped the chunk that carried it**. The same +per-chunk hidden-gate predicate (`shouldDropHiddenRendererPtyData`) that +decides renderer delivery decides reply ownership, evaluated once, +synchronously, at ingestion: + +- Visible/unmarked PTY → chunk delivered → renderer xterm auto-replies via + `Terminal.onData` → `transport.sendInput`, unchanged. +- Hidden-marked, no delivery interest → chunk dropped → main answers from the + runtime headless emulator, via the provider input path (`provider.write`, + same path as `pty:write`; daemon shell-ready write gating and the SSH relay + write apply unchanged). +- Replayed/seeded/snapshot bytes → answered by no one (replay guards on both + sides). + +This is structurally exactly-one-responder: a chunk is delivered or dropped, +never both, and each side only answers bytes it actually parsed live. The +mark/unmark ordering, unhide-before-restore, and restore-marker IPC all exist +from Phase 4 and are reused, not duplicated. + +Rejected alternatives: + +- **Fact-based renderer replies per query class** (the mode-2031 pattern + generalized): needs a main-side detection grammar per query, a fact round + trip per reply, and the renderer cannot answer CPR/DECRPM anyway — the + emulator is the only state for a hidden pane. The 2031 fact stays because it + is subscription registration, not a state query. +- **Emulator always answers**: re-creates the OSC-11 double-reply race for + visible panes. Never. + +## Mechanism: forwarded emulator onData, not a new grammar + +`HeadlessEmulator` has `onData` wiring behind a per-write capture flag. +For static and model-state queries, xterm core **is** the query grammar: the +runtime emulator runs the same xterm version with equivalent options as the +renderer pane, so main's reply set equals the visible renderer's by +construction — verified empirically against the bundled headless build: +DA1/DA2, DSR 5n, CPR, DECRPM (including unknown-mode `0`), DECRQSS (including +DECSCUSR from cursor options), XTVERSION, kitty `CSI ? u` all reply; XTWINOPS +(`windowOptions` stays default-off) and XTGETTCAP stay silent, matching +visible behavior today. The headless build has **no theme service**: OSC +4/10/11/12 queries and DSR ?996n return nothing even with the `theme` option +set, so the view-attribute class is answered by responder-registered parser +handlers instead (below) — never by core defaults. + +Forwarding predicate, captured per chunk in `OrcaRuntimeService.onPtyData` and +attached to the emulator `writeChain` link (the mark can flip between +ingestion and an async write; the decision must not be re-read at reply time): + +1. gate enabled (`terminalMainSideEffectAuthority` and + `terminalHiddenDeliveryGate` both on) AND new kill switch + `terminalModelQueryAuthority !== false`; +2. the chunk was hidden-dropped for this PTY (`shouldDropHiddenRendererPtyData` + — same module state, same tick as the drop sites); +3. the write is live PTY data — never `seedHeadlessTerminal`, + `maybeHydrateHeadlessFromRenderer`, option pushes, or any snapshot replay + (main-side replay guard, mirror of the renderer's `replay-guard.ts`); +4. no remote view subscriber is attached to the PTY (runtime terminal-RPC + subscriber records / `mobileSubscribers`): a mobile/web/remote-desktop + xterm receiving the multiplexed stream answers with view authority, exactly + like a visible local pane. Legacy JSON `terminal.subscribe` streams **do** + register as view subscribers and suppress, even when the consumer is a + read-only watcher — deliberately conservative, because the stream may feed + an older live xterm view and a withheld reply (the pre-Phase-5 status quo) + is strictly safer than a double reply. Consumers that never register a + stream (CLI `terminal.read`, automation observers) do not suppress — they + also do not answer; that bounded no-reply case matches today's behavior. + +Everything the emulator emits outside a forwarding window is discarded, which +also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes +triggered by option mutations). + +## Reply classes + +| Class | Queries | Answer source | +| --- | --- | --- | +| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state | +| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative | +| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** | +| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options | +| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden | +| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard | + +### View-attribute bridge + +Renderer→main push, `pty:terminalViewAttributes` — one global snapshot, +not per-PTY: the composed terminal `ITheme` (from +`applyTerminalAppearance`, `terminal-appearance.ts`), +`terminalCursorStyle`, `terminalCursorBlink`, and the resolved color-scheme +mode (`resolveTerminalColorSchemeMode` — the same source as the existing +hidden 2031 reply). Pushed on renderer startup and on every theme/settings +apply. + +Main consumes it two ways: + +- `cursorStyle`/`cursorBlink` are applied to every runtime emulator's options + inside the replay guard; xterm core then answers DECRQSS DECSCUSR and + DECRQM 12 with renderer-true values (verified working headless). +- Palette and color-scheme replies come from responder-registered parser + handlers on the emulator (`registerOscHandler` 4/10/11/12, + `registerCsiHandler` for DSR ?996n), because the headless core cannot + answer them. The OSC handlers see SET payloads too, so runtime OSC + 4/10/11/12 mutations (and 104/110/111/112 resets) from the byte stream are + tracked per PTY and layered over the pushed base palette — matching what + the renderer's theme service reports for a visible pane. + +Staleness rules: replies use the last push; a theme flip is stale for at most +one IPC hop (subscribed TUIs are corrected by the 2031/997 flip push). +**Before the first push main answers no view-attribute query** — a fabricated +default would resurrect the default-black OSC-11 bug; silence is the +documented hidden status quo. + +### Kitty keyboard flags + +`vtExtensions.kittyKeyboard: true` is enabled in `HeadlessEmulator`, matching +`buildDefaultTerminalOptions` (`pane-terminal-options.ts:50`). Risk is low: +for the write-only daemon use, keyboard state never alters serialization; the +change only makes the emulator parse `CSI =/>/< u` pushes instead of ignoring +them, and lets the responder answer `CSI ? u` with the flags the hidden app +actually pushed. Snapshot parity: add `kittyKeyboardFlags` to `TerminalModes` +for emulator re-seed parity only. `rehydrateSequences` must **not** push kitty +flags into a renderer xterm — `POST_REPLAY_REATTACH_RESET`'s deliberate kitty +reset (stale CSI-u Ctrl+C hazard, `terminal-replay-cursor-state.test.ts`) +stays authoritative. Slice 3 wires the re-seed consumer: the daemon +warm-reattach snapshot threads `modes.kittyKeyboardFlags` through the spawn +result into `seedHeadlessTerminal`, which applies them to the fresh runtime +emulator via its own `CSI = flags ; 1 u` parse (outside any forwarding +window), so hidden `CSI ? u` reports the flags the hidden app actually +pushed. Paths without a snapshot (cold restore spawns a fresh shell) answer +`?0u`; protocol-conformant programs re-push. + +### ConPTY DA1 variant + +The provider kind is known main-side: mirror `isLocalNativeWindowsPty` +(`windows-pty-compatibility.ts:59`) from the spawn record (local/daemon +provider, `win32`, not WSL). For such PTYs register a CSI `c` override on the +emulator parser (the main-side twin of +`installConptyDeviceAttributesHandler`) replying `CSI ?61;4c`, still gated by +the forwarding predicate. The override is installed at emulator creation and +retrofitted when the spawn mark lands (daemon stream data can create the +emulator before the awaited spawn response marks the PTY). ConPTY blocking on +a missing DA1 is a spawn-time hazard; the hidden-at-spawn loss window is +closed by the slice-3 `initiallyHidden` spawn flag (races section). + +## Suppression: when main never replies + +- Visible or unmarked PTY (chunk was delivered). +- Renderer delivery interest registered (chunk was delivered to a sidecar). +- Remote-runtime (`remote:`) PTYs — never markable + (`isHiddenDeliveryGateManagedPty`), bytes never transit local main. +- Remote view subscriber attached (mobile/web/remote desktop owns replies). +- Seed/hydration/snapshot writes into the emulator, and option pushes. +- Kill switches off — no marks exist, and `terminalModelQueryAuthority` is an + independent off switch for the responder alone. +- The **daemon** emulator: never, under any setting. The responder lives in + main's runtime only; `session.test.ts:163-190` stays pinned verbatim. + +## Transition races + +Worst cases, per direction: + +- **visible→hidden**: chunks delivered between the visibility flip and the + mark landing in main are hidden-skipped by the renderer write path without + query scanning. No reply, no duplicate — identical to the pre-Phase-4 hidden + skip behavior, bounded by one renderer→main IPC hop. After the mark lands, + main answers everything it drops. +- **hidden→visible**: unmark consumes the drop latch and emits the restore + marker; the snapshot replay is replay-guarded, so queries main already + answered are never re-answered from the snapshot; post-unmark live chunks + are answered by xterm once (restore-queued live chunks reply late, not + twice). +- **Split queries across the drop/deliver boundary**: neither parser saw the + whole sequence → no reply; the restore marker resets renderer cross-chunk + state and replay hygiene resets the parser. At-most-once holds. + +Safe-side rule per class: duplicates are structurally impossible (one decision +point per chunk); where the race costs anything it costs a missing reply. +That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or +tolerate silence, as they did for every hidden pane before this phase). The +one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible +pane answers it from the renderer xterm. A PTY spawned hidden previously had +no answerer until the renderer's hidden mark landed in main (one IPC hop +after spawn). Slice 3 closes that window with the `initiallyHidden` +spawn-record flag: the renderer declares hidden-at-spawn on `pty:spawn` +(never for remote-runtime transports), and main marks the PTY hidden before +the first byte — pre-spawn for +daemon-host sessions whose id is minted up front, immediately after +`provider.spawn` resolves otherwise — so the gate and responder own queries +from byte one. The pane's first visibility sync then re-marks or unmarks +through the existing Phase-4 machinery (unmark emits the restore marker for +any spawn-window drops). + +## Invariants + +1. Exactly one party may answer any query, chosen by the chunk's delivery + decision: delivered → the consuming live view's xterm; dropped → main's + model responder; replayed/seeded → no one. The decision is captured once, + synchronously, at ingestion. +2. Main answers only from live PTY bytes parsed by the runtime emulator — + never from snapshot, seed, hydration, or option-push writes. +3. View-attribute answers are renderer-true or absent: no reply is ever + fabricated from emulator defaults (the OSC-11 lesson). +4. The daemon emulator stays write-only; daemon subprocess query writes stay + zero (`session.test.ts` pins are permanent). +5. Reply parity is structural for static and model-state classes: same xterm + core, equivalent options, no hand-rolled grammar — the only overrides are + the documented ConPTY DA1 variant and the view-attribute parser handlers + the headless core cannot serve. +6. Remote views keep view authority; main yields whenever a remote view + subscriber is attached. + +**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is +replaced by: + +> 6. Terminal query authority is singular and structural: the party that +> writes a chunk into a live terminal answers its queries. Visible renderer +> and remote views keep xterm authority. Chunks dropped by the +> hidden-delivery gate are answered exactly once by the main model +> responder, from runtime-emulator state plus renderer-pushed view +> attributes. Replayed, seeded, or snapshot bytes are answered by no one. +> The daemon emulator never answers. + +The contract's test bullet "headless tracking does not answer DA, DSR, OSC 11, +or theme-sensitive queries" splits into: daemon emulator never answers +(unchanged pins) / runtime responder answers only hidden-dropped chunks. The +side-effect authority matrix row "DECSET 2031 reply — query authority stays +with the view (contract invariant 6)" gains a pointer here; its reply path is +otherwise untouched in this phase. + +## Test strategy + +- Responder unit tests beside `orca-runtime.test.ts`: marked vs unmarked vs + interest-suppressed; each reply class; seed/hydrate silence; remote- + subscriber suppression; ConPTY DA1 variant; kill-switch off; mark flip + between ingestion and async emulator write (captured decision wins). +- Parity harness: shared query byte fixtures through a renderer-configured + xterm (onData capture) and through the responder; assert byte-identical + replies for static + model-state classes, and for view-attribute classes + after an attribute push. +- `session.test.ts:163-190`: assertions stay; the comment is updated to name + the main responder (not "the renderer") as the hidden answerer. +- E2E: hidden `claude /theme` reports the configured theme; hidden TUI + blocked on CPR/DA unblocks while gated; reveal shows no stray reply + fragments (`?1;2c`, `rgb:` …) on the prompt; Windows ConPTY golden and + `terminal-hidden-view-parking.spec.ts` stay green. + +## Cut-offs (shipped as three stacked slices) + +1. **Responder core.** Emulator onData wiring + per-write capture + main + replay guard; kitty flag enable (+ `TerminalModes.kittyKeyboardFlags`); + static + model-state classes; ConPTY DA1 override; remote-subscriber + suppression; `terminalModelQueryAuthority` switch; unit + parity tests. + Main-only — no renderer change. Ships the DA1/CPR/DECRPM unblock. +2. **View-attribute bridge.** `pty:terminalViewAttributes` push, cursor + option application under the guard, responder OSC/DSR parser handlers with + per-PTY palette-mutation tracking, silent-until-push rule, `/theme` e2e. +3. **Contract alignment.** Invariant-6 amendment in the contract doc, test + bullet split, `session.test.ts` comment, side-effect matrix pointer, and + the Phase 6 prerequisites below recorded as accepted. + +## Phase 6 (delete skip grammar + startup window): prerequisites from this design + +Phase 6 is shipped: the renderer hidden-skip eligibility grammar and the 10s +codex startup renderer-query window are deleted. Kill-switch-off hidden panes +fall back to the pre-grammar path — hidden bytes ride the bounded background +scheduler queue; overflow latches the model-snapshot restore — and never run +a per-chunk content scan. + +Accepted and shipped in slice 3 (except where noted): + +- **Mark-before-first-byte** (shipped): panes spawned without a visible view + are hidden-marked at spawn via the `initiallyHidden` flag on `pty:spawn` + (spawn-record flag, not a renderer round trip) so startup queries — + including ConPTY's blocking DA1 — are main-owned from byte zero. Phase 6 + removed the codex exclusion with the window: codex spawns are main-owned + from byte zero too, the responder answering their startup probes. +- **Attributes before spawn** (shipped): the renderer pushes composed view + attributes once at app start (right after settings load, before terminal + reconnect/spawn), so spawn-time view-attribute queries no longer fall into + the silent-until-push rule. Per-pane appearance applies keep re-publishing + through the same deduped publisher. +- **Daemon shell-ready write gating** (verified): responder replies through + `ptyController.write` → daemon `Session.write` are QUEUED pre-ready, never + dropped, and the queue flushes at the shell-ready marker or the 15s + `SHELL_READY_TIMEOUT_MS` bound (`session.ts`). The codex window was removed + with hosted ConPTY golden coverage, unit DA1 parity, and the kill switches + as the safety net; explicit spawn-time e2e on Windows daemon PTYs remains + worth adding. +- With the skip grammar deleted, every chunk is either written to a live + xterm or dropped — the delivered-but-skipped no-reply gap disappears and + the only remaining loss window is the mark IPC race. +- **2031 consolidation** (optional follow-up): move the subscription registry + into the responder (the headless core cannot serve 997 pushes any more than + it can ?996n) and push 997 flips from the attribute cache, retiring the + `2031-subscribe` fact reply, the parked responder, and the parked-tab + theme-flip gap. diff --git a/docs/reference/terminal-side-effect-authority.md b/docs/reference/terminal-side-effect-authority.md new file mode 100644 index 00000000000..852a607d5bf --- /dev/null +++ b/docs/reference/terminal-side-effect-authority.md @@ -0,0 +1,252 @@ +# Terminal Side-Effect Authority + +Status: Shipped — Phase 3 of the terminal model/view architecture, kill switch +`terminalMainSideEffectAuthority` (default on). Builds on +[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) and +[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) (Phase 1). + +## Problem + +Main parses every local/daemon/SSH PTY byte before renderer delivery +(`OrcaRuntimeService.onPtyData` in `src/main/runtime/orca-runtime.ts`: +side-effect tracker, OSC 9999 agent status, headless emulator, tails, URL +watchers; SSH feeds the same path from `wireUpPtyEvents` in +`src/main/ssh/ssh-relay-session.ts`). Before this phase, the side effects +users see — bell unread/notifications, title transitions, agent-complete +notifications, command lifecycle, PR links — were derived a second time by +renderer byte parsers. That duplication forced Phase 1's watcher to parse +bytes, forced main to fabricate synthetic OSC title frames over `pty:data` +just so renderer parsers could see them, and blocked Phase 4 from ever +stopping hidden byte delivery. Phase 3 made main the side-effect parser for +every PTY whose bytes transit local main; the renderer byte parsers +(`createPtyOutputProcessor` in `pty-transport.ts`, the parked watcher's byte +mode) survive only for remote-runtime PTYs and the kill-switch-off fallback. + +## Authority Matrix + +"Main" means parsed once in `onPtyData` and delivered as derived facts. +Remote-runtime PTYs (`remote:`) never transit local main; the renderer +(`remote-runtime-pty-transport.ts:74`) stays their parser permanently. + +| Side effect | local-daemon | SSH | remote-runtime | +| --- | --- | --- | --- | +| OSC 9999 agent status | main (parsed in `onPtyData`, emitted as `agentStatus:set`) | main | renderer (`shouldOwnAgentStatusInRenderer`, `pty-connection.ts`) | +| OSC 0/1/2 titles + working/idle/exited tracker + 3s stale-title timer | main | main | renderer | +| BEL attention (OSC-aware stateful detector) | main | main | renderer | +| OSC 133;D command-finished exit code | main | main | renderer | +| GitHub PR-link scan | main | main | renderer | +| Command Code output scrape | main (per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main | renderer | +| DECSET 2031 color-scheme reply | renderer view/watcher — the 2031 fact reply path is untouched by Phase 5; general query authority is now per-chunk structural ownership, see [`terminal-query-authority.md`](./terminal-query-authority.md) (contract invariant 6 as amended) | same | renderer | +| DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer | + +## Main-Side Tracker + +- The side-effect core shared with the renderer processor lives in + `src/shared/terminal-output-side-effects.ts`: all-titles ordering via + `extractAllOscTitles` (coalesced working→idle transitions are why last-title + is insufficient — issue #1083), `normalizeTerminalTitle`, the literal + `cursor agent` title drop (`CURSOR_NATIVE_TITLE_LOWER`, + `src/shared/agent-detection.ts`), the `createAgentStatusTracker` + transitions, the stale-working-title 3s timer + (`STALE_WORKING_TITLE_TIMEOUT_MS`), and the stateful BEL detector + (`src/shared/terminal-bell-detector.ts`). +- One tracker per PTY on `OrcaRuntimeService`, lazily created like + `agentStatusOscProcessorsByPtyId`; disposed in `onPtyExit` (cancels the + stale-title timer). +- It replaced the chunk-level last-title extraction in `onPtyData`: titles + feed in byte order, so `lastOscTitle`/`lastAgentStatus`, tui-idle waiters, + and pending-message delivery see intermediate transitions instead of only + the chunk's last title. PTY/leaf records keep the **raw** last title + (worktree `ps` and mobile tab titles expect raw); emitted facts carry + `(normalizedTitle, rawTitle)` like `onTitleChange`. +- No deferred drain in main — the renderer's setTimeout(0) batching + (`sideEffectDrainTimer`, `pty-transport.ts`) protects xterm paint, which + does not exist in main. Main applies synchronously and batches the IPC per + flush. +- The stats `AgentDetector` (`src/main/stats/agent-detector.ts`) keeps its own + last-title scan, untouched: synthetic titles must never reach it. + +## Event Transport: `pty:sideEffect` + +One batched main→renderer channel (`window.api.pty.onSideEffect`, +`src/preload/index.ts`). It is **not** routed through the pty dispatcher: +the renderer fact-consumer registry +(`terminal-side-effect-facts-handler.ts`) subscribes directly via +`window.api.pty.onSideEffect` — one channel subscription per renderer, with +exactly one registered fact consumer per PTY. Events are **facts, not +decisions**: `title`, `bell`, `agent-working`, `agent-idle` (with title), +`agent-exited`, `command-finished` (exit code), `pr-link`. Each carries +`ptyId`, main-known attribution (worktreeId/tabId/paneKey from runtime leaf +records, same resolution as `emitTerminalAgentStatusEvents`), and the PTY +`outputSequence`. + +Ordering rules: + +1. Per-PTY in-order; facts from one chunk are emitted in byte order (status + payloads, then titles in sequence, then bell — the renderer drain's order). +2. Deliberately **not** synchronized with `pty:data`: side effects must keep + advancing while renderer delivery is ACK-gated (contract invariant 1). A + completion title may reach the store before the visible xterm paints the + final output; that is acceptable — attention/title state is out-of-band UI + state, and today's renderer drain already decouples by many batches under + timer throttling. +3. No attention replay: facts emitted while no renderer is subscribed are + dropped. On transport attach/park-handoff the renderer pulls a title-only + snapshot (`pty:sideEffectSnapshot`) marked `replay: true` — this reproduces + the eager-buffer behavior where replay restores titles but is barred from + bells/completions (`suppressAttentionEvents`, `pty-transport.ts`). The + store handler ignores a replay title older (by `outputSequence`) than the + last live title fact it applied. + +## Renderer Store Handler (policy stays in the renderer) + +Notification semantics, all preserved across the authority flip: + +- BEL marks worktree+tab unread unconditionally — including the focused pane + (`onBell`, `pty-connection.ts`); pane unread only behind + `experimentalTerminalAttention`; keydown clears unread + (`onTerminalKeyDown`, `pty-connection.ts`). +- BEL's OS notification is delayed 250 ms and yields to a pending + agent-task-complete (`scheduleTerminalBellNotification`, + `pty-connection.ts`). +- working→idle starts the Claude cache timer (null settings = not hydrated, + treat enabled) and schedules completion with 250 ms grace + 1500 ms max + wait + detail-wait store subscription + (`AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS` / + `AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS`, + `agent-task-complete-policy.ts`). +- Completion unread is suppressed only for the exact visible foreground pane + (`isVisibleForegroundPaneKey`, `use-notification-dispatch.ts`); BEL unread + has no such check. +- Dispatch-time liveness/staleness guards + (`dispatchTerminalNotification`, `use-notification-dispatch.ts`) and main's + 5 s per-worktree cooldown (`NOTIFICATION_COOLDOWN_MS`, + `src/main/ipc/notifications.ts`) remain the final gates. + +These need live renderer store state (PTY/layout maps, pane visibility, +settings, `agentStatusByPaneKey`, repo labels), so they stay in the renderer: +the pane-independent per-paneKey handler module +(`terminal-side-effect-facts-handler.ts`) consumes `pty:sideEffect` and +subsumes both `pty-connection.ts`'s callbacks and the parked watcher's +callback block (`sideEffectCallbacks`, `parked-terminal-byte-watcher.ts`) — +one policy path whether the tab is mounted, hidden, or parked. Main holds +**no** notification timers; only the stale-title timer (parser state) lives +in main. + +## Synthetic Frame Reroute + +`driveSyntheticTitleFromHook` and the spinner tick (`sendSyntheticTitle`, +`src/main/index.ts`) feed `runtime.ingestSyntheticTitleFrame(ptyId, data)`, +so synthetic agent-title/BEL frames enter the per-PTY tracker directly — +**not** `onPtyData`, so emulator state, tails, transcripts, and stats never +see them. The decorative-frame visibility gating +(`shouldSendSyntheticTitleFrame`) stands. The legacy synthetic `pty:data` +copy survives only in kill-switch-off mode, where renderer parsers still +need the bytes. The visible xterm renders nothing from titles, but +`pane.terminal.onTitleChange` feeds `registerPtyTitleSource` +(`pty-connection.ts`) → renderer serialize-snapshot `lastTitle` (mobile +parity); main prefers its own tracked title over renderer snapshot +`lastTitle` in both serialize paths. Under main authority synthetic frames +no longer produce phantom ACKs for bytes main never metered (`ackPtyData`, +`pty-dispatcher.ts`). + +## Migration Switch and Double-Fire Prevention + +Authority is structural per PTY kind — the predicate is "bytes transit local +main", exactly the `shouldOwnAgentStatusInRenderer` split +(`pty-connection.ts`). One renderer-consulted kill switch +(`settings.terminalMainSideEffectAuthority`, default on, mirroring +`terminalHiddenViewParking`): when on, IPC transports and the parked watcher +do not register byte parsers for local/SSH and the store handler consumes +`pty:sideEffect`; when off, renderer parsers register and `pty:sideEffect` +events are ignored. Main always parses and emits (its internal consumers need +the tracker regardless); main consults the same setting only to keep the +legacy synthetic-frame `pty:data` path alive while the switch is off. Exactly +one consumer per fact at any time — decided at transport/watcher creation, so +no per-chunk race. + +## Sidecar Consumers and Phase 4 + +Keep renderer byte access (input pacing / raw-output consumers, not side +effects): `agent-paste-draft.ts` (DECSET 2004 readiness), +`launch-agent-background-session.ts` (startup-injection pacing, onData +passthrough), `automation-session-observer.ts` (onData passthrough), and +`parked-terminal-mode2031-responder.ts` (DECSET 2031 theme replies for +parked tabs while the delivery gate is off). Their duplicated local OSC 9999 +store writes are gated off under main authority (the `onAgentStatus` +automation callbacks still fire; only the racing `setAgentStatus` store +writes drop). The Phase-4 hidden-delivery gate exempts PTYs with an active +`subscribeToPtyData` sidecar: registration is auto-surfaced to main as a +ref-counted delivery-interest signal (`pty-delivery-interest.ts`). With main +authoritative, the parked watcher is purely fact-driven: byte parsing exists +only in kill-switch-off mode, and the 2031 reply comes from the +`2031-subscribe` fact when the gate is on (the byte responder sidecar only +when it is off). The watcher file is deleted outright only when the kill +switch retires — it returns as a byte parser only if remote-runtime tabs +ever become parkable. + +## Invariants + +1. Every byte is side-effect-parsed exactly once, by exactly one authority, + chosen structurally per PTY kind. +2. Attention facts never replay: snapshot/eager/attach replays restore title + state only. +3. Notification policy (grace timers, yielding, suppression, dispatch guards) + lives with the renderer store; main emits facts with ordering metadata. +4. Side-effect facts keep flowing while renderer byte delivery is + backpressured, parked, or stopped by the hidden-delivery gate. +5. Synthetic agent frames feed the model tracker, never the emulator, tails, + transcripts, or stats. + +## Test Strategy + +- Parity harness (`terminal-title-tracker-parity.test.ts`): shared byte + fixtures (agent title cycles incl. coalesced chunks, BEL inside/spanning + OSC, CAN/SUB cancellation, cursor-agent literal, stale-title timeout under + fake timers, OSC 133;D, split PR URLs) run through the renderer + `createPtyOutputProcessor` and the main tracker; assert identical ordered + fact sequences. +- Unit: main tracker tests beside `orca-runtime.test.ts` (lastOscTitle + parity, tui-idle waiter transitions, synthetic ingestion); store-handler + tests reusing `parked-terminal-byte-watcher.test.ts` scenarios. +- Pinned tests that flip or retire: `pty-connection.test.ts` callback wiring, + `parked-terminal-byte-watcher.test.ts` (retires with the watcher); + `pty-transport*.test.ts` stay (processor remains for remote + kill switch). +- E2E gates that must stay green throughout: `terminal-attention.spec.ts`, + `droid-notification.spec.ts`, `terminal-hidden-view-parking.spec.ts`, + `terminal-parked-memory.spec.ts`; add main-authority bell/completion cases + (parked tab, focused-pane suppression, kill switch off). SSH parity is + exercised manually per the SSH test procedure before each slice ships. + +## Cut-Offs (shipped as four stacked slices) + +1. **Shared tracker in main.** Extract the processor core to shared, run the + per-PTY tracker in `onPtyData` replacing `extractLastOscTitle`, parity + tests. Main-internal consumers only; no IPC or renderer change. +2. **Authority flip.** `pty:sideEffect` channel, renderer store handler, + titles/bell/tracker authority to main for local+SSH behind the kill + switch; parked watcher stops byte parsing for those kinds. +3. **Inversion unwind.** Synthetic frames into the tracker, off `pty:data`; + OSC 133;D and PR-link facts; mobile `lastTitle` source preference. +4. **Long tail.** Command Code scrape to main, sidecar OSC 9999 dedup, parked + watcher shrunk to fact-driven mode (deletion waits on kill-switch + retirement), Phase 4 delivery-interest registration documented in the gate + design. + +## Open Items + +- **Daemon checkpoint `lastTitle` is write-only.** The daemon sleep/periodic + checkpoint (`daemon-pty-adapter.checkpointSessions` → daemon + `Session.getSnapshot`) persists the daemon emulator's `lastTitle`, which is + derived from real PTY bytes only — synthetic hook title frames never reach + the daemon process, so that field cannot carry hook-driven titles. Today no + restore path reads it back (`ColdRestoreInfo` drops it; reattach snapshots + surface only the ANSI payload), so there is nothing to fix. Main-side + consumers of the renderer serializer's `lastTitle` (mobile snapshot reads + and the headless hydration seed) prefer main's tracked title. If a future + consumer starts reading checkpoint `lastTitle`, it must route through the + same tracked-title preference. +- **Kill-switch retirement.** Once `terminalMainSideEffectAuthority` is + removed, the parked watcher's byte-parser mode, the renderer transport + parsers for local/SSH, and the legacy synthetic-frame `pty:data` copy all + become dead code and the watcher byte path can be deleted outright. diff --git a/docs/terminal-main-owned-state.md b/docs/terminal-main-owned-state.md index a9c3165e7cf..e3e7a125988 100644 --- a/docs/terminal-main-owned-state.md +++ b/docs/terminal-main-owned-state.md @@ -1,5 +1,9 @@ # Terminal Main-Owned State +This document covers the hidden-output recovery slice. The broader terminal +model/view boundary is defined in +[`reference/terminal-model-view-contract.md`](./reference/terminal-model-view-contract.md). + ## Problem Hidden and background terminal panes cannot rely on renderer memory as the only @@ -51,6 +55,13 @@ already reaches `OrcaRuntimeService.onPtyData` before renderer delivery for local, daemon, and SSH PTYs. That path keeps a headless xterm emulator updated and can serialize it. +Since the hidden-delivery gate shipped (`terminalHiddenDeliveryGate`, default +on — see the contract's Architecture Status), main drops hidden renderer-bound +bytes after model ingestion and emits an out-of-band restore marker, so a +gated hidden pane accumulates no renderer backlog at all. The overflow path +below is the fallback for kill-switch-off mode and for hidden PTYs with an +active delivery-interest sidecar. + The renderer scheduler keeps its 2 MB background cap. When the cap is exceeded: 1. The scheduler replaces the queued backlog with a small warning fallback. diff --git a/notes/garble-fuzz-divergences.md b/notes/garble-fuzz-divergences.md new file mode 100644 index 00000000000..dde77ac2450 --- /dev/null +++ b/notes/garble-fuzz-divergences.md @@ -0,0 +1,296 @@ +# Garble differential fuzz — divergence log + +Findings from the HeadlessEmulator-vs-renderer-twin differential fuzz +(`src/main/daemon/headless-emulator-fidelity.fuzz.test.ts`). Each divergence is +a case where restoring a hidden terminal from its main-side snapshot +(`serialize → replay`, exactly as `applyMainBufferSnapshot` does on reveal) +produces a screen that differs from an always-visible renderer terminal fed the +same bytes. Any such diff is a user-visible garble on reveal. + +## Method + +- Corpus: seeded agent-TUI byte streams (`buildAgentTuiStreamOps`), 3 pane + sizes, PTY-style random chunk splitting. +- Differential: production `HeadlessEmulator` snapshot replayed into a fresh + renderer-parity terminal, compared cell-by-cell (text, per-cell style, + cursor, modes, scrollback) against an always-visible renderer-parity twin. +- Parity confirmed: `createRendererParityTerminal` mirrors the renderer pane's + buffer-affecting options exactly — `scrollback: 5000`, `allowProposedApi`, + `vtExtensions.kittyKeyboard`, `Unicode11Addon`, Orca ZWJ provider (verified + against `buildDefaultTerminalOptions` in + `src/renderer/src/lib/pane-manager/pane-terminal-options.ts` and + `pane-dom-creation.ts`). Render-only options (`minimumContrastRatio`, + `drawBoldTextInBrightColors`, font, cursor, scrollbar) do not alter stored + cell attributes, so their omission is not a source of false diffs. + `windowsMode` is unset in both (matches renderer). Addon versions: + `@xterm/addon-serialize` / `@xterm/headless` / `@xterm/addon-unicode11` all + `*-beta.287` (headless `6.1.0-beta.287`). +- Scan: seeds 1..2000. Every divergence is either the known serialize-wrap bug + (predicate `bufferHasSerializeHostileWrappedRow`, tolerated + counted) or is + listed below. + +## Inventory + +| bug | found by | seeds | classification | +| --- | --- | --- | --- | +| A — serialize wrap null-cell | fidelity (suite 1) | 31, 157, 171, 207, 423, 426, 502, 801, 815, 826, 865, 881, 923, 977, 1004, 1119, 1142, 1238, 1241, 1318, 1351, 1374, 1532, 1601, 1657, 1728, 1770 (27 in 1..2000) | (a) real serialize bug, pre-documented + pinned — STILL OPEN | +| B — SGR bold loss (`1;22`) | fidelity (suite 1) | 435, 770, 1321 | (a) real serialize bug — FIXED by the addon patch (intensity-group SGR reorder, config/patches) | +| C — cursor off-by-one at right margin | fidelity (suite 1) | 454, 1696 | (a) real serialize bug — FIXED Orca-side (absolute-cursor epilogue, serializeWithAbsoluteCursor) | +| D — DECSC saved-cursor lost across reveal | reconciliation (suite 2) | seed 3 | (a) real snapshot limitation — FIXED (snapshot re-saves the DECSC register, readSavedCursorRegister) | +| E — snapshot boundary mid-escape-sequence | reconciliation (suite 2) | seed 4 (+~24% of corpus) | (a) real snapshot limitation — FIXED (pendingEscapeTailAnsi carried out-of-band, terminal-partial-escape-tail.ts) | + +Status update (fix/snapshot-decsc-midescape): B/C/D/E repros are UNSKIPPED and +their corpus tolerances removed — only Bug A remains tolerated + counted. Bug D +carries position only (saved SGR/charset are not re-established — the synthetic +ESC 7 saves the serializer's final pen). Bug E's pending tail is a separate +snapshot field written LAST by restorers because any later ESC (e.g. the +post-replay reset) would abort the dangling sequence; its bytes are already +counted by the snapshot seq, so tail-slice arithmetic is unchanged. + +All five bug classes are reproduced by dedicated minimal `test.skip` repros so +they cannot silently regress, AND each is tolerated + counted by its suite's +corpus loop so deep mode surfaces only genuinely NEW divergences: + +- Suite 1 (fidelity): Bug A via `bufferHasSerializeHostileWrappedRow`, Bug B via + `snapshotHasSelfCancellingBoldReset` (matches the `1;22` in the serialized + snapshot), Bug C via `isMarginWrapPendingCursorOffByOne` (cursor x-1 with a + full-width content row). Green at the default 300 and at `FUZZ_ITERATIONS=2000`. +- Suite 2 (reconciliation): Bug E via `prefixEndsMidSequence`. Bug D and the + Bug-C cursor cascade are kept out of the corpus by an append-only racing tail + (no DECSC/cursor motion) and pinned only as standalone repros. Green at the + default 200 and at `FUZZ_ITERATIONS=1000`. + +Each tolerance has a `< max(3, ITERATIONS*0.5)` guard so a predicate that starts +tripping on most seeds fails the suite instead of silently swallowing it. + +Seed 113 (called out in the handoff as a "DECSC/DECRC detour writing colored +text mid-line") does not diverge on the current harness. It is a `savedCursor +Detour` op seed; DECSC/DECRC SGR carry is correctly preserved by both the +emulator and the serializer here. It was most likely an earlier observation +folded into Bug C (the DECRC cases 1696 also involve `\x1b7`/`\x1b8`), or a +transient during harness construction. No live divergence at 113. + +--- + +## Bug A — SerializeAddon drops null cells at a soft-wrap boundary + +**Classification: (a) real `@xterm/addon-serialize` bug.** Pre-existing; found +and minimized by the prior agent, pinned by two `test.skip` repros in the fuzz +suite (V1 seed 31, V2 seed 157). Full mechanism documented in +`bufferHasSerializeHostileWrappedRow` and the suite's headline comment. + +- **V1 (cell loss):** a wrapped continuation row starting with a NULL cell + passes the addon's wrap-validity ternary, gets skipped with `CUF` which clamps + at the right margin, overwriting the previous row's last cell and shifting the + tail left by one. `cols=20: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K'`. +- **V2 (stray `-` filler):** a wrapped pair whose source row is entirely null + takes the forced-wrap "magic" path; cleanup emits `ESC[0C` (param 0 → 1) so + the ECH erase lands one cell right and the first filler `-` survives. + +**Impact:** any snapshot consumer (hidden reveal, parked-tab reveal, sleep/wake, +mobile subscribe replay) paints lost/shifted characters or stray `-` fillers +when a TUI erases inside a soft-wrapped line. Tolerated + counted by the suite; +unskip the repros when upstream fixes or a local serialize post-processor lands. + +--- + +## Bug B — SerializeAddon loses BOLD when serializing a dim→bold-only transition + +**Classification: (a) real `@xterm/addon-serialize` bug.** New finding. + +**Seeds:** 435, 770 (alt-screen), 1321 (minimal, 2 ops). + +**Minimal repro (isolated, no fuzz corpus needed), cols=20:** + +``` +live bytes: "\x1b[2mA\x1b[22m\x1b[1mB" +SerializeAddon → : "\x1b[2mA\x1b[1;22mB" +live cell B: bold=1 dim=0 (style flags 100000) +restored cell B: bold=0 dim=0 (style flags 000000) ← BOLD LOST +``` + +**Mechanism:** cell A is dim, cell B is bold-only. The serializer diffs the pen +from A (dim on) to B (bold on, dim off). To clear dim it appends SGR 22 — but in +xterm/ECMA-48 **SGR 22 resets *both* bold and dim** (`normalIntensity`). So the +emitted `\x1b[1;22m` sets bold then immediately clears it: the restored cell is +neither dim nor bold. Verified directly: writing `\x1b[1;22mX` yields `bold=0`. +(`\x1b[1;2m` — the same-cell dim+bold case — round-trips fine, so the bug is +specific to a dim-cell → bold-only-cell attribute transition.) + +**Why it garbles a real pane:** agent TUIs routinely draw a dim body line then a +bold status/spinner line (Claude Code, Codex). On the live screen the status +line is bold; after a hide→reveal snapshot restore it renders normal-weight. +The seed-1321 live row `⠦ bash: pnpm typecheck` is bold live, non-bold restored. + +**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` → +`it.skip('preserves bold when serializing a dim cell followed by a bold-only cell')`. +Tolerated + counted in the corpus via `snapshotHasSelfCancellingBoldReset`. + +--- + +## Bug C — SerializeAddon cursor restore is off-by-one when the last content row fills the right margin + +**Classification: (a) real `@xterm/addon-serialize` bug.** New finding. + +**Seeds:** 454 (minimal, plain CUP), 1696 (DECSC/DECRC + wide CJK). + +**Minimal repro (isolated, pure serializer replay), cols=10:** + +``` +live bytes: "0123456789\x1b[3;5H" (fill row 0 to the margin, CUP to r3c5) +SerializeAddon → : "0123456789\x1b[2B\x1b[6D" +live cursor: { x: 4, y: 2 } +restored cursor: { x: 3, y: 2 } ← ONE COLUMN SHORT +``` + +Control (`"012\x1b[3;5H"` — row 0 not full) serializes to `"012\x1b[2B\x1b[1C"` +and round-trips the cursor exactly, isolating the trigger to a full-width final +content row. + +**Mechanism:** after emitting a row filled to exactly `cols`, xterm is left in +the *wrap-pending* state (cursor visually on the last column, logically "one +past"). The serializer computes its final cursor-restore as relative +`CUD`/`CUB` moves from that ambiguous position; the horizontal delta is computed +one column short, so the restored cursor lands at `x-1`. Reproduced with pure +`serializeAddon.serialize()` replay into a fresh terminal — **no Orca preamble +or normalization involved**, confirming it is upstream, not Orca's snapshot +path. + +**Why it garbles a real pane:** the cursor is where the next keystroke echoes +and where the block/bar cursor is drawn. On reveal of a TUI whose bottom line +reached the right edge (wide status lines, long prompts), the cursor sits one +cell left of where the live pane had it — visible as a mispositioned prompt +caret or spinner, and subsequent input can overwrite the wrong cell. + +**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` → +`it.skip('restores the cursor exactly when the last content row fills the right margin')`. +Tolerated + counted in the corpus via `isMarginWrapPendingCursorOffByOne`. + +--- + +## Bug D — snapshot does not preserve the DECSC saved-cursor register across a hide/reveal boundary + +**Classification: (a) real bug — a structural snapshot limitation.** New +finding, surfaced by the reveal-reconciliation fuzz (suite 2), not the fidelity +fuzz. + +**Minimal repro (cols=20):** + +``` +hidden bytes: "AB\x1b7\x1b[4;10HCD" (write AB, DECSC saves cursor at r0c2, + move to r3c9, write CD) +tail bytes: "\x1b8X" (DECRC restores the saved cursor, write X) + +live (always visible): rows ["ABX", " CD"] cursor { x: 3, y: 0 } +reveal (snapshot+tail): rows ["XB", " CD"] cursor { x: 1, y: 0 } + ^^ 'X' overwrote 'A' — DECRC landed at home, not r0c2 +snapshotAnsi: "AB\r\n\r\n\r\n\x1b[9CCD" (no saved-cursor state at all) +``` + +**Mechanism:** the snapshot is a serialized *screen* (SerializeAddon) plus a few +rehydrated modes. The VT100 DECSC/DECRC saved-cursor register (also `CSI s` / +`CSI u`) is runtime state that never appears in the serialized buffer, so it +cannot survive a snapshot. When a hidden TUI runs `\x1b7` (or `\x1b[s`) before +the reveal seq and the racing tail (or any post-reveal output) runs `\x1b8` (or +`\x1b[u`), the restore targets the fresh terminal's default saved position +(home) instead of where the TUI saved it — the next writes land at the wrong +cell and overwrite live content. + +**Why it garbles a real pane:** DECSC/DECRC is common in shell prompts and +status-line redraws (save cursor, jump to a corner to paint a clock/token +counter, restore). If the save happens while the pane is hidden and the restore +fires on reveal, the restored paint clobbers the wrong cells. Found by suite-2 +seed 3 (a `savedCursorDetour` op whose `\x1b7` fell in the hidden prefix and +whose `\x1b8` fell in the racing tail after chunk-splitting). + +**Handling:** suite 2 keeps its racing tail append-only (no DECSC/DECRC, cursor +motion, scroll regions, or alt frames) so the seq-reconciliation byte-stitch is +tested in isolation from this and the other terminal-state-loss garbles. Bug D +is instead pinned as a standalone repro, +`hidden-reveal-reconciliation.fuzz.test.ts` → +`it.skip('preserves the DECSC saved-cursor register across a hide/reveal …')`. + +**Fix (applied):** the snapshot epilogue re-establishes the register with +`CUP(saved) + ESC 7 + CUP(actual)` composed in `serializeWithAbsoluteCursor`, +reading the active buffer's core register via `readSavedCursorRegister` +(alt screen yields its own register). Position-only: saved SGR/charset are not +carried. + +--- + +## Bug E — snapshot boundary mid-escape-sequence drops the partial sequence + +**Classification: (a) real bug — a structural snapshot limitation.** New +finding, surfaced by the reveal-reconciliation fuzz (suite 2). + +**Minimal repro (cols=20):** + +``` +hidden prefix: "AB\x1b[3" (write AB, then ESC [ 3 — no final byte yet) +tail bytes: "mCD" ('m' completes ESC[3m = italic, then CD) + +live (always visible): rows ["ABCD"] (ESC[3m parsed atomically, CD italic) +reveal (snapshot+tail): rows ["ABmCD"] ← 'm' became a literal character +snapshotAnsi: "AB" (the partial ESC[3 is in the parser, gone) +``` + +**Mechanism:** a PTY read (one delivery record) can split an escape sequence. +If the pane is revealed while the emulator's parser sits mid-`ESC[…`, the +serialized SCREEN cannot carry the partial sequence (it lives in the parser +state machine, not the buffer). The racing tail supplies the sequence's +remaining bytes, but with the prefix gone the terminal parses them as literal +text. Reproduced end-to-end against the real `HeadlessEmulator.getSnapshot`. + +**Why it garbles a real pane:** any TUI whose output is heavy with escape +sequences (all of them) can have a read boundary fall mid-escape; if a reveal +lands in that window the continuation renders as stray literal bytes (a rogue +`m`, `H`, digits) injected into the visible text. + +**Reachability:** requires the reveal/snapshot to fire in the gap between the two +halves of a split escape. `main` writes each PTY read to the emulator and +records it as one delivery unit (`session.ts emitSubprocessOutput`), and the +snapshot is taken synchronously at a drain — so the window is a single delivered +record that ended mid-escape. Narrow but real. + +**Handling:** suite 2 tolerates + counts scenarios whose hidden prefix ends +mid-escape-sequence (`prefixEndsMidSequence`), the same way suite 1 tolerates the +serialize wrap bug — it fired on ~24% of the corpus, confirming the class is +common. Pinned by `hidden-reveal-reconciliation.fuzz.test.ts` → +`it.skip('completes an escape sequence split across the hide/reveal boundary')`. + +**Fix (applied):** the emulator tracks the unparsed trailing partial escape at +ingest (`terminal-partial-escape-tail.ts`, committed post-parse like the mouse +mirror) and ships it as `TerminalSnapshot.pendingEscapeTailAnsi`; restorers +write it LAST, after their post-replay resets, so the racing tail's +continuation completes it exactly as live. Snapshot seq already counted those +ingested bytes, so reconcile slicing is unchanged. + +--- + +## Known-legitimate normalization (NOT bugs) + +- **OSC 8 hyperlink underline** — classification (c). xterm marks OSC-8 link + cells underlined; SerializeAddon never re-emits OSC 8. Production restores the + link ranges out-of-band via `snapshot.oscLinks` + (`collectHeadlessOscLinkRanges`), so byte replay keeps the text but drops the + underline by design. Pinned by the passing + `it('drops OSC 8 underline from byte replay but preserves the range …')`. +- **P256→P16 color mode** — classification (c). SerializeAddon re-emits palette + indices 0–15 written as `38;5;N` using classic SGR 30–37/90–97, so a restored + cell reports `CM_P16` where live reported `CM_P256`. Both resolve through the + same 16 theme slots — no visual difference. Canonicalized by + `canonicalColorMode` in the parity fixture. + +--- + +## Corpus vs deep mode + +- **Suite 1** (`headless-emulator-fidelity.fuzz.test.ts`): default + `FUZZ_ITERATIONS=300` (~17s). `FUZZ_ITERATIONS=2000` (~113s) is green — Bugs A, + B, and C are each tolerated + counted by a predicate, so the corpus fails only + on a genuinely new divergence. +- **Suite 2** (`hidden-reveal-reconciliation.fuzz.test.ts`): default + `FUZZ_ITERATIONS=200` (~5s). `FUZZ_ITERATIONS=1000` is green — the racing tail + is append-only, so the only tolerated class is Bug E (`prefixEndsMidSequence`). +- Combined default runtime is ~19s (well under the 60s gate). +- `FUZZ_SEED=`: re-run exactly one seed for a repro (both suites). diff --git a/notes/orca-performance-branch-guide.md b/notes/orca-performance-branch-guide.md new file mode 100644 index 00000000000..870c3813911 --- /dev/null +++ b/notes/orca-performance-branch-guide.md @@ -0,0 +1,532 @@ +# orca-performance Branch Guide + +Agent-facing map of every optimization on this branch: what it does, why it exists, +where it lives, and the invariants you must not break when adding to it. The +chronological evidence trail (benchmarks, retractions, A/B protocols) is in +`notes/terminal-performance-initiative.md`; this doc is the _current-state_ view. + +**Context**: Orca's terminal was ~300× slower than Terminal.app under agent load +(DSR-under-load p50 134ms, p99 292ms on v1.4.91; agent-TUI throughput 2.0 MB/s). +As of v1.4.122-rc.1.perf: p50 13.3ms / p99 18.7ms, zero timeouts, throughput +11.8–15.5 MB/s — beats VS Code on 5 of 6 metrics. Goal line still open: 4.5ms +(10× Terminal.app). + +## The pipeline + +``` +shell → pty → daemon (persistence, headless model) → unix socket + → main (ipc/pty.ts: batching, delivery gate, flow control, snapshots) + → IPC → renderer (pty-dispatcher → pty-connection → output scheduler → xterm) +``` + +Main is on the hot path for every byte (unlike VS Code's ptyHost→renderer +MessagePort). The daemon owns sessions so they survive app restarts; it also runs +a headless xterm emulator per pty — the _model_ — which is the source of truth +for screen contents. The renderer terminal is a _view_ that can be discarded and +rebuilt from model snapshots. + +## Optimization inventory + +### 1. Renderer parse-path fixes (the original 16× on agent TUIs) + +- **Parse-clocked scheduler drains** (`pane-terminal-output-scheduler.ts`): drain + cadence follows xterm's actual parse completion instead of fixed timers, so the + queue never outruns the parser. +- **Windowed retained-tail redraw**: TUI repaints (erase-down + redraw) only + re-process a bounded window instead of the full retained tail. Guarded by + differential fuzz `retained-tail-redraw-window.equivalence.test.ts`. +- **Throttled wait-blocked check** (`orca-runtime.ts`): the per-chunk agent + wait-detection (two 256KB waitText builds + multi-pattern scans) now runs at + 50ms cadence with trailing edge + keyword pre-filter. Was ~85% of main's + per-chunk cost. + +### 2. term-speed-2 chain (model/view contract — the architecture) + +Revived from ~38 never-merged branches; kill-switched, default ON. Docs: +`docs/reference/terminal-model-view-contract.md`. + +- **Hidden view parking**: hidden tabs tear down their xterm view entirely + (memory: parked panes cost ~0). +- **Hidden delivery gate** (main): renderer-bound bytes for hidden ptys are + dropped at main — hidden panes receive nothing. Reveal rebuilds the view from a + model snapshot + live chunks after the snapshot's seq. +- **Side-effect authority**: main extracts side-effect facts (bell, title, cwd) + from the model so parked panes stay live in the UI without a view. +- **Model query authority**: main answers terminal queries (DSR/CPR, DA1, OSC + colors) deterministically from the model for hidden panes. +- **Seq/ordered-delivery bookkeeping**: every chunk carries a seq; reveal + reconciliation drops duplicates already covered by the snapshot baseline. + +### 3. Batching & scheduling cadence + +- **Batch windows 8ms → 2ms** in both `daemon-stream-data-batcher.ts` + (`STREAM_DATA_BATCH_INTERVAL_MS`) and `ipc/pty.ts` (`PTY_BATCH_INTERVAL_MS`). + At 9% utilization there is no queue — latency was literally the sum of fixed + batch windows. This one change took dev DSR-load 19→8ms. +- **MessageChannel zero-delay drains** (`pane-terminal-output-scheduler.ts`): + Chromium clamps nested `setTimeout(0)` to ~4ms; posted messages are macrotasks + without the clamp, preserving cooperative yield (input/paint still serviced). + Vitest keeps the timer path (fake timers can't advance channel posts). +- **Input write coalescing** (from main, #7205): renderer input writes coalesce + instead of queuing macrotask-per-keystroke. + +### 4. Backpressure (the correctness spine — read before touching delivery) + +Three cooperating layers, innermost first: + +- **ACK at parse-drain** (`deliverPtyDataWithDeferredAck`, scheduler + `ackCredit`): the renderer credits a chunk when xterm has _parsed_ it (or the + chunk is legitimately discarded), not when IPC delivered it. + **INVARIANT: every delivered chunk credits exactly once — parsed or + discarded.** Every scheduler/pty-connection discard path (backlog replacement, + disposed terminal, reconcile drop, split remainders) must fire the credit. +- **Cumulative ACKs + solicited resync** (`terminal-pty-ack-gate.ts`, + `applyCumulativeAck` in pty.ts): ACKs carry monotonic per-pty processed totals + (TCP-style); main max-merges, so lost ACKs self-heal. Data arriving for a + fully-gated pty triggers a resync probe instead of a timeout reset. The only + timer is a hygiene warn that mutates nothing. Main's 512KB per-pty in-flight + gate + 2MB pendingData cap sit on top. +- **Renderer-pull delivery watchdog** (`terminal-delivery-watchdog.ts`, + `pty:reportRendererDeliveryState` in pty.ts): recovers the field-confirmed + wedge where every main→renderer PUSH channel dies while invoke stays alive + (v1.4.121-rc.0 snapshot; electron#37067 class) — a state the push-ridden + resync probe can never reach. The 15s heartbeat costs one Map upsert per + received chunk and does no IPC while output flows; mutation stays + verified-state-only (the timer decides when to REPORT; the write-off derives + entirely from the renderer's cumulative received totals, never wall-clock, + and a received-but-unparsed window is never written off). Heal = re-attach + push listeners + pull restore markers through the modelRestoreNeeded router. + E2e blackhole harness: `__terminalDeliveryWatchdog`, + `terminal-push-delivery-loss-recovery.spec.ts`. +- **Stale-visibility proof for the hidden gate** (`stale-document-visibility.ts` + and the `shouldWritePtyOutputForeground` fallthrough in pty-connection.ts): + recovers the field-confirmed wedge where macOS occlusion tracking pins + `document.visibilityState` at `'hidden'` after display sleep and never fires + another visibilitychange (v1.4.124-rc.2.perf snapshot: 78MB hidden-gate + dropped across 2 pane-level-visible ptys, transport healthy). Real user + input (keydown/pointerdown/window focus) while the document claims hidden is + a physical contradiction — it latches an override, runs each pane's existing + visibilitychange resync (gate unhide + hidden-output restore), and a genuine + visibilitychange hands authority back. No timers; recovery is purely + event-proven, and the failure bias is safe (a wrong override only restores + pre-gate delivery cost, never drops bytes). Hot-path cost: zero when + visible (same single comparison); one property read per user-interaction + event. E2e: `terminal-stuck-occlusion-recovery.spec.ts` (pins both the + freeze repro and the keystroke recovery, plus the + `hiddenDeliveryGatedVisiblePtyCount` field discriminator). +- **One-paste freeze report** (`terminal-freeze-report.ts`, prod-installed + `await window.__orcaTerminalFreezeReport()`): a single DevTools command that + returns renderer state (visibilityState + stale override, pty:data listener + count, watchdog totals), main's snapshot with a per-pty delivery table + (sent/acked/pending, hidden vs visible-set membership, last send/ACK ages, + window focus flags, power suspend/resume ages, app version), and bounded + breadcrumb rings from BOTH processes (`pty-delivery-diagnostics.ts` shared + ring: 100 entries, same-kind coalescing) recording gate marks, visibility + trust changes, watchdog stalls/heals, restore markers, heal write-offs, + renderer lifecycle resets. Pty ids are redacted to their `@@` suffix — + daemon session ids embed worktree paths. Recording happens only on rare + transitions; the table/report is built only when read. This exists so a + field freeze report never needs a follow-up ask. +- **Hidden/parked exit teardown completeness** (pty-connection.ts kept-exit + guard + `terminal-parked-tab-watchers.ts` exit sidecar): two invariants that + keep a split pane's death near the hidden/park boundary from stranding state + (field incident: a closed setup-split leaf persisted in `root` with no + binding and remounted as a permanently blank pane, unreachable by + dead-session reconcile — it skips ptyId-null panes by design). + (1) The "keep a fresh split whose newborn PTY died" branch is **gated on + `isVisibleRef`** — hidden panes' bytes are gate-withheld, so "no output" + proves nothing there; a hidden newborn death must `closePane`, or the kept + pane becomes a binding-less ghost. (2) A PTY exit that lands **while parked** + reaches ONLY the parked watcher's exit sidecar (hosts' `onPtyExit` needs a + mounted TerminalPane), so the sidecar itself collapses the dead leaf out of + the stored layout via `detachTerminalLayoutLeaf` — a stale binding left + behind reattaches on reveal and the daemon re-creates the exited session id + as a fresh shell (silent pane resurrection). E2e: + `terminal-pane-close-layout-consistency.spec.ts` sweeps close/exit at every + lifecycle phase and asserts leaves(root) == bindings == live panes. +- **Producer flow control** (protocol v19 `pausePty`/`resumePty`, 256KB pause / + 32KB resume watermarks, keyed off **pendingData only** — never renderer + counters; kill switch `PRODUCER_FLOW_CONTROL_ENABLED`, ipc/pty.ts): when main's + buffer grows, the _shell_ blocks. For main-hosted ptys pause is synchronous + (drops impossible); for daemon ptys the pause notify has ~20-30ms socket + latency, so wire-speed bursts can still cross the 2MB cap (known follow-up: + daemon-side self-pacing watermark). +- **Shallow stream-socket write gate + per-session fairness** + (`daemon-stream-data-batcher.ts`, 128KB gate / 64KB safe-split slices / + 4KB small-session bypass / 32MB write-through valve; kill switch + `ORCA_DAEMON_SHALLOW_SOCKET_GATE=0`): the stream socket is one FIFO for + every session — bytes already written can never be overtaken, so a deep + user-space buffer buries a visible pane's echo behind other panes' bulk + (measured 192MB / 6+s under 12 flooding hidden agents). Bulk writes stop at + the gate and hold in the batcher, where the interactive flushSession path + and the deterministic small-session bypass still jump them; socket `drain` + refills. This layer alone bounds echo latency by the shallow depth. +- **Background keep-tail stream thinning + daemon fact authority** + (`daemon-stream-keep-tail-drop.ts` 1MB cap / 512KB keep-tail, + `daemon-background-transient-facts.ts`; kill switch + `ORCA_DAEMON_BACKGROUND_STREAM_DROP=0`): hidden-gated ptys are exempt from + pendingData flow control (main drops their bytes after ingestion), which + let N background agents run unbounded ahead of main. Main mirrors the + hidden-delivery gate to the daemon via the wire-tolerated + `setSessionBackground` notification (introduced in v19; authoritative + thinning requires v20 snapshots, so preserved v19 sessions are explicitly + unthinned; older daemons swallow it) — but a live remote view subscriber + (mobile/web) vetoes backgrounding (`hasRemoteTerminalViewSubscriber`). + Backgrounded sessions' queued output is keep-tail dropped (oldest bytes + replaced by an in-order `dataGap` event; reply-eliciting query bytes are + salvaged so hidden programs never hang on DSR/DA replies); producers are NEVER paused, + so reveal stays instant with zero catch-up. Un-background neither discards + nor force-flushes the queued tail — restore paths read MAIN's model + (hidden-output recovery buffer), so a discarded tail loses a finished + program's last output forever (caught by the ACK-backpressure e2e), and a + 16-pane force-flush dumps ~12MB onto the socket ahead of the reveal's own + bytes; the ordered drain loop delivers it within the budget below. Two + aggregate bounds make that budget real: (1) a GLOBAL background keep + budget (~2MB): per-session keep-tails shrink (512KB → floor 64KB) as more + backgrounded sessions hold queued data, and tighten retroactively when the + count grows — without this, N sub-cap sessions queue N×cap and a worktree + switch waits seconds behind the aggregate (measured 9MB → 2.5s hidden + restore vs the 1.5s budget, probe-verified drain overlap); (2) a + kernel-flush refill sentinel: a held flush pass arms one ~90B empty data + event whose write callback re-flushes when the kernel accepts the + in-flight bytes — without it, held bulk advances one gate-depth per + 'drain' (user-space empty) per event-loop turn (~8MB/s ceiling on a busy + daemon). NOTE: an empty `socket.write('')`'s callback fires immediately + even with megabytes buffered (verified) — the sentinel must be a real + protocol no-op line. Notifications are + structurally lossless: while backgrounded, the DAEMON runs the same shared + scanners main uses (`terminal-output-side-effects.ts`: bell / OSC 133 + command-finished / pr-link / DECSET 2031) over every byte BEFORE drop + decisions and relays facts as in-order `transientFact` events; ordered + `sessionBackgroundMarker` events hand scan authority back and forth + (main suppresses just those four scanners in between), and the emulator's + `partialEscapeTailAnsi` seeds each side's fresh scanner carry so a + sequence split across the handoff neither phantom-fires nor goes missing. + Titles/agent-status stay main-side (they converge from the kept tail and + fuse with synthetic spinner frames). On `dataGap`, main resets its + cross-chunk parse carries, drops the mobile headless mirror (rebuilds from + tail/seeds), and sends the model-restore-needed marker so any + renderer-side buffer heals from the snapshot. Visible ptys are never + touched by this layer. Backlog observability: + `ORCA_DAEMON_STREAM_BACKLOG_FILE=` JSONL + (`daemon-stream-backlog-probe.ts`; events incl. `backgroundKeepTailDrop`, + `setSessionBackground`, `mainBackgroundSync`, `heldWriteThrough`). + Causation A/B (`bench:multi-workspace-typing`): realistic steady rates + (8×192KB/s) don't reproduce even fix-off; burst rates on a loaded machine + do (8×512KB/s + 12 CPU spinners: fix-off p50 293ms/p90 647ms → fix-on p50 + 29ms); extreme 12×1MB/s: fix-off p50 6,146ms → 29ms. + +### 5. Flood resilience (why bulk output can't wedge or lie anymore) + +- **Restore-loop cut** (pty-connection.ts): the hidden-output-restore loop + abandons immediately when a foreground pane's live-chunk queue overflows + (3-iteration hard cap), and a 2s flood-suppression window stops main's own + backpressure drops (`droppedOutput`/`modelRestoreNeeded`) from re-arming + restore — bytes write through, ONE deferred repaint heals after the flood. + This killed a positive feedback loop (restore starves ACKs → main drops → + drop re-arms restore) that caused multi-second renderer stalls. +- **Query survival**: if the 2MB cap ever drops bulk output, embedded terminal + queries are extracted (`terminal-reply-query-extraction.ts`) and answered by + _synthesizing replies on the input path_ (CPR from live buffer, DA1 canned, + OSC via direct responder) — probes and TUIs never hang on a dropped reply. +- Drops are downstream of the model: the daemon ingests every byte, so the + post-flood repaint restores complete, correct content. + +### 6. Wake/sleep recovery + +- powerMonitor resume → `system:resumed` IPC → renderer wake recovery (fixes + WebGL-latch blank-after-sleep that DOM focus/visibilitychange missed). +- Cumulative ACKs make the historical "lost ACKs across suspend pin the global + window forever" wedge (BMW user bug) structurally impossible. + +### 7. Snapshot fidelity (the garble fixes — all fuzz-pinned) + +Reveal-from-snapshot multiplied exposure of serializer defects ~1000×. Five bugs +found by differential fuzzing; four fixed, one tolerated: + +- **B: SGR intensity ordering** — upstream `@xterm/addon-serialize` emitted + `1;22` (22 clears the bold 1 just set). Patched via pnpm patch + (`config/patches/@xterm__addon-serialize@*.patch`): clear-before-set for the + bold/dim group (+2 sibling bare-22 defects). +- **C: cursor off-by-one at wrap-pending margin** — bypassed entirely: + `serializeWithAbsoluteCursor` (`terminal-serialize-absolute-cursor.ts`) + appends absolute CUP from the source terminal's authoritative cursor + (skipped when wrap-pending, where CUP would corrupt). +- **D: DECSC saved-cursor register not serialized** — snapshot appends + `CUP(saved) + ESC 7 + CUP(actual)` when a register exists. +- **E: snapshot mid-escape-sequence** (fired on 24% of fuzz corpus) — + `terminal-partial-escape-tail.ts` is a fold-safe VT-parser-state scanner; the + unparsed tail ships as `TerminalSnapshot.pendingEscapeTailAnsi` and is written + LAST on restore so continuation bytes complete the sequence. Seq accounting + unchanged (the tail is a suffix of bytes ≤ snapshot seq). +- **A (tolerated)**: upstream wrap-null-cell serialize defect — fenced by + `bufferHasSerializeHostileWrappedRow`, the only remaining tolerance. + +## Correctness infrastructure (run these before merging delivery/restore changes) + +- `headless-emulator-fidelity.fuzz.test.ts` — differential: HeadlessEmulator vs + reference xterm, seeded TUI streams. `FUZZ_ITERATIONS=2000` for deep, + `FUZZ_SEED=n` to replay. +- `hidden-reveal-reconciliation.fuzz.test.ts` — property tests: random + hide/reveal boundaries × snapshot seq × racing chunks must equal an + always-visible reference. +- `terminal-snapshot-serialize-roundtrip.test.ts` — the garble repros (unskipped + = regression alarms). +- e2e: `terminal-hidden-view-parking` (incl. 25-cycle park/reveal drift test — + byte-identical vs control), `terminal-parked-memory`, + `terminal-sleep-wake-restore`. +- Scheduler credit-invariant + ack-gate deferred-credit + restore-flood tests + (pane-manager / terminal-pane suites). + +## Benchmarking protocol (hard-won rules) + +- Rig: `tools/benchmarks/terminal-pipeline-bench.mjs` — DSR idle + DSR under + 1MB/s agent-TUI load + DSR-fenced throughput on 4 fixtures. Run _inside_ the + terminal under test. +- Multi-workspace typing rig: `pnpm bench:multi-workspace-typing -- --panes 12 +--rate-kbps 1024 --keys 32 --cadence-ms 250 [--cpu-workers 8] --label ` + — real keystrokes (CDP) into a visible pane while N hidden-worktree panes + replay paced agent-TUI streams through real daemon ptys; decomposes each key + into input-half (keydown→pty, sidecar timestamps) and echo-half + (pty→screen). JSON in `tools/benchmarks/results/`. Noise band at 4×256KB/s: + p50 10-15ms, p90 ≤50ms. The latency signature lives in echo-half; renderer + timer drift staying ~15ms while echo-half grows means the backlog is + upstream of the renderer (daemon socket / main ingest). +- **Bench at 10MB** (`--size-mb 10`). The ACK-at-parse bug shipped because dev + benches used 3MB and never tripped the cap. +- Load-controlled A/B only: alternate builds within one session; dev carries ~2× + day-to-day variance. Never conclude from runs while agents/builds hammer the + machine (two false convictions came from this). +- Never set `ORCA_E2E_USER_DATA_DIR` for benches (arms the e2e ACK gate → hang). +- Packaged builds are truth; dev has ~2× overhead. + +## Release mechanics + +- Perf RCs: `release-cut.yml` workflow_dispatch, `kind=rc ref=orca-performance +version_suffix=perf` → tags like `v1.4.122-rc.1.perf`. The suffix sorts above + its base rc.N but below rc.N+1 (never hijacks the RC channel). The rc counter + (`release-rc-history.mjs`), telemetry identity classifier, and build guard are + all suffix-aware — a suffixed rc classifies as `rc`. +- cmd/ctrl-click "Check for Updates" fetches the latest perf-tagged release + (PR #7278; merged here) — perf-line users self-update after one manual install. + +## Syncing with main: MERGE, never rebase + +`orca-performance` is a long-lived, shared, continuously-pushed integration +branch — RCs are cut from it and agents branch off it. **Always +`git merge origin/main`; never rebase** (rebasing rewrites pushed history and +strands every RC tag, fix branch, and worktree based on the old commits). +Conflict pattern, established over ~6 syncs: + +1. **Our structure wins; main's semantics graft in.** This branch deliberately + restructures terminal code (shared scanners, single-policy handlers, + model/view split). When main adds a feature inside code we've restructured, + keep our shape and port their new behavior into it. Example: main inlined an + OSC 133 parser to add `onCommandStarted` (133;C); we kept the shared + `createOsc133CommandFinishedScanner` (main's side-effect tracker must parse + byte-identically) and added 133;C support to the shared scanner instead. +2. Preserve the invariants in **Guardrails** below through every resolution — + especially chunk-credit, `pendingData`-keyed flow control, and the + single `handleCommandFinished` policy (byte path AND sideEffect-fact path + route through it). +3. After resolving: `pnpm typecheck`, the terminal-pane + ipc/pty + daemon + suites, and both fuzz suites. Commit the merge with a message stating what + was kept from each side; push. If the push races a moved remote, merge the + remote tip — never `pull --rebase` a merge. +4. If a sync lands anything on the delivery/restore path, re-run a 10MB bench + before the next RC cut. + +## Known limits / next levers (in rough priority order) + +1. Daemon self-pacing: daemon-hosted ptys can cross the 2MB cap for ~20-30ms at + wire speed before `pausePty` bites. Fix: daemon enforces its own watermark + locally (VS Code does this server-side for remotes). +2. Cadence floor to the 4.5ms goal: xterm's 12ms parse slices and remaining + drain cadence dominate the 13.3ms prod p50. +3. utilityProcess router endgame: take main off the per-byte hot path + (VS Code's ptyHost→renderer MessagePort shape). +4. SerializeAddon full-buffer stalls at 50k-row scrollbacks (#5096 follow-up). +5. Peel PRs to main: throughput fixes → batch+MessageChannel → flow control → + term-speed-2 last. PR #7214 is the integration overview; #7260 (wake/ACK) + is open against main separately. + +## Guardrails for future agents + +- The chunk-credit invariant (§4) is the load-bearing one. If you add ANY path + that receives, defers, drops, or splits pty data in the renderer, prove it + credits exactly once. The credit-invariant unit tests are the gate. +- Flow control keys off `pendingData` only. Do not couple it to renderer + counters; the two layers compose because they are independent. +- Snapshot changes must keep seq semantics: a snapshot covers _exactly_ bytes + ≤ its seq (Bug E made this true; don't regress it). Chunks after restore are + reconciled by seq — off-by-N re-triggers duplicate-drop garble. +- Hidden panes must receive nothing (delivery gate) but side-effects and query + replies must stay live via the model. If you add a new query type, wire it + through model authority AND the drop-path synthesis. +- Never add timeout-based recovery that mutates counters (user requirement — + design decision from #7260). Deterministic resync or nothing; hygiene timers + may only log. +- Any change on the delivery/restore path: run both fuzz suites, the roundtrip + tests, the chain e2e trio, AND a 10MB bench before calling it done. + +## Audit 1 + +Completed 2026-07-10 against `orca-performance`. Scope: the daemon → main → +renderer terminal path, with particular attention to hidden delivery, parking, +stream thinning, snapshot fidelity, ACK/backpressure semantics, wake/reattach, +mobile/remote composition, SSH routing, and teardown. The audit treated model +state and user scrollback as correctness requirements, not expendable memory. + +### Findings and fixes + +1. **Hidden-gate handoff owners could undo one another.** A parked watcher and + an unmounting/remounting pane shared one boolean hidden mark; likewise a + retiring pane could report `visible=false` after its replacement had already + reported `visible=true`. Hidden claims are now reference-counted and + visibility is counted per owner (`pty-renderer-delivery-claims.ts`). Eager + pre-mount buffers no longer hold raw-byte delivery interest: they are not a + side-effect consumer, and model-backed hidden output can restore from a + snapshot. This removes the ownership and eager-interest races formerly + listed in Known limits item 6 without weakening parked side effects. The + remaining transient-visibility concern was checked separately: bind and + reconnect reports read `TerminalPane`'s synchronously refreshed + `isVisible && isWorktreeActive` ref, the global effect uses the same + expression, and owner counting prevents a retiring pane from overriding its + replacement. No hidden-worktree `visible=true` report site remains. + +2. **A natural/synthetic daemon exit could overtake final output.** When a + shallow-gated socket had queued data, `daemon-server.ts` wrote the exit event + directly. The final bytes could therefore arrive after `exit`. Exit is now an + ordered control event in `DaemonStreamDataBatcher`; both natural and + synthetic exits flush through the same FIFO. A deep-socket regression test + pins final-data-before-exit ordering. + +3. **Keep-tail thinning could permanently reduce scrollback to the retained + tail.** On `dataGap`, main discarded its headless model, then rebuilt it from + later tail bytes even though the daemon still owned the complete model. Live + daemon snapshots now carry `outputSequence`; the provider exposes an + authoritative `getBufferSnapshot`, accepts the requested scrollback depth, + and main requires that provider snapshot after a gap. Reconciliation starts + in the pre-snapshot absolute sequence domain and runtime sequence accounting + advances across dropped bytes. The daemon remains the source of complete + scrollback instead of making a transport optimization destructive. If that + authoritative RPC is temporarily unavailable, main now returns no snapshot + and lets the renderer retry; it never paints main's known-incomplete tail as + a full recovery. + +4. **Query-salvage copies corrupted the absolute sequence domain.** DSR/DA + bytes salvaged from a dropped region are copies of bytes already counted by + the daemon, not new output. Stream events now distinguish delivered text + from `sequenceChars`; salvaged query data advances by zero while the gap + advances by the original characters. Main can still parse/deliver the query + copy without shifting every later snapshot baseline. + +5. **“Parse-deferred” ACKs were submission-deferred, not parse-deferred.** ACK + credit fired when bytes entered `terminal.write`, before xterm's callback. + Split scheduler chunks also attached `onParsed` to the first slice. ACK + credit is now owned by `pane-terminal-output-ack-credit.ts`, fires after the + final xterm parse callback, and is released exactly once on throw, discard, + or terminal disposal. Submitted-but-unparsed credit is retained until parse + or disposal, so main's flow-control window measures parser work rather than + renderer submission. + +6. **Hidden restore ignored configured scrollback.** The renderer always asked + for 5,000 rows, so users configured for 10k–50k silently lost older history + on a hide/reveal rebuild. Restore now reads the pane's xterm scrollback + option and clamps it through the shared 0–50,000 policy + (`terminal-hidden-restore-scrollback.ts`). + +7. **Active alternate-screen snapshots discarded the normal shell buffer.** + SerializeAddon emits `normal buffer + ?1049h + alternate buffer`; the old + normalization sliced away everything before the last `?1049h`. A restored + TUI looked correct until it exited alternate mode, then returned to empty + history. Snapshots now carry the normal buffer separately in + `scrollbackAnsi`. Fresh reattach and mobile/remote snapshot streams compose + both buffers; an already-alt renderer exits alt, clears/rebuilds the normal + buffer, then re-enters and rebuilds alt. History replay also composes both + buffers, including legacy empty-field compatibility. + + Deep fuzz then found a second two-buffer issue: normal-buffer serialization + can leave its SGR pen active while the separately serialized alternate body + assumes default SGR. The rehydrate boundary now emits `SGR 0` before + `?1049h`, preventing a shell color from tinting restored TUI cells. The + regression proves the TUI is visible immediately and `?1049l` returns to the + original shell history. + +8. **Daemon provider wrappers forwarded only part of the recovery contract.** + A preserved current/legacy daemon could emit `dataGap` through a provider + wrapper, but `DegradedDaemonPtyProvider` omitted `getBufferSnapshot`, while + the ordinary multi-version `DaemonPtyRouter` omitted background hints, gap + events, snapshots, and explicit `sequenceChars`. Both wrappers now route the + complete contract to the provider that owns the session, including requested + 50,000-row recovery and zero-advance query-salvage events. + +9. **Sequence-safe recovery was added without advancing the daemon protocol.** + An already-running v19 daemon could accept background-thinning hints but + could not return the new `outputSequence`, making any resulting gap + impossible to reconcile safely. The authoritative snapshot contract is now + protocol v20. Preserved v19 sessions remain live but are explicitly marked + unthinned; their stale background hint is cleared on the ordered control + socket before `createOrAttach`, while fresh v20 sessions retain keep-tail + performance and full-model recovery. + +### Static audit conclusions + +- Model query authority still captures ownership synchronously at ingestion; + seed/hydration/snapshot writes remain reply-silent, remote view subscribers + retain view authority, and replies use the provider input path (including + daemon shell-ready queuing and SSH routing). +- Hidden/visibility/interest/background-sync/provider-snapshot state is cleared + by the centralized PTY teardown path. Parked watcher timers, byte sidecars, + fact consumers, exit subscriptions, hidden claims, and runtime-title slots + dispose on reveal/exit/worktree shutdown. +- Remote-runtime and SSH PTYs remain excluded from cold parking. SSH hidden + panes still have a main-owned headless model, so mounted hidden-gate restore + is valid; live remote viewers veto daemon background thinning. The new + two-buffer payload is recomposed before mobile/remote snapshot frames. +- Wake recovery keeps its focus/visibility/system-resume listener symmetry and + cancels its settled animation frame on cleanup. No timeout was added that + mutates ACK or delivery counters. +- The one documented upstream SerializeAddon null-cell/wrapped-row defect + remains tolerated. Deep reveal fuzz now uses the same narrow hostile-row + predicate as fidelity fuzz, rather than misclassifying that known serializer + defect as sequence-reconciliation loss. + +### Validation evidence + +- Focused ownership/connection/dispatcher tests: 422 passed. +- Daemon server/batcher/order tests passed, including deep queued-socket exit. +- Broad main/daemon/runtime/RPC/SSH run: 1,854 passed, 5 skipped. Three stale + mocks were updated to assert the new explicit `sequenceChars` argument; the + production behavior was already correct. +- Broad renderer terminal/pane/scheduler/runtime-stream run: 1,934 passed. +- Final restore/roundtrip/history/adapter/runtime/scheduler sweep: 1,315 passed. +- Post-protocol completion sweep: 1,106 affected main/daemon tests passed; + 484 renderer/restore tests passed with 2 expected skips. +- Scheduler throughput harness passed with `ORCA_TERMINAL_PERF_BENCH=1`. +- Required hidden-view parking, parked-memory, and sleep/wake E2E trio: + 7 passed on a fresh v20 Electron build, including byte-identical output + across 25 park/reveal cycles. +- `FUZZ_ITERATIONS=2000` headless-emulator fidelity: passed (120.19s). +- `FUZZ_ITERATIONS=2000` hidden reveal reconciliation: passed (69.77s). It + reproducibly found the SGR boundary bug at seed 16 and the known upstream + wrapped-null-cell case at seed 1221 before the final green run. +- Snapshot roundtrip, retained-tail equivalence, ACK gate, PTY connection, and + remote incomplete-escape regression suites passed. +- Fullscreen real-app headful flow: a real shell wrote normal history, entered + a TUI while its worktree was hidden, restored on reveal, then exited with + `?1049l` back to the original history. The BrowserWindow was fullscreen; + no click/focus occurred before evidence; the restored frame settled for + 1.5s before capture. Measurements: window 1710×1073 at DPR 2; xterm 133×63; + `fitAddon.proposeDimensions()` 133×63; cell width 8px; screen-to-xterm gap + 11px (the scrollbar/remainder, with grid and proposed dimensions equal). + Artifacts: `.tmp/terminal-audit-headful/fullscreen-alt-restore.png` and + `.tmp/terminal-audit-headful/fullscreen-alt-restore-metrics.json`. +- Visible, non-E2E current-build Orca 10MB agent-TUI bench: 10.14 MB/s + (986ms for 10.0MB), DSR idle p50/p90/p99 0.64/6.47/57.82ms, + DSR-under-load 6.72/9.86/13.87ms, zero timeouts. The pane was 115×39, + reported app version 1.4.131-rc.2, and ran on a v20 daemon/session. Result: + `tools/benchmarks/results/terminal-pipeline-audit-v20-20260710-2026-07-10T10-54-45-990Z.json`. +- `pnpm typecheck`, oxlint on every touched TypeScript file, + `pnpm check:max-lines-ratchet`, `git diff --check`, and the E2E production + build all passed. No max-lines bypass was added. diff --git a/notes/terminal-performance-initiative.md b/notes/terminal-performance-initiative.md new file mode 100644 index 00000000000..2ac56bbf9e4 --- /dev/null +++ b/notes/terminal-performance-initiative.md @@ -0,0 +1,583 @@ +# Terminal Performance Initiative + +Working plan for the `orca-performance` branch. Goal: make Orca's terminal as +performant as the architecture allows, with every claim backed by a number. +Started 2026-07-02. + +## Why (user-reported, from the team meeting) + +1. Typing in the terminal is sometimes laggy — occasionally seconds of delay. +2. Users say the terminal is slower than iTerm (unclear if typing or scrolling). +3. Scrolling in Claude Code / OpenCode is slow. +4. Idle memory is high (1–2 GB). +5. Battery usage is high. + +Goals: legit performance complaints ≤ 1/week; sampled P90 typing/scrolling +latency down significantly; lower memory with 0–1 agents. + +## Ground truth (verified against source, 2026-07-02) + +Research corpus: xterm.js 6 / VS Code / Ghostty internals study (verified +file:line claims) — see the archived digest and the "xterm.js vs Ghostty" +deep-dive. The Orca-specific findings below were re-verified against this +repo's code: + +- **Electron main sits on every terminal byte's path** (daemon → main → + renderer). VS Code ships the same xterm.js but bypasses main entirely: its + ptyHost is a UtilityProcess with a direct MessagePort to each renderer. +- **The PTY producer is never paused.** `acknowledgeDataEvent` is a no-op in + both `LocalPtyProvider` and `DaemonPtyAdapter`. Only main→renderer delivery + is watermarked (512 KB, `src/main/ipc/pty.ts:1374`); main's own buffer can + grow toward a 512 MB cap under flood. VS Code pauses the actual pty at 100k + unacked chars (kernel backpressure blocks the shell). +- Renderer terminals share one thread with the entire React app; xterm.js + parses in 12 ms slices at a documented 5–35 MB/s ceiling. +- Renderer scrollback default is 5,000 rows (`src/shared/terminal-scrollback-policy.ts`), + 5× VS Code's default; 12 B/cell plus per-line JS objects; O(all lines) + reflow on column resize. +- Latency physics: Ghostty ~4 ms median keypress latency, VS Code ~31 ms + (same-library reference), native class 5–10 ms. Realistic target: beat + VS Code, close on iTerm2, eliminate the stall/jank class entirely (P99 + dominates perception). + +## Current state + +Branch `orca-performance` (long-lived testing line, from main @ `8e8a08ac7`): + +1. `tools/benchmarks/terminal-pipeline-bench.mjs` — cross-terminal rig + (see Benchmark protocol below). +2. Merge of PR #7153 = #7150 (freeze/memory: backlog caps, wedge guards, + probe-certified replay release) + #7139 (cooperative drain: paced backlog + draining keeps typing responsive under floods). Post-merge on this base: + `pnpm typecheck` clean, 626 targeted tests green (scheduler, guards, + pty/pty-connection/pty-transport suites). #7153 itself is a disposable + testing PR; #7139 and #7150 land separately on main. + +## Workstreams + +### 1. Baseline benchmarks (now; human-in-terminal required) + +Run the rig in each terminal on the same machine — Orca pane, iTerm2, Ghostty, +Terminal.app, VS Code (T3Code if available): + +``` +node tools/benchmarks/terminal-pipeline-bench.mjs --label - +node tools/benchmarks/terminal-pipeline-bench.mjs report +``` + +These numbers answer "are we actually slower than iTerm, and where," and are +the before/after for everything below. + +### 2. Validate #7153 on orca-performance (this week, extended testing) + +Watch for: typing responsiveness under agent floods, bounded memory, +skip-notice + snapshot repaint on overflow, no permanent input loss. When +validated, land #7139 and #7150 as separate PRs on main. + +### 3. Revive term-speed-2 (the headline structural work) + +History: nwparker's ~38-branch chain (+20k lines) implementing the terminal +model/view contract — hidden view parking, hidden delivery gate, side-effect +authority in main, model query authority, skip-grammar deletion — all +kill-switched, documented in +`origin/nwparker/term-speed-2-architecture-docs:docs/reference/terminal-model-view-contract.md`. +It shipped only in v1.4.78-rc.1, a deliberate personal-testing build; it was +never rejected and never reached main. Directly targets complaints 3–5 +(hidden panes stop receiving bytes and unmount their xterm + WebGL atlases). + +Merge scout (2026-07-02, chain tip into orca-performance): 144 files, 34 +conflicted, 115 hunks. Hotspots: `pty-connection.ts` (31), `pty.ts` (16), +`daemon-pty-adapter.ts` (6), `orca-runtime.ts` (5). +`pane-terminal-output-scheduler.ts` does NOT conflict — #7139/#7150 and the +chain touch different layers; runtime interaction (drain pacing × hidden +gate) still needs deliberate testing. + +Execution: dedicated focused session; resolve on `revive/term-speed-2` off +orca-performance; keep both sides' kill switches; validate with typecheck + +the contract tests listed in the model-view-contract doc + #7153's suites; +merge back to orca-performance for extended testing. Estimated ~1 day of +careful resolution + validation. + +### 4. Remaining stall-bug fixes (parallel, independently shippable) + +The "seconds of delay" class = discrete thread-blocking events, not +steady-state latency: + +- PR #7105 (open): skip synchronous cold-restore replay for live daemon + sessions in doSpawn. +- `SerializeAddon.serialize()` audit: ~1.2 s renderer block at 50k scrollback + rows (#5096 follow-up, never done). Call sites include the mobile snapshot + path (`pty-connection.ts:2861`) and sleep/hibernate serialization. +- #2836 frozen-terminal leads: replay-guard latch, codex-stale gate, uncapped + buffers (repro harness exists). +- Checkpoint-RPC main-thread scrub (measured ~2–10 ms bursts per hot 5 s + tick; small, part of the same program). + +### 5. Producer-side PTY flow control + +Ack-driven pause/resume of the actual PTY through the daemon protocol +(node-pty supports it), watermarks per the xterm.js flow-control guide +(≤500 KB). Converts flood-induced buffered lag into shell blocking — the +correct physics. Sequence after #7139 lands (interacts with its drain pacing). + +Design (2026-07-03, implement after the term-speed-2 revival merges — +same files): + +- Signal source: main already tracks per-pty pending + in-flight + (`pendingData`, `rendererInFlightCharsByPty` in `ipc/pty.ts`). When a + pty's pending exceeds HIGH (256 KB), main asks the producer to pause; + below LOW (32 KB), resume. +- Producer side: two new protocol notifications (`pausePty`/`resumePty`, + protocol vNext, version-gated like `supportsIncrementalCheckpoints`); + daemon `Session` calls node-pty `pause()`/`resume()` — stops reading the + pty fd, kernel buffer fills, the shell blocks on write: true kernel + backpressure, identical physics to VS Code's 100k/5k design. + `LocalPtyProvider` calls pause/resume directly. +- Safety invariants: (1) failsafe auto-resume after 5 s regardless of + watermark, so a lost resume can never wedge a shell; (2) resume on + detach/exit/kill/daemon-reconnect; (3) pause must not suppress the + interactive-echo bypass — with the pipeline fixed (11.5 MB/s dev), the + HIGH watermark is only reachable during genuine floods where echo is + already queued; (4) PTY reads never stop for model/tail ingestion + (term-speed-2 invariant #1) — pause gates the fd read, so daemon-side + emulator state pauses with it, which is correct (state = what was read). +- Tests: watermark transition unit tests, lost-resume failsafe, kill/exit + cleanup, plus an e2e pressure scenario asserting bounded main memory and + a blocked producer (`yes` exits promptly on SIGINT while paused). + +### 6. Extend the measurement rig + +- True keypress→pixel latency: Typometer manual protocol (the DSR probe stops + at the parser reply, before paint). +- Idle memory + battery: per-process RSS breakdown + `powermetrics` sampling + at 0/1/5 agents (goal-3 metric). +- FPS under flood; event-loop-delay probes (`monitorEventLoopDelay`) in + main/daemon/renderer behind a debug flag for pipeline attribution. + +### 7. utilityProcess terminal router (structural endgame; gated on data) + +An Electron UtilityProcess owns the daemon socket and hands each renderer a +MessagePort — VS Code's topology while keeping Orca's detached daemon (warm +reattach). Takes main off the terminal data path entirely; daemon-side +history persistence falls out naturally. Prototype only after baselines show +how much tail latency lives in the main hop. + +### 8. Production P90 telemetry + +Sampled keypress→echo latency + long-task/stall counts from real users; +defines the success criterion and becomes the permanent regression gate. +Design after the local rig stabilizes so the metrics match. + +## Benchmark protocol + +`tools/benchmarks/terminal-pipeline-bench.mjs` measures, from inside any +terminal: + +- **DSR idle latency** — ESC[6n round trips (p50/p90/p99); replies come only + after the parser reaches the query, so it proxies the input pipeline + without keystroke injection. +- **Fenced throughput** — 4 deterministic fixtures (`ascii-log`, `cjk-emoji`, + `agent-tui` — Claude-Code-shaped transcript + DEC-2026 status repaints — + and labeled-pathological `styles-stress`), each run ended by a DSR fence so + xterm.js-class ingest queues can't flatter the result. +- **DSR under load** — latency sampled during a paced 1 MB/s agent-TUI + stream: "typing while the agent works," quantified. + +Rules: same machine, AC power, comparable window size, no tmux/screen, hands +off the keyboard during runs. Never compare numbers across machines. + +## Sequencing + +``` +now: [1] baselines [2] #7153 testing (parallel) +next: [3] term-speed-2 revival (dedicated session) +parallel: [4] stall fixes, [6] rig extensions +after 2/3: [5] flow control +gated: [7] utility router [8] telemetry +``` + +BMW-group crash work remains the team's priority gate above all of this +(#7150's wedge guards overlap it); this plan runs measurement and revival +prep in parallel without displacing it. + +## Findings log + +### 2026-07-02 — baseline + decomposition (results committed in tools/benchmarks/results/) + +Same machine, unattended serial runs (Orca 1.4.91 prod, Terminal.app, Ghostty +1.3.1; iTerm2 not installed, VS Code pending): + +| metric | Orca prod | Terminal.app | Ghostty | +|---|---|---|---| +| DSR idle p50/p99 (ms) | 0.69 / 22.7 | 0.35 / 0.68 | 0.19 / 0.72 | +| DSR under 1 MB/s agent load p50/p99 (ms) | **134 / 292** | 0.45 / 7.9 | 0.21 / 6.1 | +| agent-tui fenced throughput | **2.0 MB/s** | 37 | 78 | +| ascii-log fenced throughput | 13 MB/s | 39 | 93 | + +Decomposition of the 51× agent-tui gap — both pipeline ends are fast: + +- Bare `@xterm/headless` (114×85, scrollback 5000): agent-tui **103 MB/s** + (`terminal-headless-parse-bench.mjs`). The xterm parser is not the problem. +- Daemon `Session` ingest (emulator + pending-output recording + fanout): + agent-tui **103 MB/s** (`session-ingest-throughput.bench.test.ts`, + `ORCA_TERMINAL_PERF_BENCH=1`). The daemon is not the problem. + +Conclusions: (1) idle latency is fine — the extra process hop costs ~0.5 ms, +so the utilityProcess router is deprioritized by data; (2) the crisis is +queueing between daemon egress and renderer parse completion — main +per-chunk processing, the 512 KB delivery/ACK pacing (ACKs fire after +renderer write callbacks, so renderer slowness throttles delivery +multiplicatively), and renderer per-chunk layers above xterm; (3) the +agent-TUI shape (DEC-2026 frames + erase/repaint) is 6.5× worse than plain +text inside Orca while being equal-cost everywhere else — profile it in the +renderer first (task #9). + +### 2026-07-02 — dev-build check of #7139/#7150 (confounded; directional only) + +Dev build of orca-performance (282-col window, 3MB fixtures, dev-mode +overhead): DSR idle p50 0.64 ms (unchanged), **DSR under load p50 161 ms** — +the cooperative-drain branch does not move the under-load class. In +hindsight this is structural: DSR replies are ordered within the output +stream, so the metric measures output-queue depth; #7139 paces draining to +protect input-send responsiveness but cannot reorder the queue. Implications: +(1) the 134 ms-class number is fixed only by shrinking the queue (producer +flow control) or raising drain rate (the 51× throughput hunt); (2) #7153's +own wins (freeze class, bounded memory, input-loss guards) must be validated +with freeze scenarios and real typing, not DSR. Also learned: dev-mode runs +are ~2× slower across the board and fences need `--dsr-timeout-ms` headroom. + +### 2026-07-02 — 51× loss attributed: scheduler fixed-nap drip (task #9) + +The renderer output scheduler (`pane-terminal-output-scheduler.ts`) drained +at most 2×16KB per tick, then slept 4ms (high-priority) / 16ms (background) +regardless of parse speed. Isolation bench (fake timers, instant-parse +terminal — `pane-terminal-output-scheduler-throughput.bench.test.ts`, +`ORCA_TERMINAL_PERF_BENCH=1`): **background cadence = 1.9 MB/s — matching +prod's measured 2.0 MB/s agent-tui ceiling**; foreground = 27 MB/s (only +when arrivals re-poke 0ms drains; Chromium's ~4ms timer clamp makes the +sustained real-world HP ceiling ~8 MB/s). Classification: pty-connection's +`isLatencySensitiveForegroundOutput` routes sizable no-recent-input chunks +to the queue, so floods always ride the drip. + +Fix (committed 9e8bb2243): high-priority drains are now **parse-clocked** — +a pacer re-arms a 0ms drain when xterm's write callback confirms the batch +parsed — and carry 8 writes/tick (128KB ≈ 1.3ms parse). Isolation ceiling: +27 → **117.6 MB/s** (parse-limited). Background cadence deliberately +unchanged (protects the focused pane; hidden panes are term-speed-2's job). +`DRAIN_TIME_BUDGET_MS` still bounds tick work (cooperative-drain intent of +#7139 preserved; its budget-yield test still passes). 621 tests green. + +Open follow-ups from this attribution: (a) end-to-end dev verification (in +progress); (b) whether main's `background:true` delivery marking demotes +visible-pane floods to the background drip — check +`window.__terminalOutputSchedulerDebug` counters in a dev run; (c) ascii-log +gap (13 vs 83 MB/s headless) — likely per-chunk `beforeWrite` side-effect +scanning; profile after (a). + +### 2026-07-03 — THE WHALE: main's retained-tail redraw path is O(tail) per chunk + +Parse-clock fix didn't move end-to-end (agent-tui still 0.7 MB/s dev). Layered +probes (renderer scheduler counters → main whole-method timer → per-section +timers → targeted micro-benches) attributed it fully: + +- Renderer receives only ~350–770 KB/s — it is **starved**, not slow. +- `OrcaRuntime.onPtyData` consumes **~93% of main's event loop** during the + flood (~950 ms/s at ~450 chunks/s ≈ 2.1 ms/chunk). +- All wrapped sub-calls (OSC scanners, agent detect, watchers, headless + track, leaves loop, mobile touch) together: **~3.5%**. The remainder is the + pty-record tail block. +- Micro-bench (`appendNormalizedToTailBuffer` with a real agent-TUI frame + containing `ESC[10A ESC[0J`): **0.888 ms/chunk at a 2,000-line tail** — 32× + the plain-append path. Cause: `appendNormalizedToMultilineTailBuffer` + materializes ~2,001 row objects per chunk (orca-runtime.ts:22324) and + `finalizeRetainedTerminalRows` allocates them all again plus runs a + trailing-whitespace regex per row (:22458) — ~4k allocations + 2k regexes + per tiny chunk, twice the tail length in O(n) passes. Every Claude-Code + frame (cursor-up + erase-below) takes this path; plain logs don't — which + is exactly the measured agent-tui vs ascii asymmetry. + +Chain: TUI flood → O(tail) work per chunk in main → main event loop +saturates → daemon socket backpressures → renderer starved at ~0.4 MB/s → +deep queue → 134 ms DSR-under-load. + +Fix (in progress): run the existing algorithm on a lazy suffix window (the +cursor's maximum upward reach, computed from the chunk) with the untouched +prefix shared by reference; differential fuzz test proves output equality +against the original implementation. Worst case (pathological full-height +cursor-up) falls back to today's cost. + +### 2026-07-03 — windowed-tail fix: partial end-to-end win; next suspect queued + +Dev-build bench after the windowed redraw-tail fix (label dev-tailfix, same +protocol as dev-parseclock): agent-tui **0.7 → 1.0 MB/s (+43%)**, DSR-under- +load **p50 161 → 108 ms, p99 624 → 154 ms (4×)**. Real movement for the +first time, but the pipeline is still far from the renderer's 27–117 MB/s +capacity — another main-side consumer remains hot. + +Next cycle (exact recipe): re-apply the whole-method main probe +(`onPtyDataMs` sampler in `pty.ts` bindProviderListeners) on the fixed +build. If onPtyData still dominates, the remaining O(tail)/per-chunk +suspects in priority order: (1) `buildTerminalWaitText` ×2 per chunk (full +tail join, 0.116 ms/chunk in prod-node isolation — likely 2-4× that in +dev); (2) `normalizeTerminalChunk` (regex over every chunk, never measured); +(3) the per-leaf duplicate tail path when `tailStateMatches` fails. If +onPtyData no longer dominates, probe the main→renderer delivery batching +next. The probe/bench cycle is mechanical: relaunch dev +(`ELECTRON_ENABLE_LOGGING=1 pnpm dev`), `orca-dev terminal create --command +" --label X --size-mb 3 --dsr-timeout-ms 120000"`, grep the log. + +### 2026-07-03 — post-fix attribution: `blockedCheck` is the remaining whale + +Post-windowed-tail probe run (dev build, agent-tui): `onPtyData` still +~90% of main's event loop (~930 ms/s). Bucket split per second: +**blockedCheck ≈ 700–790 ms (~85%)**, waitText ≈ 70, append ≈ 25 (windowed +fix confirmed), normalize ≈ 7, preview ≈ 0. + +Mechanism (orca-runtime.ts:23128 `nextTailHasNewerBlockedReason` + its +callers): per chunk, TWO full wait texts are built (`buildTerminalWaitText` +joins the whole ≤256KB tail), then the check calls `.toLowerCase()` on both +(another ~512KB of string allocation per chunk) and runs multi-pattern +blocked/ready scans (`findTerminalWaitBlockedSignal`, +`findKnownReadyPromptIndex` — lastIndexOf/regex passes over the full text) +— all to timestamp `waitBlockedAt` for `terminal wait`. + +Fix design (next session): blocked/ready prompts are end-anchored — an +actionable prompt is at the END of output. (1) Run the check on a bounded +suffix of the wait text (last ~64 lines / 16KB) instead of the full tail; +(2) cheap pre-filter: skip entirely unless the appended chunk (plus a small +carry for split keywords) can contain a blocked keyword; (3) build the two +wait texts only when the check runs. Verification mirrors the windowed-tail +pattern: keep the full-text check as reference + differential fuzz over +randomized tails/prompts (split-across-chunks cases included — the +`appendCandidateSignal` ordering semantics at :23146 must be preserved), +plus the terminal-wait contract tests. Expected effect: removes ~85% of +remaining onPtyData cost; combined with the two landed fixes should +finally unlock the pipeline toward the renderer's measured 27–117 MB/s. + +### 2026-07-03 — pipeline unlocked: three stacked fixes, 16× throughput, 9× latency + +Dev-build bench with all three fixes (parse-clocked drains 9e8bb2243, +windowed tail 4e08a28cd, throttled blocked-check 66f20258e), label +dev-blockedfix, same protocol/config as prior dev rows: + +| metric | pre-fix dev | +tail fix | +blocked fix | +|---|---|---|---| +| agent-tui MB/s | 0.7 | 1.0 | **11.5** | +| DSR load p50/p99 (ms) | 161 / 624 | 108 / 154 | **18.8 / 24.9** | +| DSR idle p50/p99 (ms) | 0.95 / 21 | 1.09 / 18 | **0.52 / 8.6** | +| ascii-log MB/s | 6.4 | 4.7 | **9.6** | + +The agent-TUI-specific penalty is gone (agent-tui ≈ cjk ≈ ascii now). The +throttled blocked-check delivered the predicted ~85% cut. Dev mode carries +~2× overhead vs prod, so the prod build should land near ~10ms DSR-under- +load — from the 134ms baseline (~13×) — pending a packaged-build rerun. +Remaining floor is structural cadence (8ms daemon batch + 4ms HP drain +ticks + xterm 12ms slices), which flow control (#6) does not target; +re-evaluate the "within 10× of Terminal.app" goal line after a prod +measurement. Next: term-speed-2 revival (#4), then flow control (#6). + +### 2026-07-03 — term-speed-2 revival: merged, green, NOT yet mergeable (perf gate) + +`revive/term-speed-2` pushed (merge a5052c35f, tip 64b6f7abe): 144 files, +typecheck clean, ~2,776 targeted tests green, all three of our fixes +verified present, chain features present and kill-switched (subagent's +six review risks recorded in its report). Bench verdict on the revived +build (dev): DSR-load p50 ~19ms holds, but **throughput regressed ~35% +unconditionally** (agent-tui 11.5 → 7.2–7.4 MB/s; all-switches-OFF round +proved the kill switches are NOT the cost) and idle p50 doubled. + +Attribution so far: main exonerated (whole-method probe: onPtyData ~60ms/s +≈ 6%); renderer reconcile + HP-first selection O(1)-checked; **daemon +CONVICTED by unit bench — `Session` ingest 103 → 39.5/47.7 MB/s (2.2–2.6×) +on the revive branch** (`session-ingest-throughput.bench.test.ts`, +ORCA_TERMINAL_PERF_BENCH=1). Cause: the chain's headless-emulator +restructure (scanner classes / query-reply forwarding / view-attribute +responder) added per-byte cost to the daemon hot path. Chunks reaching +main are now ~5.8KB vs ~650B (daemon emits slower, batches bigger). + +NEXT (fast inner loop — pure unit bench, no app restarts): on +revive/term-speed-2, diff `headless-emulator.ts`/`session.ts` vs +7839fb9db, find the per-chunk scanner cost, restore our bounded-parser +fast paths (the daemon emulator must never pay per-byte JS scanning for +bytes that contain no ESC — same pre-filter pattern as the blocked-check +keyword bypass), verify with the ingest bench back at ~100 MB/s, then +full dev bench expecting blockedfix parity (~11.5 MB/s), THEN merge to +orca-performance. A residual renderer-side share is possible once the +daemon is fixed — re-attribute after. + +Merge gate: revive branch merges only at ≥ blockedfix numbers. + +**RETRACTION (2026-07-03, later):** the daemon conviction above was a +confounded measurement — the 39–48 MB/s ingest runs executed while a dev +app was still running. On a quiet machine the revive branch ingests at +**82–109 MB/s** (≈ pre-merge) and its HeadlessEmulator alone does 99.5 MB/s +vs raw xterm 77.7. The daemon is innocent. Consequently the end-to-end +revival delta (11.5 → 7.2/7.4 dev) is also UNTRUSTED — none of those runs +were load-controlled, and unit benches show up to 2.6× machine-load +variance. Scanner pre-filters landed anyway on revive (71c89da9b; +strictly positive, 641 daemon tests green). + +**New measurement protocol (mandatory from here):** quiet machine (no dev +apps or benches concurrent), paired A/B runs back-to-back alternating +branches, n≥2 per side, report spread not just p50. The merge-gate +comparison (blockedfix vs revive) must be redone under this protocol +before any verdict. Next: run the controlled A/B; if the delta +disappears, merge revive into orca-performance and proceed to flow +control (#6); if it persists, resume attribution renderer-side (probe +pty-connection dataCallback additions per chunk). + +### 2026-07-03 — A/B gate passed; term-speed-2 MERGED to orca-performance + +Load-controlled alternating A/B (fresh app per run, n=2/side, agent-tui + +DSR-load): perf 6.7/5.2 MB/s, dsr p50 19.9/21.3, p99 107.8/218.1; revive +6.1/3.6 MB/s, dsr p50 21.4/20.3, **p99 63.4/26.1**. Verdict: latency p50 +tied, p99 better on revive, throughput within overlapping noise (revive2's +3.6 followed two runtime-busy create failures). The earlier "35% +regression" is confirmed noise. Note: both branches ~5-7 MB/s today vs +11.5 yesterday — dev benches carry ~2x day-to-day machine variance; +absolute dev numbers are only comparable within one A/B session. + +Merged revive/term-speed-2 → orca-performance; typecheck clean, 288 +post-merge spot tests green. orca-performance now = main-ish base + #7153 ++ three perf fixes + full term-speed-2 chain (kill-switched, default ON) ++ scanner pre-filters. Extended user testing now covers everything. +Remaining from the revival agent's risk list: gate×drain e2e specs +(terminal-hidden-*, parked-memory, sleep-wake) still not run — queue them. +Next: producer flow control (#6) per design §5; prod packaged-build bench +for the real headline numbers. + +### 2026-07-03 — flow control merged; goal-state accounting + +Producer flow control merged to orca-performance (348aeb325): protocol +v19 `pausePty`/`resumePty`, 256KB/32KB watermarks on main's pendingData, +node-pty kernel backpressure, 5s daemon-side lost-resume failsafe + +main-side pause re-assert, resume on every teardown path, version-gated +(v≤18/SSH no-op), kill switch `PRODUCER_FLOW_CONTROL_ENABLED` +(ipc/pty.ts:143), 29 new tests. Typecheck + 292 post-merge spot tests +green. + +**Definition-of-done accounting:** +- 51× loss: ATTRIBUTED AND FIXED (three fixes; agent-tui 0.7→11.5 MB/s + and DSR-load p50 161→18.8 dev, results committed). +- term-speed-2: REVIVED AND MERGED (A/B gate passed). +- Flow control: IMPLEMENTED AND MERGED. +- "Within 10× of Terminal.app (4.5ms)": RE-SCOPED to pending a packaged + RC measurement. Evidence: dev = 18.8ms with ~2× dev overhead → prod + projection ~9-10ms ≈ 20× Terminal.app (vs 300× at baseline). The + remaining gap is structural cadence (daemon 8ms batch, renderer drain + ticks, xterm 12ms parse slices) — tunable follow-ups, distinct from the + waste class this initiative eliminated. Prod verification path: + electron-vite preview CANNOT host the bench (CLI-created panes are not + adopted by the preview window's renderer → no ACKs → pending-cap drop; + two attempts, documented) — measure on the next packaged RC cut from + orca-performance using the committed rig + protocol instead. + +**Deferred, ordered:** (1) sync orca-performance with main — conflicts +incl. stream-opcode collision (chain `Ack=12` vs main's #7205-era +`Metadata=12`; renumber chain side, audit mobile/web stream consumers); +(2) chain's e2e specs (hidden parking / parked memory / sleep-wake) — +gate×drain risk; (3) cadence tuning toward the 10× line; (4) rig +extensions + P90 telemetry (tasks #3/#8). + +### 2026-07-03 — PROD VERDICT: v1.4.121-rc.0 benchmarked (the headline numbers) + +Same rig, same protocol, same machine as the 1.4.91 baseline: + +| metric | 1.4.91 baseline | v1.4.121-rc.0 | change | +|---|---|---|---| +| DSR idle p50 | 0.69 ms | **0.44 ms** | = Terminal.app (0.45) | +| DSR under load p50 | 134 ms | **18.6 ms** | 7.2x | +| DSR under load p99 | 292 ms | **29.7 ms** | 9.8x | +| agent-tui | 2.0 MB/s | **11.2 MB/s** | 5.6x | +| styles-stress | 7.8 MB/s | **10.4 MB/s** | 1.3x | +| ascii-log | 13 MB/s | 11.0 MB/s | ~0.85x | +| cjk-emoji | 15 MB/s | 12.2 MB/s | ~0.81x | + +Reading: the anomalous TUI penalty is GONE — all four fixtures now sit at +a uniform ~11-12 MB/s, which is the scheduler pacing ceiling, not parse +CPU (prod ≈ dev for both latency and throughput; the pipeline is +cadence-bound, so faster prod code changes nothing). That uniform cap +also explains plain-text dipping slightly below baseline: ascii/cjk used +to run unpaced ahead of the old scheduler; now everything flows through +the same parse-clocked path. Goal line check: 18.6 ms = 41x Terminal.app +under load (goal was 10x = 4.5 ms) — NOT met; down from 300x. Idle IS at +parity. The remaining 4x is the named cadence stack (daemon 8 ms batch, +scheduler drain ticks + 8x16KB per-tick budget, xterm 12 ms slices) — +next lever, tunable, tracked as follow-up. p99 tail (the freeze class) +is 29.7 ms — users cannot perceive it. + +Caveat: measured on the user's live app (this session active in it); +idle p99 118 ms reflects that activity, not the terminal path. + +### 2026-07-03 — Same-engine reference: VS Code head-to-head (same machine, same rig) + +| metric | Orca v1.4.121-rc.0 | VS Code | verdict | +|---|---|---|---| +| DSR idle p50 | **0.44 ms** | 7.00 ms | Orca 16x faster | +| DSR load p50 | 18.6 ms | **7.18 ms** | VS Code 2.6x faster | +| DSR load p99 | **29.7 ms** | 43.4 ms | Orca 1.5x better tail | +| ascii-log | **11.0 MB/s** | 9.0 | Orca +22% | +| cjk-emoji | 12.2 | 11.3 | tie | +| agent-tui | 11.2 | 11.7 | tie | +| styles-stress | **10.4 MB/s** | 2.0 | Orca 5.2x | + +Orca now beats or ties the best-known xterm.js terminal on 5 of 6 +metrics — including 16x at idle (what users feel all day) and 5x on +SGR-heavy output — and holds a better p99 tail under load. Throughput +sits at the shared engine ceiling (~9-12 MB/s), confirming the class +limit. + +The one loss (load p50) has a clean mechanism: VS Code's producer flow +control caps unacked output at ~100KB, so its standing queue is +~100KB / 11.7 MB/s ≈ 8.5 ms — matching its 7.18. Our standing queue +(18.6 ms ≈ ~200KB at 11 MB/s) is set by the main→renderer ACK window +(512KB/pty high water) + drain re-arm cadence (Chromium clamps nested +setTimeout to ~4ms). Two levers, both cheap to test: (1) MessageChannel +drain scheduling (sub-ms re-arm; also raises the throughput ceiling); +(2) tighter effective in-flight window on the renderer delivery path. +Target: VS Code's ~7ms class or below without giving back throughput. + +### 2026-07-03 — Batch windows were the gap: dev DSR-load p50 19 -> 8.0ms + +Lever results (dev, 3MB protocol, same session): +- MessageChannel drains (2434dfaae): 19.01ms — NO change. Proved the + ~19ms was NOT queue depth: at 1MB/s vs ~11MB/s capacity (9% util) + there is no standing queue. Kept (correct, removes a real clamp). +- Batch windows 8->2ms on BOTH hops (e67a91d7a: daemon + STREAM_DATA_BATCH_INTERVAL_MS + main PTY_BATCH_INTERVAL_MS): + **p50 8.00 / p90 10.13 / p99 12.26ms** (from 19.01/22.7/28.1). + Throughput unchanged (agent-tui 9.8 vs 9.1, ambient noise). 239 + batcher+pty tests green after timing updates. + +Dev-mode 8.0ms already matches VS Code prod (7.18); prod build should +land BELOW VS Code. p99: ours 12.3 vs VS Code 43.4. The remaining +fixed-latency terms are renderer/xterm-internal (12ms parse slices). +Note: main's interactive bypass (input-gated) means real keystroke echo +skips batching entirely — the DSR metric understates real typing +responsiveness; VS Code measured on the same freight path, comparison +fair. + +Next: cut RC, confirm in prod, re-baseline vs Terminal.app (expect +~8-15x from 300x at baseline; goal line 10x = 4.5ms now plausibly in +reach). + +### 2026-07-03 — Chain e2e debt PAID: all 6 hidden-pane specs green + +terminal-hidden-view-parking (parks + restores rich TUI on reveal; bell/ +title side effects live while parked), terminal-sleep-wake-restore +(output restored + input accepted after wake), terminal-parked-memory +(renderer memory released on park; views retained when kill-switched +off): 6/6 passed, electron-headless, 1.1m. The gate x drain interplay — +the revival's top flagged risk — now has e2e coverage on the exact +branch the RC ships from. Remaining garble-hardening: differential +hide/reveal fuzz harness (next build), reveal-time seq diagnostics. + +## Success criteria (baseline-relative; finalize after task 1) + +- DSR-under-load p90 in Orca within striking distance of iTerm2 on the same + box; zero DSR timeouts (today's freeze class). +- Fenced agent-tui throughput ≥ VS Code on the same box. +- Idle RSS with 0–1 agents materially down (target set after the memory + harness lands; hidden-pane parking is the main lever). +- Zero >100 ms event-loop stalls in main/renderer during a 10 MB flood. +- Production P90 typing latency down and monitored continuously. diff --git a/package.json b/package.json index 4bae0747aae..b9a3f9d124b 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "test:e2e:terminal-perf:scale:report": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-report-gate.mjs", "test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs", "test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs", + "test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs", "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", "test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "win-update-e2e": "node tools/win-update-e2e/run.mjs", @@ -91,6 +92,7 @@ "bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs", "bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs", "bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs", + "bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs", "bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs" }, "dependencies": { @@ -257,7 +259,8 @@ "patchedDependencies": { "node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch", "@xterm/addon-ligatures@0.11.0-beta.287": "config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch", - "@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch" + "@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch", + "@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch" } }, "reactDoctor": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf5fc522fd1..b17bd6adb8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ patchedDependencies: '@xterm/addon-ligatures@0.11.0-beta.287': hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch + '@xterm/addon-serialize@0.15.0-beta.287': + hash: 81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9 + path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch '@xterm/addon-webgl@0.20.0-beta.286': hash: c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19 path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch @@ -36,7 +39,7 @@ importers: version: 2.5.6 '@xterm/addon-serialize': specifier: 0.15.0-beta.287 - version: 0.15.0-beta.287(@xterm/xterm@6.1.0-beta.287) + version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287) '@xterm/headless': specifier: 6.1.0-beta.287 version: 6.1.0-beta.287 @@ -9288,7 +9291,7 @@ snapshots: dependencies: '@xterm/xterm': 6.1.0-beta.287 - '@xterm/addon-serialize@0.15.0-beta.287(@xterm/xterm@6.1.0-beta.287)': + '@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287)': dependencies: '@xterm/xterm': 6.1.0-beta.287 diff --git a/src/main/daemon/daemon-background-transient-facts.test.ts b/src/main/daemon/daemon-background-transient-facts.test.ts new file mode 100644 index 00000000000..acfcdb959f4 --- /dev/null +++ b/src/main/daemon/daemon-background-transient-facts.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { BackgroundTransientFactRelay } from './daemon-background-transient-facts' +import type { DaemonTransientFact } from './types' + +function createRelay() { + const emitted: { sessionId: string; fact: DaemonTransientFact }[] = [] + const relay = new BackgroundTransientFactRelay((sessionId, fact) => + emitted.push({ sessionId, fact }) + ) + return { relay, emitted } +} + +describe('BackgroundTransientFactRelay', () => { + it('emits a bell fact for a backgrounded session', () => { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + relay.onSessionData('s1', 'build output\x07more') + expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'bell' } }]) + }) + + it('keeps OSC escape state across chunks — a title terminator BEL is not a bell', () => { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + relay.onSessionData('s1', '\x1b]0;my working title') + relay.onSessionData('s1', ' continued\x07') + expect(emitted).toEqual([]) + }) + + it('emits nothing for sessions that are not backgrounded', () => { + const { relay, emitted } = createRelay() + relay.onSessionData('s1', 'ding\x07') + expect(emitted).toEqual([]) + }) + + it('emits command-finished with the OSC 133;D exit code', () => { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + relay.onSessionData('s1', '\x1b]133;D;0\x07') + expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'command-finished', exitCode: 0 } }]) + }) + + it('stops emitting after un-background and reports the toggle as a state change', () => { + const { relay, emitted } = createRelay() + expect(relay.setSessionBackground('s1', true)).toBe(true) + expect(relay.setSessionBackground('s1', true)).toBe(false) + expect(relay.setSessionBackground('s1', false)).toBe(true) + expect(relay.setSessionBackground('s1', false)).toBe(false) + relay.onSessionData('s1', 'ding\x07') + expect(emitted).toEqual([]) + }) + + it('drops the tracker on session exit', () => { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + relay.onSessionExit('s1') + expect(relay.isBackgrounded('s1')).toBe(false) + relay.onSessionData('s1', 'ding\x07') + expect(emitted).toEqual([]) + }) + + it('never arms the stale-working-title timer (titles are main-authoritative)', () => { + vi.useFakeTimers() + try { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + // A working-spinner title followed by title-less output would arm the + // 3s stale timer if titles were being tracked. + relay.onSessionData('s1', '\x1b]0;⠋ Claude\x07') + relay.onSessionData('s1', 'output without titles') + expect(vi.getTimerCount()).toBe(0) + expect(emitted).toEqual([]) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/daemon/daemon-background-transient-facts.ts b/src/main/daemon/daemon-background-transient-facts.ts new file mode 100644 index 00000000000..3867ab0eddd --- /dev/null +++ b/src/main/daemon/daemon-background-transient-facts.ts @@ -0,0 +1,106 @@ +/** + * Daemon-side transient-fact scanning for backgrounded sessions. + * + * While a session is backgrounded (its pane hidden in the renderer), the + * daemon→main stream copy may be keep-tail thinned under backlog — but the + * notification-bearing facts inside those bytes must never be lost. This + * relay runs the SAME shared scanners main uses (terminal-side-effect + * authority doc: semantics must not drift) over every raw chunk BEFORE any + * drop decision, and emits compact transientFact stream events in byte order. + * Main suppresses its own copies of these four scanners between the + * sessionBackgroundMarker handoffs, so no fact double-fires or goes missing. + * + * Title/agent-status facts are deliberately NOT relayed: they converge from + * the delivered kept tail (stale-working-title timer, snapshot-restores-title + * -state) and main fuses them with synthetic spinner frames the daemon never + * sees. + */ +import { + createTerminalTitleTracker, + type TerminalTitleTracker +} from '../../shared/terminal-output-side-effects' +import type { DaemonTransientFact } from './types' + +// Kill switch for the whole background keep-tail mechanism (thinning + +// daemon-side fact authority): ORCA_DAEMON_BACKGROUND_STREAM_DROP=0. +export const BACKGROUND_STREAM_DROP_ENABLED = process.env.ORCA_DAEMON_BACKGROUND_STREAM_DROP !== '0' + +export class BackgroundTransientFactRelay { + private trackersBySessionId = new Map() + private emitFact: (sessionId: string, fact: DaemonTransientFact) => void + + constructor(emitFact: (sessionId: string, fact: DaemonTransientFact) => void) { + this.emitFact = emitFact + } + + isBackgrounded(sessionId: string): boolean { + return this.trackersBySessionId.has(sessionId) + } + + backgroundedSessionIdSuffixes(): string[] { + return Array.from(this.trackersBySessionId.keys(), (id) => id.slice(-10)) + } + + /** Returns false when this was a no-op (already in the requested state) so + * the caller can skip a duplicate handoff marker — resyncs after adoption + * re-send the whole background set. */ + setSessionBackground(sessionId: string, background: boolean): boolean { + if (background === this.isBackgrounded(sessionId)) { + return false + } + if (background) { + this.trackersBySessionId.set( + sessionId, + createTerminalTitleTracker({ + onBell: () => this.emitFact(sessionId, { kind: 'bell' }), + onCommandFinished: (exitCode) => + this.emitFact(sessionId, { kind: 'command-finished', exitCode }), + // Note: recreating the tracker on each background toggle resets the + // PR-link dedup memory, so a link re-printed across toggles can + // re-fire — consumers treat pr-link as a latest-association update. + onPrLink: (link) => this.emitFact(sessionId, { kind: 'pr-link', link }), + onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' }) + }) + ) + } else { + this.disposeTracker(sessionId) + } + return true + } + + /** Prime a fresh tracker's cross-chunk carry with the emulator's dangling + * incomplete escape at handoff time, so a sequence split across the + * background toggle neither mints a phantom bell nor loses its fact. A + * partial tail contains no complete sequence, so this can never fire. */ + seedSessionScanState(sessionId: string, partialEscapeTailAnsi: string): void { + if (partialEscapeTailAnsi.length > 0) { + this.trackersBySessionId + .get(sessionId) + ?.handleChunk(partialEscapeTailAnsi, { titleScanData: '' }) + } + } + + /** Feed one raw chunk, in byte order, BEFORE it is enqueued for delivery — + * facts must be captured even when the chunk is later keep-tail dropped. */ + onSessionData(sessionId: string, data: string): void { + // titleScanData:'' skips title extraction (titles stay main-authoritative) + // and keeps the stale-working-title timer permanently unarmed — only the + // four transient scanners consume the chunk. + this.trackersBySessionId.get(sessionId)?.handleChunk(data, { titleScanData: '' }) + } + + onSessionExit(sessionId: string): void { + this.disposeTracker(sessionId) + } + + dispose(): void { + for (const sessionId of Array.from(this.trackersBySessionId.keys())) { + this.disposeTracker(sessionId) + } + } + + private disposeTracker(sessionId: string): void { + this.trackersBySessionId.get(sessionId)?.dispose() + this.trackersBySessionId.delete(sessionId) + } +} diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 9dfc97532fa..34c04043950 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -131,7 +131,9 @@ async function main(): Promise { // Signal readiness to parent via IPC (if available) if (process.send) { - process.send({ type: 'ready' }) + // Why: Windows has no cheap OS query for a child's start time, so the + // daemon self-reports it here for the pid file's pid-recycling guard. + process.send({ type: 'ready', startedAtMs: Date.now() - process.uptime() * 1000 }) } daemonLog.log('ready') diff --git a/src/main/daemon/daemon-errors.ts b/src/main/daemon/daemon-errors.ts new file mode 100644 index 00000000000..99d19818a3c --- /dev/null +++ b/src/main/daemon/daemon-errors.ts @@ -0,0 +1,23 @@ +// Error classes shared across the daemon protocol boundary (client, server, +// host). Split from types.ts, which is capped for wire-shape declarations. + +export class TerminalAttachCanceledError extends Error { + constructor(sessionId: string) { + super(`Attach canceled for session ${sessionId}`) + this.name = 'TerminalAttachCanceledError' + } +} + +export class DaemonProtocolError extends Error { + constructor(message: string) { + super(message) + this.name = 'DaemonProtocolError' + } +} + +export class SessionNotFoundError extends Error { + constructor(sessionId: string) { + super(`Session not found: ${sessionId}`) + this.name = 'SessionNotFoundError' + } +} diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 94d0564d09b..defbc8fd09a 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -13,7 +13,9 @@ import { parseLinuxBootTimeSeconds, parseLinuxProcStartTicks, parseDaemonPidFile, - startTimeMatches + parseWindowsProcessIdentityJson, + startTimeMatches, + startTimesWithinTolerance } from './daemon-health' import type { SubprocessHandle } from './session' @@ -125,6 +127,26 @@ describe('daemon health', () => { await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false) }) + it('classifies a hello-rejected daemon as rejected, not unreachable', async () => { + // Why: 'rejected' means the daemon answered and refused adoption — the + // launcher may replace it. 'unreachable' also covers a wedged-but-live + // daemon, which must never be replaced while its pipe accepts connections. + const server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + + try { + writeFileSync(tokenPath, 'not-the-daemon-token', { mode: 0o600 }) + await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('rejected') + await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false) + } finally { + await server.shutdown() + } + }) + it('does not unlink a live socket when the pid file does not match this daemon', async () => { if (process.platform === 'win32') { return @@ -286,6 +308,42 @@ describe('startTimeMatches', () => { }) }) +describe('parseWindowsProcessIdentityJson', () => { + it('parses command line and start time from the CIM query output', () => { + expect( + parseWindowsProcessIdentityJson( + '{"cmd":"Orca.exe daemon-entry.js","start":1700000000000}\r\n' + ) + ).toEqual({ commandLine: 'Orca.exe daemon-entry.js', startedAtMs: 1_700_000_000_000 }) + }) + + it('returns a null start time when CreationDate was unavailable', () => { + expect( + parseWindowsProcessIdentityJson('{"cmd":"Orca.exe daemon-entry.js","start":null}') + ).toEqual({ commandLine: 'Orca.exe daemon-entry.js', startedAtMs: null }) + }) + + it('returns null for a missing process or inaccessible command line', () => { + expect(parseWindowsProcessIdentityJson('')).toBeNull() + expect(parseWindowsProcessIdentityJson(' \r\n')).toBeNull() + expect(parseWindowsProcessIdentityJson('{"cmd":null,"start":123}')).toBeNull() + expect(parseWindowsProcessIdentityJson('not-json')).toBeNull() + }) +}) + +describe('startTimesWithinTolerance', () => { + it('fails open when either side is null', () => { + expect(startTimesWithinTolerance(null, 1_700_000_000_000, 1_500)).toBe(true) + expect(startTimesWithinTolerance(1_700_000_000_000, null, 1_500)).toBe(true) + expect(startTimesWithinTolerance(null, null, 1_500)).toBe(true) + }) + + it('matches within tolerance and rejects outside it', () => { + expect(startTimesWithinTolerance(1_700_000_001_000, 1_700_000_000_000, 1_500)).toBe(true) + expect(startTimesWithinTolerance(1_700_000_005_000, 1_700_000_000_000, 1_500)).toBe(false) + }) +}) + describe('killStaleDaemon pid identity guards', () => { let dir: string let socketPath: string diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 96d4a885dea..50e7226f73c 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -24,8 +24,17 @@ const RESOLVER_HEALTH_CHECK_TIMEOUT_MS = 3_000 const KILL_WAIT_MS = 3_000 const KILL_POLL_MS = 100 const START_TIME_TOLERANCE_MS = 1_500 +// Why: on Windows the pid file's startedAtMs is the daemon's self-reported +// Node start time, while verification reads the OS process creation time — +// the gap between them is the exe bootstrap, which AV/disk pressure can +// stretch to seconds. Pid recycling differs by minutes-to-days, so a wide +// tolerance keeps the guard effective without false mismatches. +const WIN32_START_TIME_TOLERANCE_MS = 10_000 -export type DaemonHealth = 'healthy' | 'unreachable' | 'pty-spawn-unhealthy' +// 'rejected' means the daemon answered and refused the handshake (bad token, +// foreign protocol) — it can never be adopted, unlike 'unreachable', which +// also covers a live-but-wedged daemon that simply missed the RPC budget. +export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy' type ParsedDaemonPid = { pid: number @@ -134,13 +143,13 @@ export function checkDaemonHealth(socketPath: string, tokenPath: string): Promis try { message = JSON.parse(line) as Record } catch { - settle('unreachable') + settle('rejected') return } if (message.type === 'hello') { if (!(message as HelloResponse).ok) { - settle('unreachable') + settle('rejected') return } // Why: a protocol-live daemon with a stale cwd or node-pty helper @@ -371,6 +380,10 @@ export function getProcessStartedAtMs(pid: number): number | null { } if (process.platform === 'win32') { + // Why: the only OS source is a CIM query costing a powershell spawn — + // too slow for this sync path. Windows pid files instead carry the + // daemon's self-reported start time from its ready message, and + // isDaemonProcess verifies it against CIM CreationDate asynchronously. return null } @@ -387,27 +400,61 @@ export function getProcessStartedAtMs(pid: number): number | null { } export function startTimeMatches(pid: number, expectedStartedAtMs: number | null): boolean { - if (expectedStartedAtMs === null) { + return startTimesWithinTolerance( + getProcessStartedAtMs(pid), + expectedStartedAtMs, + START_TIME_TOLERANCE_MS + ) +} + +// Why: fail open on null — a pid file or OS query without a start time must +// not veto an otherwise-matching daemon (adoption safety beats recycle safety). +export function startTimesWithinTolerance( + actualStartedAtMs: number | null, + expectedStartedAtMs: number | null, + toleranceMs: number +): boolean { + if (expectedStartedAtMs === null || actualStartedAtMs === null) { return true } - - const actualStartedAtMs = getProcessStartedAtMs(pid) - if (actualStartedAtMs === null) { - return true - } - - return Math.abs(actualStartedAtMs - expectedStartedAtMs) <= START_TIME_TOLERANCE_MS + return Math.abs(actualStartedAtMs - expectedStartedAtMs) <= toleranceMs } const execFileAsync = promisify(execFile) +export type WindowsProcessIdentity = { + commandLine: string + startedAtMs: number | null +} + +export function parseWindowsProcessIdentityJson(stdout: string): WindowsProcessIdentity | null { + const trimmed = stdout.trim() + if (!trimmed) { + return null + } + try { + const parsed = JSON.parse(trimmed) as { cmd?: unknown; start?: unknown } + if (typeof parsed.cmd !== 'string' || !parsed.cmd) { + return null + } + return { + commandLine: parsed.cmd, + startedAtMs: + typeof parsed.start === 'number' && Number.isFinite(parsed.start) ? parsed.start : null + } + } catch { + return null + } +} + // Why: the only reliable command-line source on Windows is a CIM query, which // costs a full powershell.exe spawn (300-800ms cold, worse under Defender). // Async because the sync version measurably froze the Electron main thread at // startup for the whole spawn (benchmark: ~0.5s warm, 3s timeout cap cold). -// Timed under ORCA_STARTUP_DIAGNOSTICS so the cold-start benchmark can -// attribute startup cost to these checks. -async function queryWindowsProcessCommandLine(pid: number): Promise { +// CreationDate rides along in the same spawn so start-time verification adds +// zero extra process launches. Timed under ORCA_STARTUP_DIAGNOSTICS so the +// cold-start benchmark can attribute startup cost to these checks. +async function queryWindowsProcessIdentity(pid: number): Promise { const startedAt = performance.now() try { const { stdout } = await execFileAsync( @@ -416,14 +463,17 @@ async function queryWindowsProcessCommandLine(pid: number): Promise { if (process.platform === 'win32') { - return queryWindowsProcessCommandLine(pid) + return (await queryWindowsProcessIdentity(pid))?.commandLine ?? null } try { diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 39f05c641ee..06344c0759f 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -25,6 +25,8 @@ const { isPackagedMock, probeSocketExistsMock, writeFileSyncMock, + readFileSyncMock, + unlinkSyncMock, netConnectMock, forkMock, checkDaemonHealthMock, @@ -34,6 +36,7 @@ const { isDaemonStaleForCurrentBundleMock, killStaleDaemonMock, getProcessStartedAtMsMock, + parseDaemonPidFileMock, daemonClientMock, spawnerInstances, ensureRunningOverrides, @@ -51,6 +54,13 @@ const { const probeSocketExistsMock = vi.fn((_path?: string) => false) const writeFileSyncMock = vi.fn() + // Why: readFileSync throws by default so legacyDaemonProcessMayBeAlive's + // catch treats every legacy pid file as unreadable — matching the pre-fix + // cleanup behavior every existing test was written against. + const readFileSyncMock = vi.fn((): string => { + throw new Error('ENOENT') + }) + const unlinkSyncMock = vi.fn() const forkMock = vi.fn() const netConnectMock = vi.fn(() => { // Why: the real probeSocket() in daemon-init connects to the socket and @@ -83,7 +93,10 @@ const { const getDaemonLaunchIdentityMock = vi.fn(() => 'match') const isDaemonStaleForCurrentBundleMock = vi.fn(() => false) const killStaleDaemonMock = vi.fn(async () => true) - const getProcessStartedAtMsMock = vi.fn(() => 1_000_000) + const getProcessStartedAtMsMock = vi.fn((): number | null => 1_000_000) + const parseDaemonPidFileMock = vi.fn( + (): { pid: number; startedAtMs: number | null } | null => null + ) const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() { return { @@ -145,6 +158,8 @@ const { isPackagedMock, probeSocketExistsMock, writeFileSyncMock, + readFileSyncMock, + unlinkSyncMock, netConnectMock, forkMock, checkDaemonHealthMock, @@ -154,6 +169,7 @@ const { isDaemonStaleForCurrentBundleMock, killStaleDaemonMock, getProcessStartedAtMsMock, + parseDaemonPidFileMock, daemonClientMock, spawnerInstances, ensureRunningOverrides, @@ -214,7 +230,8 @@ vi.mock('electron', () => ({ vi.mock('fs', () => ({ mkdirSync: vi.fn(), existsSync: (p: string) => probeSocketExistsMock(p) || p.includes('.pid'), - unlinkSync: vi.fn(), + unlinkSync: unlinkSyncMock, + readFileSync: readFileSyncMock, writeFileSync: writeFileSyncMock })) @@ -229,7 +246,8 @@ vi.mock('./daemon-health', () => ({ healthCheckDaemon: healthCheckDaemonMock, isDaemonStaleForCurrentBundle: isDaemonStaleForCurrentBundleMock, killStaleDaemon: killStaleDaemonMock, - getProcessStartedAtMs: getProcessStartedAtMsMock + getProcessStartedAtMs: getProcessStartedAtMsMock, + parseDaemonPidFile: parseDaemonPidFileMock })) vi.mock('./client', () => ({ DaemonClient: daemonClientMock })) @@ -358,6 +376,15 @@ async function importFresh() { daemonClientMock.mockClear() probeSocketExistsMock.mockClear() writeFileSyncMock.mockClear() + readFileSyncMock.mockReset() + readFileSyncMock.mockImplementation(() => { + throw new Error('ENOENT') + }) + unlinkSyncMock.mockClear() + parseDaemonPidFileMock.mockReset() + parseDaemonPidFileMock.mockReturnValue(null) + getProcessStartedAtMsMock.mockReset() + getProcessStartedAtMsMock.mockReturnValue(1_000_000) // Why: importing daemon-init *after* resetModules means the module-level // `spawner`/`adapter`/`restartInFlight` start fresh for every test, which is // the only way to reliably exercise the "first-time init" path and the @@ -1551,7 +1578,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).not.toHaveBeenCalled() }) - it('replaces a health-check-failing daemon when live sessions cannot be verified', async () => { + it('replaces a health-check-failing daemon when live sessions cannot be verified and the pipe is dead', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1595,6 +1622,203 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).toHaveBeenCalled() }) + it('adopts an unresponsive daemon whose pipe still accepts connections (update-relaunch wedge)', async () => { + // Why: the Windows update-relaunch regression — post-install disk/AV load + // wedges the daemon past the 3s health budget AND the 5s hello budget of + // the session-list re-verification, while its sessions are still alive. + // The old fail-closed path killed the daemon here. A pipe that accepts a + // raw connection proves the daemon is alive, so the launcher must adopt. + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => { + throw new Error('Hello response timed out') + }), + request: vi.fn(), + disconnect: vi.fn() + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + checkDaemonHealthMock.mockResolvedValueOnce('unreachable') + // The raw pipe probe succeeds even though every RPC timed out. + probeSocketExistsMock.mockReturnValue(true) + netConnectMock.mockImplementationOnce(() => { + const handlers: Record void)[]> = { connect: [], error: [] } + return { + on(event: string, cb: () => void) { + handlers[event]?.push(cb) + if (event === 'connect') { + queueMicrotask(() => cb()) + } + return this + }, + removeListener(event: string, cb: () => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, + destroy() {} + } + }) + + await launcher('/fake/socket', '/fake/token') + + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) + + it('replaces a hello-rejected daemon even though its pipe accepts connections', async () => { + // Why: 'rejected' means the daemon answered and refused the handshake — + // it can never be adopted, so keeping it alive would strand the app with + // no terminals forever. Replacement stays the only recovery. + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => { + throw new Error('Hello rejected') + }), + request: vi.fn(), + disconnect: vi.fn() + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + checkDaemonHealthMock.mockResolvedValueOnce('rejected') + probeSocketExistsMock.mockReturnValue(true) + forkMock.mockImplementationOnce(() => ({ + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready' })) + } + return this + }, + off() { + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + })) + + await launcher('/fake/socket', '/fake/token') + + expect(killStaleDaemonMock).toHaveBeenCalledWith( + FAKE_RUNTIME_DIR, + '/fake/socket', + '/fake/token' + ) + expect(forkMock).toHaveBeenCalled() + }) + + it('adopts a healthy daemon whose pid-file identity cannot be verified (null startedAtMs metadata)', async () => { + // Why: the regression contract — a pid file with startedAtMs null (all + // pre-fix Windows pid files) resolves launch identity to 'unknown'. With + // a live daemon answering on the pipe, that must ADOPT, never replace. + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getDaemonLaunchIdentityMock.mockReturnValueOnce('unknown') + isPackagedMock.mockReturnValue(true) + + await launcher('/fake/socket', '/fake/token') + + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) + + it('writes the daemon self-reported start time to the pid file when the OS query returns null', async () => { + // Why: getProcessStartedAtMs has no cheap Windows implementation, so the + // pid file's pid-recycling guard depends on the ready-message fallback. + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + checkDaemonHealthMock.mockResolvedValueOnce('unreachable') + getProcessStartedAtMsMock.mockReturnValue(null) + forkMock.mockImplementationOnce(() => ({ + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_700_000_123_456 })) + } + return this + }, + off() { + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + })) + + await launcher('/fake/socket', '/fake/token') + + expect(writeFileSyncMock).toHaveBeenCalledWith( + `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, + JSON.stringify({ + pid: 12345, + startedAtMs: 1_700_000_123_456, + entryPath: FAKE_DAEMON_ENTRY_PATH, + appVersion: '1.2.3' + }), + { mode: 0o600 } + ) + }) + + it('keeps legacy daemon pid/token files when the probe fails but the pid-file process is alive', async () => { + // Why: deleting a live-but-wedged legacy daemon's token file makes its + // sessions permanently unadoptable — no future launch could authenticate. + const mod = await importFresh() + readFileSyncMock.mockReturnValue('{"pid":123}') + // process.pid is guaranteed alive, so the liveness probe succeeds. + parseDaemonPidFileMock.mockReturnValue({ pid: process.pid, startedAtMs: null }) + + await mod.initDaemonPtyProvider() + + const legacyUnlinks = unlinkSyncMock.mock.calls.filter( + ([p]) => typeof p === 'string' && (p.includes('.token') || p.includes('.pid')) + ) + expect(legacyUnlinks).toEqual([]) + }) + + it('cleans up legacy daemon pid/token files when the probe fails and the process is gone', async () => { + const mod = await importFresh() + readFileSyncMock.mockReturnValue('{"pid":123}') + // Why: spy on process.kill so the liveness probe deterministically reports + // "no such process" without depending on an unallocated real pid. + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('ESRCH') + }) + parseDaemonPidFileMock.mockReturnValue({ pid: 999_999, startedAtMs: null }) + + try { + await mod.initDaemonPtyProvider() + } finally { + killSpy.mockRestore() + } + + const tokenUnlinks = unlinkSyncMock.mock.calls.filter( + ([p]) => typeof p === 'string' && p.includes('.token') + ) + expect(tokenUnlinks.length).toBeGreaterThan(0) + }) + it('replaces a health-check-failing daemon when no live sessions would be lost', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 4de553cde16..ad0227bae86 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -8,7 +8,7 @@ module-level spawner/adapter singletons must stay co-located so a future change cannot leave them drifting out of sync. */ import { join } from 'node:path' import { app } from 'electron' -import { mkdirSync, existsSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { fork } from 'node:child_process' import { connect } from 'node:net' import { @@ -34,7 +34,8 @@ import { getProcessStartedAtMs, checkDaemonHealth, isDaemonStaleForCurrentBundle, - killStaleDaemon + killStaleDaemon, + parseDaemonPidFile } from './daemon-health' import { collectPinnedDaemonVersions, @@ -255,9 +256,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { // Why: a busy machine (e.g. right after an update) can time out the // health check while the daemon is alive and owning terminals. Killing // it would destroy every live session, so re-verify with a session list - // first. Only a verified non-empty list preserves: a daemon that cannot - // even list sessions cannot serve terminals, and replacing it is the - // only recovery. + // first. const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) if (liveSessionCount !== null && liveSessionCount > 0) { if (health === 'pty-spawn-unhealthy') { @@ -275,6 +274,20 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { ) return createPreservedDaemonHandle(runtimeDir) } + // Why: on a Windows update relaunch the daemon can be wedged past every + // RPC budget (final checkpoint flush + installer/AV disk pressure), so + // both the health check AND the session list time out while sessions + // are still alive — failing closed here is what killed those sessions. + // A pipe that still accepts connections proves a live daemon: adopt it + // and let the adapter reconnect once the daemon drains. 'rejected' + // means the daemon answered and refused the handshake — it can never be + // adopted, so replacement stays the only recovery. + if (liveSessionCount === null && health !== 'rejected' && (await probeSocket(socketPath))) { + console.warn( + '[daemon] Preserving unresponsive daemon because its socket still accepts connections' + ) + return createPreservedDaemonHandle(runtimeDir) + } } // Why: a raw socket can outlive a broken or wedged daemon. Kill by PID @@ -398,11 +411,19 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { // killStaleDaemon() can verify the pid still belongs to the daemon // we forked before SIGTERMing it. Prevents pid-recycling hazard // where the OS hands the daemon's old pid to an unrelated process. + // Why the ready-message fallback: Windows has no cheap OS query + // for start time, so the daemon self-reports it — without this the + // recycling guard was permanently inert on win32. + const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs writeFileSync( getDaemonPidPath(runtimeDir), serializeDaemonPidFile({ pid: child.pid, - startedAtMs: getProcessStartedAtMs(child.pid), + startedAtMs: + getProcessStartedAtMs(child.pid) ?? + (typeof selfReported === 'number' && Number.isFinite(selfReported) + ? selfReported + : null), entryPath, appVersion: app.getVersion() }), @@ -821,6 +842,21 @@ export async function cleanupDaemonForProtocol( return { cleaned: didRequestShutdown || didKillStaleDaemon, killedCount } } +function legacyDaemonProcessMayBeAlive(runtimeDir: string, protocolVersion: number): boolean { + try { + const parsed = parseDaemonPidFile( + readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8') + ) + if (!parsed) { + return false + } + process.kill(parsed.pid, 0) + return true + } catch { + return false + } +} + async function createLegacyDaemonAdapters(runtimeDir: string): Promise { const adapters: DaemonPtyAdapter[] = [] for (const protocolVersion of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) { @@ -830,23 +866,27 @@ async function createLegacyDaemonAdapters(runtimeDir: string): Promise void>> + resume: ReturnType void>> _simulateData: (data: string) => void _simulateExit: (code: number) => void } { @@ -43,6 +46,8 @@ function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & { getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), + pause: vi.fn<() => void>(), + resume: vi.fn<() => void>(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), forceKill: vi.fn(), signal: vi.fn(), @@ -185,6 +190,135 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + describe('producer flow control', () => { + it('routes pausePty/resumePty notifications to the daemon session subprocess', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + + adapter.pauseProducer(id) + await waitFor(() => lastSubprocess.pause.mock.calls.length > 0) + + adapter.resumeProducer(id) + await waitFor(() => lastSubprocess.resume.mock.calls.length > 0) + }) + + it('sends pause/resume as fire-and-forget notifications on the current protocol', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + try { + adapter.pauseProducer(id) + adapter.resumeProducer(id) + expect(notifySpy).toHaveBeenCalledWith('pausePty', { sessionId: id }) + expect(notifySpy).toHaveBeenCalledWith('resumePty', { sessionId: id }) + } finally { + notifySpy.mockRestore() + } + }) + + it('never sends pause/resume notifications on a legacy protocol version', () => { + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 18 }) + try { + legacy.pauseProducer('legacy-session') + legacy.resumeProducer('legacy-session') + expect(notifySpy).not.toHaveBeenCalled() + } finally { + legacy.dispose() + notifySpy.mockRestore() + } + }) + + it('owes paused sessions a resumePty on the next connect after a socket drop', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + adapter.pauseProducer(id) + await waitFor(() => lastSubprocess.pause.mock.calls.length > 0) + + // Drop the daemon out from under the adapter: the in-flight pause has no + // matching resume anymore. + await server.shutdown() + await waitFor(() => !(adapter as unknown as { client: DaemonClient }).client.isConnected()) + + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: (opts) => { + lastSpawnOpts = opts + lastSubprocess = createMockSubprocess() + return lastSubprocess + } + }) + await server.start() + + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + try { + // Any reconnecting operation must flush the owed resume first. + await adapter.listProcesses() + expect(notifySpy).toHaveBeenCalledWith('resumePty', { sessionId: id }) + } finally { + notifySpy.mockRestore() + } + }) + }) + + describe('background stream thinning compatibility', () => { + it('reports background state on the authoritative-snapshot protocol', () => { + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + try { + adapter.setPtyBackgrounded('current-session', true) + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'current-session', + background: true + }) + } finally { + notifySpy.mockRestore() + } + }) + + it('keeps preserved v19 sessions unthinned because their snapshots have no sequence', () => { + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) + try { + legacy.setPtyBackgrounded('legacy-session', true) + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'legacy-session', + background: false + }) + } finally { + legacy.dispose() + notifySpy.mockRestore() + } + }) + + it('clears a preserved v19 background hint before attaching its stream', async () => { + const ensureConnectedSpy = vi + .spyOn(DaemonClient.prototype, 'ensureConnected') + .mockResolvedValue() + const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({ + isNew: true, + pid: null, + shellState: 'unsupported', + snapshot: null + } as never) + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) + try { + await legacy.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 }) + + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'legacy-session', + background: false + }) + expect(notifySpy.mock.invocationCallOrder[0]).toBeLessThan( + requestSpy.mock.invocationCallOrder[0] + ) + } finally { + legacy.dispose() + notifySpy.mockRestore() + requestSpy.mockRestore() + ensureConnectedSpy.mockRestore() + } + }) + }) + describe('getAppliedSize', () => { it('reports the spawn dims before any resize', async () => { const { id } = await adapter.spawn({ cols: 80, rows: 24 }) @@ -225,6 +359,23 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + describe('getBufferSnapshot', () => { + it('returns the daemon model with its absolute stream sequence', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + lastSubprocess._simulateData('complete hidden output\r\n') + + const snapshot = await adapter.getBufferSnapshot(id, { scrollbackRows: 123 }) + + expect(snapshot).toMatchObject({ + data: expect.stringContaining('complete hidden output'), + cols: 80, + rows: 24, + seq: 'complete hidden output\r\n'.length, + source: 'headless' + }) + }) + }) + describe('shutdown', () => { it('kills the session', async () => { const { id } = await adapter.spawn({ cols: 80, rows: 24 }) @@ -884,6 +1035,105 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) + it('checkpoints before keep-history shutdown so sleep can cold restore latest output', async () => { + const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } + const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS + adapterClass.CHECKPOINT_INTERVAL_MS = 10_000 + + try { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: 'sleep-checkpoint' + }) + const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint') + + lastSubprocess._simulateData('latest before sleep\r\n') + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + + expect(checkpointSpy).toHaveBeenCalledWith( + id, + expect.objectContaining({ snapshotAnsi: expect.stringContaining('latest before sleep') }) + ) + expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true) + + const restored = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(restored.coldRestore?.scrollback).toContain('latest before sleep') + historyAdapter.ackColdRestore(id) + + const remountAfterAck = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(remountAfterAck.coldRestore).toBeUndefined() + } finally { + adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval + } + }) + + it('cold restores the second sleep/wake cycle with post-wake output', async () => { + const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } + const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS + adapterClass.CHECKPOINT_INTERVAL_MS = 10_000 + + try { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: 'sleep-wake-cycles' + }) + + lastSubprocess._simulateData('first cycle content\r\n') + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + const metaPath = join(historyDir, getHistorySessionDirName(id), 'meta.json') + const checkpointPath = join(historyDir, getHistorySessionDirName(id), 'checkpoint.json') + // Why: keep-history sleep stays unclean so cold restore remains eligible; + // the final checkpoint is the deterministic handoff signal. + expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull() + expect(JSON.parse(readFileSync(checkpointPath, 'utf-8')).snapshotAnsi).toContain( + 'first cycle content' + ) + + const firstWake = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(firstWake.coldRestore?.scrollback).toContain('first cycle content') + historyAdapter.ackColdRestore(id) + expect(historyAdapter.hasPty(id)).toBe(true) + + lastSubprocess._simulateData('second cycle content\r\n') + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull() + expect(JSON.parse(readFileSync(checkpointPath, 'utf-8')).snapshotAnsi).toContain( + 'second cycle content' + ) + + const secondWake = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(secondWake.coldRestore?.scrollback).toContain('second cycle content') + } finally { + adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval + } + }) + it('writes meta.json with endedAt on exit', async () => { historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index cac7f14e480..3535b410404 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -21,6 +21,8 @@ import { } from './types' import type { IPtyProvider, + PtyBackgroundStreamEvent, + PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnOptions, PtySpawnResult @@ -79,8 +81,13 @@ export class DaemonPtyAdapter implements IPtyProvider { // lock, each would fork its own daemon process. This promise coalesces // concurrent respawns so only the first caller forks; the rest await it. private respawnPromise: Promise | null = null - private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + private dataListeners: ((payload: { + id: string + data: string + sequenceChars?: number + }) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] + private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] private removeEventListener: (() => void) | null = null private initialCwds = new Map() // Why: React re-renders and StrictMode double-mounts can call createOrAttach @@ -93,6 +100,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // mount → ??? The sticky cache returns the same cold restore data on the // second mount until the renderer explicitly acknowledges it. private coldRestoreCache = new Map() + private sleepRestoreSessionIds = new Set() private activeSessionIds = new Set() private dirtySessionVersions = new Map() // Why: a cold-restored session is a fresh shell whose on-disk checkpoint and @@ -109,6 +117,19 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: incremental checkpoints require the takePendingOutput RPC (v13+). // Against older daemons the tick falls back to full-snapshot checkpoints. private supportsIncrementalCheckpoints: boolean + // Why: producer pause/resume notifications require v19+; legacy daemons + // must never see them, so gating makes them silent no-ops there. + private supportsProducerFlowControl: boolean + private supportsAuthoritativeBufferSnapshots: boolean + private pausedProducerSessionIds = new Set() + // Why tracked here: the daemon's background set (keep-tail stream thinning + // + transient-fact scan authority) dies with the daemon process/socket; + // re-sync it on a fresh connection so hidden panes stay thinned. + private backgroundedSessionIds = new Set() + // Why: a daemon that survives a socket drop can still hold a pause whose + // resume died with the connection. Owe those sessions a resume on the next + // connect; the daemon's 5s failsafe covers the window in between. + private producerResumesOwedOnReconnect = new Set() private static CHECKPOINT_INTERVAL_MS = 5_000 // Why: a streaming session (build logs, `yes`) re-triggers a full multi-MB // snapshot checkpoint on every 5s tick via pending-buffer overflow or the @@ -133,6 +154,14 @@ export class DaemonPtyAdapter implements IPtyProvider { this.respawnFn = opts.respawn ?? null this.supportsCheckpoints = this.protocolVersion >= 4 this.supportsIncrementalCheckpoints = this.protocolVersion >= 13 + this.supportsProducerFlowControl = this.protocolVersion >= 19 + this.supportsAuthoritativeBufferSnapshots = this.protocolVersion >= 20 + this.client.onDisconnected(() => { + for (const id of this.pausedProducerSessionIds) { + this.producerResumesOwedOnReconnect.add(id) + } + this.pausedProducerSessionIds.clear() + }) } getHistoryManager(): HistoryManager | null { @@ -155,6 +184,12 @@ export class DaemonPtyAdapter implements IPtyProvider { } await this.ensureConnected() + // Why before createOrAttach: a preserved v19 daemon may remember this + // session as backgrounded. Ordered control delivery clears it before any + // newly attached stream bytes can be thinned without a recoverable seq. + if (!this.supportsAuthoritativeBufferSnapshots) { + this.setPtyBackgrounded(sessionId, false) + } // Why: detect crash-recovery history before spawning a replacement PTY so // the revived shell inherits the recovered cwd and dimensions instead of @@ -232,6 +267,14 @@ export class DaemonPtyAdapter implements IPtyProvider { // but should still return the cached cold restore data. const cachedRestore = this.coldRestoreCache.get(sessionId) if (cachedRestore) { + // Why: wake after sleep also lands here, and the slept session's active + // tracking and history writer were dropped when sleep killed the PTY. + // Without re-registering both, checkpoints stop after wake and the + // second sleep/wake cycle restores a blank terminal. + this.activeSessionIds.add(sessionId) + if (this.historyManager) { + this.historyManager.reopenSession(sessionId) + } return { id: sessionId, pid, @@ -275,13 +318,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // an unclean shutdown → return saved scrollback so the renderer can // display the previous terminal content. if (restoreInfo && (result.isNew || result.historySeeded === false)) { - // Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer - // (vim/less/htop); normal sessions use the full snapshot + rehydrate. - // Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback) - // would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone* - // (no rehydrateSequences — they start with \x1b[?1049h, which the - // renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as - // normal scrollback. An empty snapshot still yields null → no-op. + const coldRestore = this.buildColdRestorePayload(restoreInfo) const canReanchorHistory = !scrollback || result.historySeeded === true // Why: use registerWriter (not openSession) to avoid deleting the // existing checkpoint.json. If the revived daemon crashes again before @@ -300,8 +337,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.historyManager.suspendSession(sessionId) } } - if (scrollback) { - const coldRestore = { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks } + if (coldRestore) { this.coldRestoreCache.set(sessionId, coldRestore) return { id: sessionId, @@ -348,13 +384,24 @@ export class DaemonPtyAdapter implements IPtyProvider { } const isAltScreen = result.snapshot.modes.alternateScreen - const snapshotPayload = result.snapshot.rehydrateSequences + result.snapshot.snapshotAnsi + const snapshotPayload = + result.snapshot.scrollbackAnsi + + result.snapshot.rehydrateSequences + + result.snapshot.snapshotAnsi + // Why kitty flags ride beside the payload, not inside it: the snapshot + // string reaches renderer xterms too, where POST_REPLAY_REATTACH_RESET's + // deliberate kitty reset must win. Only the runtime emulator re-seed + // consumes the flags (terminal-query-authority.md §kitty). + const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags return { id: sessionId, pid, snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, + ...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0 + ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } + : {}), isReattach: true, isAlternateScreen: isAltScreen, // Why: carry the mid-escape tail so the renderer can write it after the @@ -368,6 +415,9 @@ export class DaemonPtyAdapter implements IPtyProvider { async attach(id: string): Promise { await this.ensureConnected() + if (!this.supportsAuthoritativeBufferSnapshots) { + this.setPtyBackgrounded(id, false) + } await this.client.request('createOrAttach', { sessionId: id, @@ -390,16 +440,62 @@ export class DaemonPtyAdapter implements IPtyProvider { this.client.notify('resize', { sessionId: id, cols, rows }) } + pauseProducer(id: string): void { + if (!this.supportsProducerFlowControl) { + return + } + this.pausedProducerSessionIds.add(id) + this.client.notify('pausePty', { sessionId: id }) + } + + resumeProducer(id: string): void { + this.producerResumesOwedOnReconnect.delete(id) + if (!this.supportsProducerFlowControl) { + return + } + this.pausedProducerSessionIds.delete(id) + this.client.notify('resumePty', { sessionId: id }) + } + + // Why fire-and-forget (like pausePty): a delivery hint for the daemon's + // keep-tail stream thinning. + setPtyBackgrounded(id: string, background: boolean): void { + if (!this.supportsProducerFlowControl) { + return + } + // Why: preserved v19 daemons can thin but cannot return the absolute + // snapshot sequence needed to recover a gap. Clear their stale hint too. + const safeBackground = this.supportsAuthoritativeBufferSnapshots && background + if (safeBackground) { + this.backgroundedSessionIds.add(id) + } else { + this.backgroundedSessionIds.delete(id) + } + this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground }) + } + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { - // Why: sleep/exact-stop must preserve restorable terminal history, - // so force a final checkpoint before killing the daemon session. + // Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run. + // Force a final snapshot so wake can restore the pane users left. if (opts.keepHistory) { + if (this.checkpointInFlight) { + await this.checkpointInFlight + } await this.checkpointSessions([id], { final: true, teardown: true }) + const restoreInfo = this.historyReader?.detectColdRestore(id) ?? null + const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null + if (coldRestore) { + this.coldRestoreCache.set(id, coldRestore) + this.sleepRestoreSessionIds.add(id) + } } await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false }) this.activeSessionIds.delete(id) this.dirtySessionVersions.delete(id) - this.coldRestoreCache.delete(id) + if (!opts.keepHistory) { + this.coldRestoreCache.delete(id) + this.sleepRestoreSessionIds.delete(id) + } // Why: the !keepHistory close path doesn't take a final checkpoint, so a // session stranded in sessionsNeedingFullCheckpoint would never be cleared. // (Under keepHistory the final checkpoint above already cleared the flag, so @@ -435,12 +531,30 @@ export class DaemonPtyAdapter implements IPtyProvider { ackColdRestore(sessionId: string): void { this.coldRestoreCache.delete(sessionId) + this.sleepRestoreSessionIds.delete(sessionId) } clearTombstone(sessionId: string): void { this.killedSessionTombstones.delete(sessionId) } + private buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null { + // Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer + // (vim/less/htop); normal sessions use the full snapshot + rehydrate. + // Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback) + // would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone* + // (no rehydrateSequences — they start with \x1b[?1049h, which the + // renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as + // normal scrollback. An empty snapshot still yields null → no-op. + const scrollback = restoreInfo.modes.alternateScreen + ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null + : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi + if (!scrollback) { + return null + } + return { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks } + } + async sendSignal(id: string, signal: string): Promise { await this.client.request('signal', { sessionId: id, signal }) } @@ -478,6 +592,44 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + async getBufferSnapshot( + id: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + if (!this.supportsAuthoritativeBufferSnapshots) { + return null + } + try { + const result = await this.client.request('getSnapshot', { + sessionId: id, + ...(typeof opts.scrollbackRows === 'number' ? { scrollbackRows: opts.scrollbackRows } : {}) + }) + const snapshot = result.snapshot + // Why: older v19 daemons have no absolute output sequence. Their snapshot + // cannot safely reconcile stream bytes still queued on the other socket. + if (!snapshot || typeof snapshot.outputSequence !== 'number') { + return null + } + return { + data: snapshot.rehydrateSequences + snapshot.snapshotAnsi, + scrollbackAnsi: snapshot.scrollbackAnsi, + cols: snapshot.cols, + rows: snapshot.rows, + cwd: snapshot.cwd, + lastTitle: snapshot.lastTitle, + seq: snapshot.outputSequence, + source: 'headless', + oscLinks: snapshot.oscLinks, + alternateScreen: snapshot.modes.alternateScreen, + ...(snapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } + : {}) + } + } catch { + return null + } + } + async clearBuffer(id: string): Promise { await this.client.request('clearScrollback', { sessionId: id }) this.markSessionDirty(id) @@ -608,6 +760,8 @@ export class DaemonPtyAdapter implements IPtyProvider { this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() this.sessionsNeedingFullCheckpoint.clear() + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() this.stopCheckpointTimer() for (const id of ids) { this.coldRestoreCache.delete(id) @@ -640,7 +794,9 @@ export class DaemonPtyAdapter implements IPtyProvider { return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s })) } - onData(callback: (payload: { id: string; data: string }) => void): () => void { + onData( + callback: (payload: { id: string; data: string; sequenceChars?: number }) => void + ): () => void { this.dataListeners.push(callback) return () => { const idx = this.dataListeners.indexOf(callback) @@ -650,6 +806,16 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { + this.backgroundStreamListeners.push(callback) + return () => { + const idx = this.backgroundStreamListeners.indexOf(callback) + if (idx !== -1) { + this.backgroundStreamListeners.splice(idx, 1) + } + } + } + onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { return () => {} } @@ -669,6 +835,8 @@ export class DaemonPtyAdapter implements IPtyProvider { this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() this.coldRestoreCache.clear() + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() this.removeEventListener?.() this.removeEventListener = null // Why: final checkpoints are written daemon-side in TerminalHost.dispose() @@ -705,6 +873,13 @@ export class DaemonPtyAdapter implements IPtyProvider { this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() this.coldRestoreCache.clear() + // Why: the detached daemon keeps these PTYs alive for warm reattach; a + // pause left behind would block their shells for a failsafe window. + for (const id of this.pausedProducerSessionIds) { + this.client.notify('resumePty', { sessionId: id }) + } + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() this.removeEventListener?.() this.removeEventListener = null this.client.disconnect() @@ -712,8 +887,36 @@ export class DaemonPtyAdapter implements IPtyProvider { private async ensureConnected(): Promise { await this.client.ensureConnected() + // Why sampled before setupEventRouting: routing is (re)installed exactly + // once per connection, so "no listener yet" identifies a fresh connect — + // the only time the daemon-side backgrounded set needs a resync (it is + // process state that died with the previous daemon/socket). + const isFreshConnection = this.removeEventListener === null this.setupEventRouting() this.scheduleCheckpointTimer() + this.flushOwedProducerResumes() + if (isFreshConnection) { + this.resyncBackgroundedSessions() + } + } + + private resyncBackgroundedSessions(): void { + for (const id of this.backgroundedSessionIds) { + // Harmless no-op for sessions the daemon doesn't know (yet). + this.client.notify('setSessionBackground', { sessionId: id, background: true }) + } + } + + private flushOwedProducerResumes(): void { + if (this.producerResumesOwedOnReconnect.size === 0) { + return + } + for (const id of this.producerResumesOwedOnReconnect) { + // Why: resuming a session the fresh daemon doesn't know is a harmless + // no-op; leaving a survivor paused would waste 5s of failsafe latency. + this.client.notify('resumePty', { sessionId: id }) + } + this.producerResumesOwedOnReconnect.clear() } private stopCheckpointTimer(): void { @@ -1027,6 +1230,13 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + private emitBackgroundStreamEvent(payload: PtyBackgroundStreamEvent): void { + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.backgroundStreamListeners]) { + listener(payload) + } + } + private async doRespawn(message = '[daemon] Daemon died — respawning'): Promise { console.warn(message) this.removeEventListener?.() @@ -1050,12 +1260,50 @@ export class DaemonPtyAdapter implements IPtyProvider { this.markSessionDirty(event.sessionId) // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration for (const listener of [...this.dataListeners]) { - listener({ id: event.sessionId, data: event.payload.data }) + listener({ + id: event.sessionId, + data: event.payload.data, + ...(event.payload.sequenceChars === undefined + ? {} + : { sequenceChars: event.payload.sequenceChars }) + }) } + } else if (event.event === 'sessionBackgroundMarker') { + this.emitBackgroundStreamEvent({ + id: event.sessionId, + kind: 'backgroundMarker', + background: event.payload.background, + ...(event.payload.scanSeedAnsi !== undefined + ? { scanSeedAnsi: event.payload.scanSeedAnsi } + : {}) + }) + } else if (event.event === 'dataGap') { + this.emitBackgroundStreamEvent({ + id: event.sessionId, + kind: 'dataGap', + droppedChars: event.payload.droppedChars, + ...(event.payload.sequenceChars === undefined + ? {} + : { sequenceChars: event.payload.sequenceChars }) + }) + } else if (event.event === 'transientFact') { + this.emitBackgroundStreamEvent({ + id: event.sessionId, + kind: 'transientFact', + fact: event.payload + }) } else if (event.event === 'exit') { this.activeSessionIds.delete(event.sessionId) this.dirtySessionVersions.delete(event.sessionId) - this.coldRestoreCache.delete(event.sessionId) + // Why: an exited session must not be owed a resume on reconnect — a + // reused sessionId would receive a stray resumePty. Same for the + // background set: a reused id must start un-thinned. + this.pausedProducerSessionIds.delete(event.sessionId) + this.producerResumesOwedOnReconnect.delete(event.sessionId) + this.backgroundedSessionIds.delete(event.sessionId) + if (!this.sleepRestoreSessionIds.has(event.sessionId)) { + this.coldRestoreCache.delete(event.sessionId) + } // Why: an exited session can never be checkpointed again, so its pending // full-checkpoint flag is dead state. Without this, a cold-restored // session that exits before its first checkpoint leaks a permanent entry. diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 47d44aa807c..a8c2b2232d8 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { DaemonPtyRouter } from './daemon-pty-router' import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types' type AdapterMock = DaemonPtyAdapter & { - emitData: (id: string, data: string) => void + emitData: (id: string, data: string, sequenceChars?: number) => void + emitBackground: (event: PtyBackgroundStreamEvent) => void emitExit: (id: string, code: number) => void } @@ -24,7 +25,9 @@ function createAdapter( reconcileResult?: { alive: string[]; killed: string[] } ): AdapterMock { const writes: { id: string; data: string }[] = [] - const dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = + [] + const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] const exitListeners: ((payload: { id: string; code: number }) => void)[] = [] return { spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { @@ -44,6 +47,8 @@ function createAdapter( writes.push({ id, data }) }), resize: vi.fn(), + setPtyBackgrounded: vi.fn(), + getBufferSnapshot: vi.fn(async () => null), shutdown: vi.fn(async (id: string) => { const idx = sessions.indexOf(id) if (idx !== -1) { @@ -62,12 +67,23 @@ function createAdapter( revive: vi.fn(async () => {}), getDefaultShell: vi.fn(async () => '/bin/zsh'), getProfiles: vi.fn(async () => []), - onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { - dataListeners.push(callback) + onData: vi.fn( + (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { + dataListeners.push(callback) + return () => { + const idx = dataListeners.indexOf(callback) + if (idx !== -1) { + dataListeners.splice(idx, 1) + } + } + } + ), + onBackgroundStreamEvent: vi.fn((callback: (payload: PtyBackgroundStreamEvent) => void) => { + backgroundListeners.push(callback) return () => { - const idx = dataListeners.indexOf(callback) + const idx = backgroundListeners.indexOf(callback) if (idx !== -1) { - dataListeners.splice(idx, 1) + backgroundListeners.splice(idx, 1) } } }), @@ -85,9 +101,14 @@ function createAdapter( reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }), dispose: vi.fn(), disconnectOnly: vi.fn(async () => {}), - emitData: (id: string, data: string) => { + emitData: (id: string, data: string, sequenceChars?: number) => { for (const listener of dataListeners) { - listener({ id, data }) + listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) + } + }, + emitBackground: (event: PtyBackgroundStreamEvent) => { + for (const listener of backgroundListeners) { + listener(event) } }, emitExit: (id: string, code: number) => { @@ -118,6 +139,62 @@ describe('DaemonPtyRouter', () => { expect(current.write).toHaveBeenCalledWith(fresh.id, 'new\n') }) + it('routes background hints and authoritative snapshots to the session owner', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['legacy-session']) + const snapshot = { + data: 'legacy frame', + cols: 80, + rows: 24, + seq: 42, + source: 'headless' as const + } + vi.mocked(legacy.getBufferSnapshot).mockResolvedValue(snapshot) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + + router.setPtyBackgrounded('legacy-session', true) + await expect( + router.getBufferSnapshot('legacy-session', { scrollbackRows: 50_000 }) + ).resolves.toEqual(snapshot) + + expect(legacy.setPtyBackgrounded).toHaveBeenCalledWith('legacy-session', true) + expect(current.setPtyBackgrounded).not.toHaveBeenCalled() + expect(legacy.getBufferSnapshot).toHaveBeenCalledWith('legacy-session', { + scrollbackRows: 50_000 + }) + }) + + it('forwards gap events and explicit sequence accounting from every adapter', () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy') + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + const dataSpy = vi.fn() + const backgroundSpy = vi.fn() + router.onData(dataSpy) + router.onBackgroundStreamEvent(backgroundSpy) + + current.emitData('current-session', '\x1b[6n', 0) + legacy.emitBackground({ + id: 'legacy-session', + kind: 'dataGap', + droppedChars: 512, + sequenceChars: 508 + }) + + expect(dataSpy).toHaveBeenCalledWith({ + id: 'current-session', + data: '\x1b[6n', + sequenceChars: 0 + }) + expect(backgroundSpy).toHaveBeenCalledWith({ + id: 'legacy-session', + kind: 'dataGap', + droppedChars: 512, + sequenceChars: 508 + }) + }) + it('drops a legacy mapping after the routed session exits', async () => { const current = createAdapter('current') const legacy = createAdapter('legacy', ['legacy-session']) diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 1f80f770cd9..ed43c663325 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,6 +1,8 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { IPtyProvider, + PtyBackgroundStreamEvent, + PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnOptions, PtySpawnResult @@ -11,7 +13,11 @@ export class DaemonPtyRouter implements IPtyProvider { private legacy: DaemonPtyAdapter[] private sessionAdapters = new Map() private unsubscribers: (() => void)[] = [] - private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + private dataListeners: ((payload: { + id: string + data: string + sequenceChars?: number + }) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { @@ -76,6 +82,18 @@ export class DaemonPtyRouter implements IPtyProvider { this.adapterFor(id).resize(id, cols, rows) } + pauseProducer(id: string): void { + this.adapterFor(id).pauseProducer(id) + } + + resumeProducer(id: string): void { + this.adapterFor(id).resumeProducer(id) + } + + setPtyBackgrounded(id: string, background: boolean): void { + this.adapterFor(id).setPtyBackgrounded(id, background) + } + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { await this.adapterFor(id).shutdown(id, opts) // Why: sleep passes keepHistory=true and re-spawns against the same @@ -105,6 +123,13 @@ export class DaemonPtyRouter implements IPtyProvider { return (await this.adapterFor(id).getAppliedSize?.(id)) ?? null } + async getBufferSnapshot( + id: string, + opts?: { scrollbackRows?: number } + ): Promise { + return await this.adapterFor(id).getBufferSnapshot(id, opts) + } + async clearBuffer(id: string): Promise { await this.adapterFor(id).clearBuffer(id) } @@ -144,7 +169,9 @@ export class DaemonPtyRouter implements IPtyProvider { return this.current.getProfiles() } - onData(callback: (payload: { id: string; data: string }) => void): () => void { + onData( + callback: (payload: { id: string; data: string; sequenceChars?: number }) => void + ): () => void { this.dataListeners.push(callback) return () => { const idx = this.dataListeners.indexOf(callback) @@ -154,6 +181,17 @@ export class DaemonPtyRouter implements IPtyProvider { } } + onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { + const unsubscribes = this.allAdapters().map((adapter) => + adapter.onBackgroundStreamEvent(callback) + ) + return () => { + for (const unsubscribe of unsubscribes) { + unsubscribe() + } + } + } + onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { return () => {} } diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index ae017e0ef02..7de928d7543 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -408,6 +408,72 @@ describe('DaemonServer', () => { vi.useRealTimers() } }) + + it('keeps exit behind final output held by the shallow socket gate', async () => { + vi.useFakeTimers() + try { + let subprocess: ReturnType + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => { + subprocess = createMockSubprocess() + return subprocess + } + }) + const daemon = server as unknown as DaemonServerPrivate + const refillCallbacks: (() => void)[] = [] + const controlSocket = { destroy: vi.fn() } as unknown as Socket + const streamSocket = { + destroyed: false, + destroy: vi.fn(), + writableLength: 128 * 1024, + write: vi.fn((_line: string, callback?: () => void) => { + if (callback) { + refillCallbacks.push(callback) + } + return true + }) + } as unknown as Socket & { + write: ReturnType + writableLength: number + } + + daemon.clients.set('client-1', { + clientId: 'client-1', + controlSocket, + streamSocket + }) + await daemon.routeRequest('client-1', { + id: 'req-1', + type: 'createOrAttach', + payload: { sessionId: 'test-session', cols: 80, rows: 24 } + }) + + const finalOutput = 'final-output'.repeat(1024) + subprocess!._simulateData(finalOutput) + subprocess!._simulateExit(42) + + // Only the refill sentinel may enter the already-deep socket; exit + // remains queued behind the final data for this session. + expect(refillCallbacks).toHaveLength(1) + const beforeRefill = streamSocket.write.mock.calls.map(([line]) => JSON.parse(String(line))) + expect(beforeRefill).toHaveLength(1) + expect(beforeRefill[0]).toMatchObject({ event: 'data', payload: { data: '' } }) + + streamSocket.writableLength = 0 + refillCallbacks[0]() + const delivered = streamSocket.write.mock.calls + .map( + ([line]) => JSON.parse(String(line)) as { event: string; payload: { data?: string } } + ) + .filter((message) => message.payload.data !== '') + expect(delivered.map((message) => message.event)).toEqual(['data', 'exit']) + expect(delivered[0]?.payload.data).toBe(finalOutput) + } finally { + vi.useRealTimers() + } + }) }) describe('authentication', () => { diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 7d98babfdee..7c604341c2c 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -9,6 +9,15 @@ import { StringDecoder } from 'node:string_decoder' import { encodeNdjson, createNdjsonParser } from './ndjson' import { TerminalHost } from './terminal-host' import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import { + BackgroundTransientFactRelay, + BACKGROUND_STREAM_DROP_ENABLED +} from './daemon-background-transient-facts' +import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' +import { + recordDaemonStreamBacklogEvent, + startDaemonStreamBacklogProbe +} from './daemon-stream-backlog-probe' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' import type { SubprocessHandle } from './session' import { checkPtySpawnHealth } from './pty-subprocess' @@ -53,8 +62,40 @@ export class DaemonServer { private log: DaemonFileLog private clients = new Map() - private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId)) + private streamDataBatcher = new DaemonStreamDataBatcher( + (clientId) => this.clients.get(clientId), + { + isSessionDroppable: (sessionId) => + BACKGROUND_STREAM_DROP_ENABLED && this.transientFactRelay.isBackgrounded(sessionId), + salvageDroppedData: (dropped) => { + if (!dropped.includes('\x1b')) { + return '' + } + const extracted = extractHiddenStartupRendererQueryData(dropped, '') + return ( + extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData + ) + } + } + ) + // Fact scan authority for backgrounded sessions — facts ride the stream + // queue as control entries so they hold byte order with the data around + // them (a fact jumping the queue could arrive after the reveal snapshot + // that already reflects it). + private transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => { + const clientId = this.streamClientIdBySessionId.get(sessionId) + if (clientId) { + this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, { + type: 'event', + event: 'transientFact', + sessionId, + payload: fact + }) + } + }) + private streamClientIdBySessionId = new Map() private lastInputAtBySessionId = new Map() + private stopStreamBacklogProbe: () => void = () => {} // Why: main-process PTY IPC has the same recent-input bypass, but daemon // output reaches main only after this stream layer. Keeping the window here @@ -69,6 +110,14 @@ export class DaemonServer { this.token = randomUUID() this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth + this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({ + clients: Array.from(this.clients.values(), (client) => ({ + clientId: client.clientId, + socketBufferedBytes: client.streamSocket?.writableLength ?? 0, + batcherQueuedChars: this.streamDataBatcher.queuedCharsForClient(client.clientId) + })), + backgroundedSessionIdSuffixes: this.transientFactRelay.backgroundedSessionIdSuffixes() + })) this.log = opts.log ?? createNoopDaemonFileLog() } @@ -97,6 +146,8 @@ export class DaemonServer { } async shutdown(): Promise { + this.stopStreamBacklogProbe() + this.transientFactRelay.dispose() this.host.dispose() this.streamDataBatcher.clear() @@ -225,6 +276,10 @@ export class DaemonServer { const previous = client.streamSocket socket.removeAllListeners('data') client.streamSocket = socket + // Why: 'drain' is the wake-up for the batcher's shallow-gate held bulk. + socket.on('drain', () => { + this.streamDataBatcher.flush(client.clientId) + }) const cleanup = (): void => { socket.removeListener('close', cleanup) @@ -296,6 +351,9 @@ export class DaemonServer { : {}), streamClient: { onData: (data) => { + // Scan BEFORE enqueue: the batcher may keep-tail drop this + // chunk, but its facts must be captured regardless. + this.transientFactRelay.onSessionData(p.sessionId, data) const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId) const isInteractiveOutput = data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS && @@ -307,24 +365,38 @@ export class DaemonServer { }) }, onExit: (code) => { - // Why: exit tears down renderer handlers; flush final output first - // so the last few milliseconds of PTY data are not stranded. + // Why: exit tears down renderer handlers, so it must ride the + // ordered queue behind final output even when the shallow socket + // gate holds that output for a later drain pass. this.log.log('session-exited', { sessionId: p.sessionId, code }) + this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, { + type: 'event', + event: 'exit', + sessionId: p.sessionId, + payload: { code } + }) this.streamDataBatcher.flush(clientId) + recordDaemonStreamBacklogEvent('sessionExit', { + sessionIdSuffix: p.sessionId.slice(-10) + }) + this.transientFactRelay.onSessionExit(p.sessionId) + this.streamClientIdBySessionId.delete(p.sessionId) this.lastInputAtBySessionId.delete(p.sessionId) - if (client?.streamSocket) { - client.streamSocket.write( - encodeNdjson({ - type: 'event', - event: 'exit', - sessionId: p.sessionId, - payload: { code } - }) - ) - } } } }) + this.streamClientIdBySessionId.set(p.sessionId, clientId) + // Why an attach-time marker: the adapter resyncs the background set on + // a fresh connection, which can precede this attach — main's scan + // suppression must still start at the head of the new stream. + if (this.transientFactRelay.isBackgrounded(p.sessionId)) { + this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId: p.sessionId, + payload: { background: true } + }) + } this.log.log(result.isNew ? 'session-created' : 'session-attached', { sessionId: p.sessionId, pid: result.pid @@ -365,6 +437,56 @@ export class DaemonServer { } return {} + case 'pausePty': + this.host.pauseProducer(request.payload.sessionId) + return {} + + case 'resumePty': + this.host.resumeProducer(request.payload.sessionId) + return {} + + case 'setSessionBackground': { + const sessionId = request.payload.sessionId + const background = request.payload.background === true + recordDaemonStreamBacklogEvent('setSessionBackground', { + sessionIdSuffix: sessionId.slice(-10), + background + }) + if (!this.transientFactRelay.setSessionBackground(sessionId, background)) { + return {} + } + if (background) { + // Prime the fresh relay tracker with the emulator's dangling + // incomplete escape so a sequence split across the handoff parses + // exactly as if the relay had seen the whole stream. + this.transientFactRelay.seedSessionScanState( + sessionId, + this.host.getPartialEscapeTailAnsi(sessionId) + ) + } + const streamClientId = this.streamClientIdBySessionId.get(sessionId) + if (!streamClientId) { + // Not attached yet — the attach-time marker covers the handoff. + return {} + } + // Reveal deliberately does NOT discard or force-flush the queued + // tail: main's model (hidden-output recovery buffer, tail previews) + // needs those bytes — a finished program's last output lives there — + // and the normal flush/drain loop delivers them within milliseconds + // (bounded ≤ the keep-tail drop cap), in order, ahead of the marker. + const scanSeedAnsi = background ? '' : this.host.getPartialEscapeTailAnsi(sessionId) + this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId, + payload: { + background, + ...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}) + } + }) + return {} + } + case 'kill': this.lastInputAtBySessionId.delete(request.payload.sessionId) this.log.log('session-killed', { @@ -397,8 +519,26 @@ export class DaemonServer { case 'listSessions': return { sessions: this.host.listSessions() } - case 'getSnapshot': - return { snapshot: this.host.getSnapshot(request.payload.sessionId) } + case 'getSnapshot': { + const snapshotStart = performance.now() + const requestedScrollbackRows = request.payload.scrollbackRows + const scrollbackRows = + typeof requestedScrollbackRows === 'number' && Number.isFinite(requestedScrollbackRows) + ? Math.max(0, Math.min(50_000, Math.floor(requestedScrollbackRows))) + : undefined + const snapshot = this.host.getSnapshot(request.payload.sessionId, { scrollbackRows }) + const snapshotMs = performance.now() - snapshotStart + if (snapshotMs >= 25) { + // Serialize stalls block the daemon's single thread — every pty's + // echo included. Surfaced here so multi-second typing stalls can be + // attributed to checkpoint storms (issue #5096 family) in the field. + recordDaemonStreamBacklogEvent('slowGetSnapshot', { + sessionIdSuffix: request.payload.sessionId.slice(-10), + snapshotMs: Math.round(snapshotMs) + }) + } + return { snapshot } + } case 'getSize': return { size: this.host.getAppliedSize(request.payload.sessionId) } @@ -449,13 +589,12 @@ export class DaemonServer { // Why: write/resize are notification-heavy and intentionally do not wait // for replies. If their target session is gone, this synthetic exit is the // only signal the renderer gets to clear stale terminal pane bindings. - client.streamSocket.write( - encodeNdjson({ - type: 'event', - event: 'exit', - sessionId, - payload: { code } - }) - ) + this.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, { + type: 'event', + event: 'exit', + sessionId, + payload: { code } + }) + this.streamDataBatcher.flush(client.clientId) } } diff --git a/src/main/daemon/daemon-stream-backlog-probe.ts b/src/main/daemon/daemon-stream-backlog-probe.ts new file mode 100644 index 00000000000..444d1deb250 --- /dev/null +++ b/src/main/daemon/daemon-stream-backlog-probe.ts @@ -0,0 +1,55 @@ +/** + * Env-gated diagnostics for the daemon→main stream backlog: samples the + * batcher queue and each stream socket's user-space write buffer so + * multi-second echo lag can be attributed to the hop that actually holds the + * bytes. Enable by setting ORCA_DAEMON_STREAM_BACKLOG_FILE to a writable + * path; zero cost otherwise. The timer only observes and appends JSONL — it + * never mutates delivery state. + */ +import { appendFileSync } from 'node:fs' + +export type StreamBacklogClientSample = { + clientId: string + socketBufferedBytes: number + batcherQueuedChars: number +} + +export type StreamBacklogSample = { + clients: StreamBacklogClientSample[] + backgroundedSessionIdSuffixes?: string[] +} + +const SAMPLE_INTERVAL_MS = 250 + +/** Event-level entries interleaved with the periodic samples — used to + * attribute WHO mutated pacing state, not just when counts changed. */ +export function recordDaemonStreamBacklogEvent( + event: string, + detail: Record +): void { + const filePath = process.env.ORCA_DAEMON_STREAM_BACKLOG_FILE + if (!filePath) { + return + } + try { + appendFileSync(filePath, `${JSON.stringify({ atMs: Date.now(), event, ...detail })}\n`) + } catch { + // Diagnostics must never break the daemon. + } +} + +export function startDaemonStreamBacklogProbe(sample: () => StreamBacklogSample): () => void { + const filePath = process.env.ORCA_DAEMON_STREAM_BACKLOG_FILE + if (!filePath) { + return () => {} + } + const timer = setInterval(() => { + try { + appendFileSync(filePath, `${JSON.stringify({ atMs: Date.now(), ...sample() })}\n`) + } catch { + // Diagnostics must never break the daemon. + } + }, SAMPLE_INTERVAL_MS) + timer.unref?.() + return () => clearInterval(timer) +} diff --git a/src/main/daemon/daemon-stream-data-batcher.test.ts b/src/main/daemon/daemon-stream-data-batcher.test.ts index 8777434e1d9..32d85ff87b0 100644 --- a/src/main/daemon/daemon-stream-data-batcher.test.ts +++ b/src/main/daemon/daemon-stream-data-batcher.test.ts @@ -6,12 +6,37 @@ import { createNdjsonParser } from './ndjson' function createBatcher(options?: ConstructorParameters[1]) { const streamSocket = { destroyed: false, + writableLength: 0, write: vi.fn() - } as unknown as Socket & { write: ReturnType } + } as unknown as Socket & { write: ReturnType; writableLength: number } const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket }), options) return { batcher, streamSocket } } +function writtenData(streamSocket: { write: ReturnType }): string { + return streamSocket.write.mock.calls + .map(([line]) => { + const parsed = JSON.parse(String(line)) as { payload?: { data?: string } } + return parsed.payload?.data ?? '' + }) + .join('') +} + +type ParsedWrite = { + event: string + sessionId?: string + payload?: { data?: string } +} + +// A held pass arms a zero-payload data event whose kernel-flush callback +// refills the queue; it carries no content, so assertions about delivered +// output must ignore it. +function nonSentinelWrites(streamSocket: { write: ReturnType }): ParsedWrite[] { + return streamSocket.write.mock.calls + .map(([line]) => JSON.parse(String(line)) as ParsedWrite) + .filter((message) => !(message.event === 'data' && (message.payload?.data ?? '') === '')) +} + describe('DaemonStreamDataBatcher', () => { it('coalesces background output before writing daemon stream events', () => { vi.useFakeTimers() @@ -22,7 +47,7 @@ describe('DaemonStreamDataBatcher', () => { batcher.enqueue('client-1', 'session-1', 'b') expect(streamSocket.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(7) + vi.advanceTimersByTime(1) expect(streamSocket.write).not.toHaveBeenCalled() vi.advanceTimersByTime(1) @@ -45,7 +70,7 @@ describe('DaemonStreamDataBatcher', () => { expect(streamSocket.write).toHaveBeenCalledTimes(1) expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('\\u001b[20;2Hredraw') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(streamSocket.write).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() @@ -65,7 +90,7 @@ describe('DaemonStreamDataBatcher', () => { }) expect(streamSocket.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(streamSocket.write).toHaveBeenCalledTimes(1) expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain(`${pending}redraw`) } finally { @@ -91,7 +116,7 @@ describe('DaemonStreamDataBatcher', () => { ) expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"echo"') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(streamSocket.write).toHaveBeenCalledTimes(2) expect(String(streamSocket.write.mock.calls[1]?.[0])).toContain( '"sessionId":"session-background"' @@ -102,6 +127,447 @@ describe('DaemonStreamDataBatcher', () => { } }) + it('holds bulk output while the socket buffer is deep and resumes on the next flush', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const bulk = 'x'.repeat(64 * 1024) + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', bulk) + vi.advanceTimersByTime(2) + expect(nonSentinelWrites(streamSocket)).toHaveLength(0) + expect(batcher.queuedCharsForClient('client-1')).toBe(bulk.length) + + // Socket drained (server routes 'drain' to flush): held bulk writes. + streamSocket.writableLength = 0 + batcher.flush('client-1') + expect(writtenData(streamSocket)).toBe(bulk) + expect(batcher.queuedCharsForClient('client-1')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('lets interactive echo jump bulk held behind a deep socket', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', 'x'.repeat(64 * 1024)) + vi.advanceTimersByTime(2) + expect(nonSentinelWrites(streamSocket)).toHaveLength(0) + + batcher.enqueue('client-1', 'session-typing', 'echo', { + flushImmediately: true, + flushMaxChars: 1024 + }) + const written = nonSentinelWrites(streamSocket) + expect(written).toHaveLength(1) + expect(written[0]?.sessionId).toBe('session-typing') + // The bulk stays held — order across sessions has no contract, and the + // deep socket is exactly what the echo must not queue behind. + expect(batcher.queuedCharsForClient('client-1')).toBe(64 * 1024) + } finally { + vi.useRealTimers() + } + }) + + it('lets a small session write through the gate while a flooding session holds (per-session fairness)', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-flood', 'x'.repeat(256 * 1024)) + // Non-interactive small output (echo that missed the 100ms fast-path). + batcher.enqueue('client-1', 'session-typing', 'echo-line') + vi.advanceTimersByTime(2) + + // The flood holds; the tiny session's bytes must NOT wait behind it. + const written = nonSentinelWrites(streamSocket) + expect(written.some((m) => m.sessionId === 'session-typing')).toBe(true) + expect(writtenData(streamSocket)).toContain('echo-line') + expect(written.some((m) => m.sessionId === 'session-flood')).toBe(false) + expect(batcher.queuedCharsForClient('client-1')).toBe(256 * 1024) + } finally { + vi.useRealTimers() + } + }) + + it('never reorders bytes within a session around the small-session bypass', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + + streamSocket.writableLength = 128 * 1024 + // Same session: big entry then (non-adjacent) small entry. + batcher.enqueue('client-1', 'session-a', 'x'.repeat(256 * 1024)) + batcher.enqueue('client-1', 'session-b', 'other') + batcher.enqueue('client-1', 'session-a', 'tail') + vi.advanceTimersByTime(2) + const written = streamSocket.write.mock.calls.map(([line]) => String(line)).join('') + // session-a held its first entry, so its tail must be held too. + expect(written).not.toContain('tail') + + streamSocket.writableLength = 0 + batcher.flush('client-1') + expect(writtenData(streamSocket)).toContain('x'.repeat(64)) + // Full reassembly, in order, once drained. + const aPayload = streamSocket.write.mock.calls + .map( + ([line]) => + JSON.parse(String(line)) as { sessionId?: string; payload?: { data?: string } } + ) + .filter((m) => m.sessionId === 'session-a') + .map((m) => m.payload?.data ?? '') + .join('') + expect(aPayload).toBe(`${'x'.repeat(256 * 1024)}tail`) + } finally { + vi.useRealTimers() + } + }) + + it('slices oversized held entries so one write cannot re-deepen the socket unboundedly', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const bulk = 'y'.repeat(64 * 1024 + 5) + + batcher.enqueue('client-1', 'session-bulk', bulk) + vi.advanceTimersByTime(2) + // Two slices: 64K then the 5-char remainder; payload reassembles intact. + expect(streamSocket.write).toHaveBeenCalledTimes(2) + expect(writtenData(streamSocket)).toBe(bulk) + } finally { + vi.useRealTimers() + } + }) + + it('stops mid-queue when a written slice is followed by a still-deep socket', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const first = 'a'.repeat(70 * 1024) + + batcher.enqueue('client-1', 'session-bulk', first) + // First slice write fills the socket past the gate; the remainder holds. + streamSocket.write.mockImplementation(() => { + streamSocket.writableLength = 200 * 1024 + return false + }) + vi.advanceTimersByTime(2) + expect(nonSentinelWrites(streamSocket)).toHaveLength(1) + expect(batcher.queuedCharsForClient('client-1')).toBe(first.length - 64 * 1024) + + streamSocket.writableLength = 0 + streamSocket.write.mockImplementation(() => true) + batcher.flush('client-1') + expect(writtenData(streamSocket)).toBe(first) + } finally { + vi.useRealTimers() + } + }) + + it('writes through the gate once held bulk exceeds the memory safety valve', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const huge = 'z'.repeat(32 * 1024 * 1024 + 1) + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', huge) + vi.advanceTimersByTime(2) + // Deep socket, but holding would exceed the valve: old write-through + // behavior wins over bounded echo latency. + expect(writtenData(streamSocket).length).toBeGreaterThan(0) + } finally { + vi.useRealTimers() + } + }) + + it('does not split surrogate pairs at the bulk slice boundary', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + // Position an astral char to straddle the 64K slice boundary. + const bulk = `${'a'.repeat(64 * 1024 - 1)}😀${'b'.repeat(10)}` + + batcher.enqueue('client-1', 'session-bulk', bulk) + vi.advanceTimersByTime(2) + expect(writtenData(streamSocket)).toBe(bulk) + for (const [line] of streamSocket.write.mock.calls) { + expect(String(line)).not.toContain('�') + } + } finally { + vi.useRealTimers() + } + }) + + it('keep-tail drops a droppable session over the cap and delivers a gap before the kept tail', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher({ + isSessionDroppable: (sessionId) => sessionId === 'session-bg' + }) + // Two enqueues that together cross the 1MB cap. + batcher.enqueue('client-1', 'session-bg', 'a'.repeat(900 * 1024)) + batcher.enqueue('client-1', 'session-bg', 'b'.repeat(300 * 1024)) + expect(batcher.queuedCharsForClient('client-1')).toBe(512 * 1024) + vi.advanceTimersByTime(2) + + const messages = streamSocket.write.mock.calls.map( + ([line]) => + JSON.parse(String(line)) as { + event: string + payload: { data?: string; droppedChars?: number } + } + ) + expect(messages[0]?.event).toBe('dataGap') + expect(messages[0]?.payload.droppedChars).toBe((900 + 300 - 512) * 1024) + const delivered = messages + .filter((m) => m.event === 'data') + .map((m) => m.payload.data ?? '') + .join('') + expect(delivered.length).toBe(512 * 1024) + // Keep-TAIL: the newest bytes survive. + expect(delivered.endsWith('b'.repeat(300 * 1024))).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('accounts every dropped char across repeated drops (gap sums + delivered = enqueued)', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher({ isSessionDroppable: () => true }) + // Deep socket: data holds; a gap entry (~100B) may still write through — + // it always precedes the kept tail, so a second drop mints a second gap. + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bg', 'a'.repeat(1024 * 1024 + 1)) + batcher.enqueue('client-1', 'session-bg', 'b'.repeat(600 * 1024)) + batcher.enqueue('client-1', 'session-bg', 'c'.repeat(600 * 1024)) + vi.advanceTimersByTime(2) + + streamSocket.writableLength = 0 + batcher.flush('client-1') + const messages = streamSocket.write.mock.calls.map( + ([line]) => + JSON.parse(String(line)) as { + event: string + payload: { data?: string; droppedChars?: number } + } + ) + const gapChars = messages + .filter((m) => m.event === 'dataGap') + .reduce((sum, m) => sum + (m.payload.droppedChars ?? 0), 0) + const dataMessages = messages.filter((m) => m.event === 'data') + const deliveredChars = dataMessages.reduce((sum, m) => sum + (m.payload.data?.length ?? 0), 0) + expect(deliveredChars).toBeLessThanOrEqual(1024 * 1024) + expect(gapChars + deliveredChars).toBe(1024 * 1024 + 1 + 1200 * 1024) + // The newest bytes always survive. + expect(dataMessages.at(-1)?.payload.data?.endsWith('c')).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('salvages reply-eliciting query bytes out of dropped data', () => { + vi.useFakeTimers() + try { + const dsr = '\x1b[6n' + const { batcher, streamSocket } = createBatcher({ + isSessionDroppable: () => true, + salvageDroppedData: (dropped) => (dropped.includes(dsr) ? dsr : '') + }) + // The DSR probe sits in the oldest (dropped) region. + batcher.enqueue('client-1', 'session-bg', `flood${dsr}${'x'.repeat(900 * 1024)}`) + batcher.enqueue('client-1', 'session-bg', 'y'.repeat(300 * 1024)) + vi.advanceTimersByTime(2) + + const messages = streamSocket.write.mock.calls.map( + ([line]) => + JSON.parse(String(line)) as { + event: string + payload: { data?: string; droppedChars?: number; sequenceChars?: number } + } + ) + expect(messages[0]?.event).toBe('dataGap') + // The salvaged query rides right after the gap, before the kept tail. + expect(messages[1]?.event).toBe('data') + expect(messages[1]?.payload.data).toBe(dsr) + expect(messages[1]?.payload.sequenceChars).toBe(0) + const originalSequenceChars = messages.reduce( + (sum, message) => + sum + + (message.event === 'dataGap' + ? (message.payload.sequenceChars ?? 0) + : (message.payload.sequenceChars ?? message.payload.data?.length ?? 0)), + 0 + ) + expect(originalSequenceChars).toBe(`flood${dsr}${'x'.repeat(900 * 1024)}`.length + 300 * 1024) + } finally { + vi.useRealTimers() + } + }) + + it('shrinks keep-tails as more backgrounded sessions queue (global aggregate budget)', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher({ isSessionDroppable: () => true }) + streamSocket.writableLength = 128 * 1024 // deep socket: queues accumulate + // 17 backgrounded sessions × 768KB — each below the single-session cap, + // but a reveal would have to drain the ~13MB aggregate (measured 2.5s + // hidden-restore). The global budget thins each to ~2MB/17 ≈ 120KB. + for (let s = 0; s < 17; s++) { + batcher.enqueue('client-1', `session-${s}`, '#'.repeat(768 * 1024)) + } + const totalQueued = batcher.queuedCharsForClient('client-1') + expect(totalQueued).toBeLessThan(3 * 1024 * 1024) + // Every session still keeps at least a full screen of newest tail. + streamSocket.writableLength = 0 + batcher.flush('client-1') + const perSession = new Map() + for (const m of nonSentinelWrites(streamSocket)) { + if (m.event === 'data' && m.sessionId) { + perSession.set( + m.sessionId, + (perSession.get(m.sessionId) ?? 0) + (m.payload?.data?.length ?? 0) + ) + } + } + for (let s = 0; s < 17; s++) { + expect(perSession.get(`session-${s}`) ?? 0).toBeGreaterThanOrEqual(64 * 1024) + } + } finally { + vi.useRealTimers() + } + }) + + it('never drops sessions that are not droppable', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher({ isSessionDroppable: () => false }) + const bulk = 'v'.repeat(2 * 1024 * 1024) + batcher.enqueue('client-1', 'session-visible', bulk) + vi.advanceTimersByTime(2) + expect(writtenData(streamSocket)).toBe(bulk) + } finally { + vi.useRealTimers() + } + }) + + it('delivers control events in byte order with the session data around them', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + batcher.enqueue('client-1', 'session-1', 'before') + batcher.enqueueControlEvent('client-1', 'session-1', { + type: 'event', + event: 'transientFact', + sessionId: 'session-1', + payload: { kind: 'bell' } + }) + batcher.enqueue('client-1', 'session-1', 'after') + vi.advanceTimersByTime(2) + + const messages = streamSocket.write.mock.calls.map( + ([line]) => JSON.parse(String(line)) as { event: string; payload: { data?: string } } + ) + expect(messages.map((m) => m.event)).toEqual(['data', 'transientFact', 'data']) + expect(messages[0]?.payload.data).toBe('before') + expect(messages[2]?.payload.data).toBe('after') + } finally { + vi.useRealTimers() + } + }) + + it("holds a control event behind its session's held bulk (order latch)", () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', 'x'.repeat(64 * 1024)) + batcher.enqueueControlEvent('client-1', 'session-bulk', { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId: 'session-bulk', + payload: { background: true } + }) + vi.advanceTimersByTime(2) + expect(nonSentinelWrites(streamSocket)).toHaveLength(0) + + streamSocket.writableLength = 0 + batcher.flush('client-1') + expect(nonSentinelWrites(streamSocket).map((m) => m.event)).toEqual([ + 'data', + 'sessionBackgroundMarker' + ]) + } finally { + vi.useRealTimers() + } + }) + + it('delivers a held queued tail (data + facts, in order) once the socket drains', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bg', 'x'.repeat(64 * 1024)) + batcher.enqueueControlEvent('client-1', 'session-bg', { + type: 'event', + event: 'transientFact', + sessionId: 'session-bg', + payload: { kind: 'bell' } + }) + batcher.enqueue('client-1', 'session-bg', 'DONE') + vi.advanceTimersByTime(2) + + // Reveal never discards: a finished program's last output must reach + // main's model (restore reads it). The normal drain loop delivers it. + streamSocket.writableLength = 0 + batcher.flush('client-1') + expect(batcher.queuedCharsForClient('client-1')).toBe(0) + const messages = nonSentinelWrites(streamSocket) + expect(messages.map((m) => m.event)).toEqual(['data', 'transientFact', 'data']) + expect(messages.at(-1)?.payload?.data).toBe('DONE') + } finally { + vi.useRealTimers() + } + }) + + it('arms one kernel-flush refill sentinel per held pass and resumes without waiting for drain', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const flushCallbacks: (() => void)[] = [] + streamSocket.write.mockImplementation((_line: string, cb?: () => void) => { + if (cb) { + flushCallbacks.push(cb) + } + return true + }) + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', 'x'.repeat(64 * 1024)) + vi.advanceTimersByTime(2) + // Held pass → exactly one sentinel armed; further held passes don't stack. + expect(flushCallbacks).toHaveLength(1) + batcher.flush('client-1') + expect(flushCallbacks).toHaveLength(1) + + // Kernel flushed the in-flight bytes (socket now shallow): the sentinel + // callback resumes the held bulk with no 'drain' event involved. + streamSocket.writableLength = 0 + flushCallbacks[0]() + expect(writtenData(streamSocket)).toBe('x'.repeat(64 * 1024)) + expect(batcher.queuedCharsForClient('client-1')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('writes large stream data as parser-sized NDJSON events', () => { vi.useFakeTimers() try { @@ -113,7 +579,7 @@ describe('DaemonStreamDataBatcher', () => { const parser = createNdjsonParser(onMessage, onError, { maxLineBytes }) batcher.enqueue('client-1', 'session-1', data) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (const [line] of streamSocket.write.mock.calls) { parser.feed(String(line)) } diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts index 3374222f17f..84f7465b913 100644 --- a/src/main/daemon/daemon-stream-data-batcher.ts +++ b/src/main/daemon/daemon-stream-data-batcher.ts @@ -1,19 +1,63 @@ import type { Socket } from 'node:net' import { encodeNdjson, NDJSON_MAX_LINE_BYTES } from './ndjson' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import { + clampToSafeSplitIndex, + encodeStreamDataEvent, + writeStreamDataEvents +} from './daemon-stream-data-split' +import { + backgroundSessionDropCapChars, + backgroundSessionKeepTailChars, + dropOldestQueuedForSession, + type PendingStreamDataBatch +} from './daemon-stream-keep-tail-drop' +import type { DaemonEvent } from './types' type StreamDataClient = { streamSocket: Socket | null } -type PendingStreamDataBatch = { - timer: ReturnType | null - queue: { sessionId: string; data: string }[] - queuedChars: number -} +// Why 2ms: under continuous agent output every chunk waits an expected +// half-window here AND again in main's PTY batch — at 8ms each that was +// ~8ms of the measured ~19ms DSR-under-load latency. 2ms keeps burst +// coalescing (~500 socket writes/s worst case, ~100B framing overhead per +// write against MB/s payloads) while cutting the fixed latency tax 4x. +const STREAM_DATA_BATCH_INTERVAL_MS = 2 -// Why: match main-process PTY IPC batching to avoid adding latency while -// removing daemon socket writes and JSON framing during bursty output. -const STREAM_DATA_BATCH_INTERVAL_MS = 8 +// Why a shallow socket: the stream socket is one FIFO for every session, and +// bytes already written can never be overtaken — a deep user-space buffer +// buries a visible pane's keystroke echo behind bulk output for other panes +// (measured 192MB / 6+s under 12 flooding hidden agents). Bulk writes stop at +// this depth and the remainder is HELD here, where the interactive +// flushSession path can still jump it; socket 'drain' refills. Echo latency +// is then bounded by the shallow depth, not by how much bulk is in flight. +// 128KB must stay above the socket's ~16KB highWaterMark so a held state +// implies a false write() and therefore a guaranteed 'drain' wake-up. +// Kill switch: ORCA_DAEMON_SHALLOW_SOCKET_GATE=0 restores pre-gate unbounded +// socket writes for field debugging and true fix-off A/B benches. +const SHALLOW_SOCKET_WRITE_GATE_BYTES = + process.env.ORCA_DAEMON_SHALLOW_SOCKET_GATE === '0' ? Number.POSITIVE_INFINITY : 128 * 1024 +// Why sliced writes: enqueue coalesces per-session entries, so a held entry +// can grow to megabytes; writing it whole would re-deepen the socket past the +// gate in one call. +const BULK_WRITE_SLICE_CHARS = 64 * 1024 +// Safety valve: if held bulk ever exceeds this, write through to the socket +// (exactly the pre-gate behavior) — bounded daemon memory beats bounded echo +// latency in the extreme. Must sit FAR above the pacer's pause watermark plus +// its overshoot (observed ~5MB with 17 paused sessions' in-flight pty reads): +// an engaged valve deepens the socket and buries interactive echo behind the +// whole backlog (measured as bimodal ~2.4s key medians when this was 8MB). +const HELD_WRITE_THROUGH_TOTAL_CHARS = 32 * 1024 * 1024 +// Why a small-session bypass: the hold is there to stop FLOODS from burying +// everyone else; a session with only a few KB queued (keystroke echo, prompt +// redraws, query replies) is never the flood and must not wait FIFO behind +// other sessions' megabytes. The daemon's 100ms interactive fast-path is a +// heuristic that misses under event-loop load (measured: echo classified +// non-interactive rode the held queue for ~2.4s); this bypass is the +// deterministic backstop. Worst socket over-deepening per flush is +// sessions × this ≈ tens of KB. +const SMALL_SESSION_HOLD_BYPASS_CHARS = 4 * 1024 type EnqueueOptions = { flushImmediately?: boolean @@ -22,90 +66,25 @@ type EnqueueOptions = { type DaemonStreamDataBatcherOptions = { maxLineBytes?: number -} - -function encodeStreamDataEvent(sessionId: string, data: string): string { - return encodeNdjson({ - type: 'event', - event: 'data', - sessionId, - payload: { data } - }) -} - -function streamDataEventLineBytes(sessionId: string, data: string): number { - return Buffer.byteLength(encodeStreamDataEvent(sessionId, data), 'utf8') -} - -function isHighSurrogate(value: number): boolean { - return value >= 0xd800 && value <= 0xdbff -} - -function isLowSurrogate(value: number): boolean { - return value >= 0xdc00 && value <= 0xdfff -} - -function clampToSafeSplitIndex(value: string, start: number, end: number): number { - if (end <= start || end >= value.length) { - return end - } - const prev = value.charCodeAt(end - 1) - const next = value.charCodeAt(end) - return isHighSurrogate(prev) && isLowSurrogate(next) ? end - 1 : end -} - -function nextSafeSplitIndex(value: string, start: number): number { - const next = Math.min(value.length, start + 1) - if ( - next < value.length && - isHighSurrogate(value.charCodeAt(start)) && - isLowSurrogate(value.charCodeAt(next)) - ) { - return next + 1 - } - return next -} - -function splitStreamDataForNdjson(sessionId: string, data: string, maxLineBytes: number): string[] { - if (streamDataEventLineBytes(sessionId, data) <= maxLineBytes) { - return [data] - } - - const chunks: string[] = [] - let start = 0 - while (start < data.length) { - let low = start + 1 - let high = data.length - let best = start - - while (low <= high) { - const rawMid = Math.floor((low + high) / 2) - const mid = clampToSafeSplitIndex(data, start, rawMid) - if (mid <= start) { - low = rawMid + 1 - continue - } - - if (streamDataEventLineBytes(sessionId, data.slice(start, mid)) <= maxLineBytes) { - best = mid - low = rawMid + 1 - } else { - high = rawMid - 1 - } - } - - const end = best > start ? best : nextSafeSplitIndex(data, start) - chunks.push(data.slice(start, end)) - start = end - } - - return chunks + /** Fires after each stream-socket write — the only place backlog grows, so + * the backlog pacer checks its watermark here. */ + onAfterSocketWrite?: () => void + /** True for sessions whose queued output may be keep-tail dropped + * (main-marked background sessions). */ + isSessionDroppable?: (sessionId: string) => boolean + /** Carve reply-eliciting query bytes (DSR/DA/DECRQM/OSC color probes) out + * of dropped data — the hidden program blocks on the reply, so those few + * bytes must still be delivered even when their flood is not. */ + salvageDroppedData?: (dropped: string) => string } export class DaemonStreamDataBatcher { private pendingByClient = new Map() private getClient: (clientId: string) => StreamDataClient | undefined private maxLineBytes: number + private onAfterSocketWrite: (() => void) | undefined + private isSessionDroppable: (sessionId: string) => boolean + private salvageDroppedData: (dropped: string) => string constructor( getClient: (clientId: string) => StreamDataClient | undefined, @@ -113,6 +92,9 @@ export class DaemonStreamDataBatcher { ) { this.getClient = getClient this.maxLineBytes = Math.max(1, options.maxLineBytes ?? NDJSON_MAX_LINE_BYTES) + this.onAfterSocketWrite = options.onAfterSocketWrite + this.isSessionDroppable = options.isSessionDroppable ?? (() => false) + this.salvageDroppedData = options.salvageDroppedData ?? (() => '') } enqueue(clientId: string, sessionId: string, data: string, options: EnqueueOptions = {}): void { @@ -121,19 +103,45 @@ export class DaemonStreamDataBatcher { return } - let batch = this.pendingByClient.get(clientId) - if (!batch) { - batch = { timer: null, queue: [], queuedChars: 0 } - this.pendingByClient.set(clientId, batch) - } - + const batch = this.getOrCreateBatch(clientId) const last = batch.queue.at(-1) - if (last?.sessionId === sessionId) { + // Never coalesce across a control entry — it marks a position in the + // session's byte order. + if (last?.sessionId === sessionId && !last.control) { last.data += data } else { batch.queue.push({ sessionId, data }) } batch.queuedChars += data.length + batch.queuedCharsBySession.set( + sessionId, + (batch.queuedCharsBySession.get(sessionId) ?? 0) + data.length + ) + + if (this.isSessionDroppable(sessionId)) { + // Keep-tail scales down as more backgrounded sessions queue, bounding + // the AGGREGATE a reveal must drain (see daemon-stream-keep-tail-drop). + const droppableQueued = this.countDroppableSessionsWithQueuedData(batch) + const dropCap = backgroundSessionDropCapChars(droppableQueued) + const keepTail = backgroundSessionKeepTailChars(droppableQueued) + if ((batch.queuedCharsBySession.get(sessionId) ?? 0) > dropCap) { + dropOldestQueuedForSession(batch, sessionId, keepTail, this.salvageDroppedData) + } + if (droppableQueued > (batch.lastDroppableSessionCount ?? 0)) { + // The shared budget tightened: re-trim sessions that already finished + // producing — they never re-enter this path on their own. + for (const [queuedSessionId, queued] of Array.from(batch.queuedCharsBySession)) { + if ( + queued > dropCap && + queuedSessionId !== sessionId && + this.isSessionDroppable(queuedSessionId) + ) { + dropOldestQueuedForSession(batch, queuedSessionId, keepTail, this.salvageDroppedData) + } + } + } + batch.lastDroppableSessionCount = droppableQueued + } if ( options.flushImmediately === true && @@ -148,6 +156,43 @@ export class DaemonStreamDataBatcher { } } + /** Append a pre-shaped stream event at the current position in the + * session's byte order (scan handoff markers, gaps, transient facts). */ + enqueueControlEvent(clientId: string, sessionId: string, control: DaemonEvent): void { + const client = this.getClient(clientId) + if (!client?.streamSocket || client.streamSocket.destroyed) { + return + } + const batch = this.getOrCreateBatch(clientId) + batch.queue.push({ sessionId, data: '', control }) + if (!batch.timer) { + batch.timer = setTimeout(() => this.flush(clientId), STREAM_DATA_BATCH_INTERVAL_MS) + } + } + + private countDroppableSessionsWithQueuedData(batch: PendingStreamDataBatch): number { + let count = 0 + for (const [sessionId, queued] of batch.queuedCharsBySession) { + if (queued > 0 && this.isSessionDroppable(sessionId)) { + count++ + } + } + return count + } + + private getOrCreateBatch(clientId: string): PendingStreamDataBatch { + let batch = this.pendingByClient.get(clientId) + if (!batch) { + batch = { timer: null, queue: [], queuedChars: 0, queuedCharsBySession: new Map() } + this.pendingByClient.set(clientId, batch) + } + return batch + } + + queuedCharsForClient(clientId: string): number { + return this.pendingByClient.get(clientId)?.queuedChars ?? 0 + } + flush(clientId: string): void { const batch = this.pendingByClient.get(clientId) if (!batch) { @@ -158,16 +203,113 @@ export class DaemonStreamDataBatcher { clearTimeout(batch.timer) batch.timer = null } - this.pendingByClient.delete(clientId) const client = this.getClient(clientId) if (!client?.streamSocket || client.streamSocket.destroyed) { + // Same as pre-gate behavior: a vanished stream socket drops the batch — + // the model owns the bytes and reconnect restores from a snapshot. + this.pendingByClient.delete(clientId) return } - for (const entry of batch.queue) { - this.writeStreamDataEvent(client.streamSocket, entry.sessionId, entry.data) + const socket = client.streamSocket + // Sessions that held an entry must hold ALL their later entries in this + // pass — writing around a held entry would reorder that session's bytes. + const heldSessions = new Set() + const retained: PendingStreamDataBatch['queue'] = [] + while (batch.queue.length > 0) { + const entry = batch.queue[0] + if (entry.control) { + // Control entries only respect the held-session order latch — they + // are ~100B, so writing them onto a deep socket is as harmless as the + // small-session bypass. + if (heldSessions.has(entry.sessionId)) { + retained.push(entry) + batch.queue.shift() + continue + } + batch.queue.shift() + socket.write(encodeNdjson(entry.control)) + this.onAfterSocketWrite?.() + continue + } + const socketDeep = (socket.writableLength ?? 0) >= SHALLOW_SOCKET_WRITE_GATE_BYTES + if (socketDeep && batch.queuedChars <= HELD_WRITE_THROUGH_TOTAL_CHARS) { + const sessionHeld = batch.queuedCharsBySession.get(entry.sessionId) ?? 0 + if (heldSessions.has(entry.sessionId) || sessionHeld > SMALL_SESSION_HOLD_BYPASS_CHARS) { + // Hold this flooding session's entry; small talkers keep flowing. + // The socket's 'drain' (routed back to flush by the server) + // resumes held bulk. No timer: a deep socket implies a prior + // false write(), so 'drain' is guaranteed. + heldSessions.add(entry.sessionId) + retained.push(entry) + batch.queue.shift() + continue + } + } else if (socketDeep) { + // Valve engaged: held bulk exceeded the memory cap and is being + // written through onto a deep socket — echo protection is off until + // it drains. Rare enough to be worth a diagnostics event every time. + recordDaemonStreamBacklogEvent('heldWriteThrough', { + heldChars: batch.queuedChars, + socketBufferedBytes: socket.writableLength ?? 0 + }) + } + const end = + entry.data.length <= BULK_WRITE_SLICE_CHARS + ? entry.data.length + : clampToSafeSplitIndex(entry.data, 0, BULK_WRITE_SLICE_CHARS) + const slice = entry.data.slice(0, end) + const entrySequenceChars = entry.sequenceChars ?? entry.data.length + const sliceSequenceChars = entrySequenceChars === 0 ? 0 : slice.length + if (end >= entry.data.length) { + batch.queue.shift() + } else { + entry.data = entry.data.slice(end) + const remainingSequenceChars = entrySequenceChars - sliceSequenceChars + entry.sequenceChars = + remainingSequenceChars === entry.data.length ? undefined : remainingSequenceChars + } + batch.queuedChars -= slice.length + const sessionHeldAfter = + (batch.queuedCharsBySession.get(entry.sessionId) ?? slice.length) - slice.length + if (sessionHeldAfter <= 0) { + batch.queuedCharsBySession.delete(entry.sessionId) + } else { + batch.queuedCharsBySession.set(entry.sessionId, sessionHeldAfter) + } + writeStreamDataEvents(socket, entry.sessionId, slice, this.maxLineBytes, sliceSequenceChars) + this.onAfterSocketWrite?.() } + if (retained.length > 0) { + batch.queue = retained + // Held entries must not wait for the socket's 'drain' alone: drain only + // fires when the user-space buffer fully EMPTIES, so bulk would advance + // one gate-depth per daemon event-loop turn — seconds of dead time for + // a multi-MB hidden backlog on a busy daemon (measured: hidden-restore + // 2.5s vs the 1.5s budget). Arm ONE ~90B empty data event whose + // kernel-flush callback re-flushes while bytes are still in flight, so + // main never starves. (An empty socket write's callback fires + // immediately — verified — so the sentinel must be a real protocol + // no-op line.) Event-driven, no timers; the per-client latch stops + // sentinel stacking; 'drain' remains the backstop. + this.armHeldQueueRefill(socket, clientId, retained[0].sessionId) + return + } + this.pendingByClient.delete(clientId) + } + + private refillArmedClients = new Set() + + private armHeldQueueRefill(socket: Socket, clientId: string, sessionId: string): void { + if (this.refillArmedClients.has(clientId) || socket.destroyed) { + return + } + this.refillArmedClients.add(clientId) + socket.write(encodeStreamDataEvent(sessionId, ''), () => { + this.refillArmedClients.delete(clientId) + this.flush(clientId) + }) } private queuedCharsForSession(batch: PendingStreamDataBatch, sessionId: string): number { @@ -203,6 +345,7 @@ export class DaemonStreamDataBatcher { batch.queue = retained batch.queuedChars -= flushedChars + batch.queuedCharsBySession.delete(sessionId) if (batch.queue.length === 0) { if (batch.timer) { clearTimeout(batch.timer) @@ -217,7 +360,19 @@ export class DaemonStreamDataBatcher { } for (const entry of flushed) { - this.writeStreamDataEvent(client.streamSocket, entry.sessionId, entry.data) + if (entry.control) { + client.streamSocket.write(encodeNdjson(entry.control)) + this.onAfterSocketWrite?.() + } else { + writeStreamDataEvents( + client.streamSocket, + entry.sessionId, + entry.data, + this.maxLineBytes, + entry.sequenceChars ?? entry.data.length + ) + this.onAfterSocketWrite?.() + } } } @@ -234,13 +389,4 @@ export class DaemonStreamDataBatcher { this.pendingByClient.delete(id) } } - - private writeStreamDataEvent(streamSocket: Socket, sessionId: string, data: string): void { - // Why: createNdjsonParser rejects oversized lines. Terminal output can - // burst faster than the batch interval, so writer-side chunking prevents - // the daemon from dropping its own stream events at the receiver. - for (const chunk of splitStreamDataForNdjson(sessionId, data, this.maxLineBytes)) { - streamSocket.write(encodeStreamDataEvent(sessionId, chunk)) - } - } } diff --git a/src/main/daemon/daemon-stream-data-split.ts b/src/main/daemon/daemon-stream-data-split.ts new file mode 100644 index 00000000000..0e15cf49523 --- /dev/null +++ b/src/main/daemon/daemon-stream-data-split.ts @@ -0,0 +1,114 @@ +/** + * Surrogate-safe splitting for daemon stream data events: NDJSON line-size + * chunking (the receiver's parser rejects oversized lines) and the safe-index + * clamp shared by the batcher's bulk write slicing and keep-tail dropping. + */ +import { encodeNdjson } from './ndjson' +import type { Socket } from 'node:net' + +export function encodeStreamDataEvent( + sessionId: string, + data: string, + sequenceChars?: number +): string { + return encodeNdjson({ + type: 'event', + event: 'data', + sessionId, + payload: { data, ...(sequenceChars === undefined ? {} : { sequenceChars }) } + }) +} + +function streamDataEventLineBytes(sessionId: string, data: string, sequenceChars?: number): number { + return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, sequenceChars), 'utf8') +} + +function isHighSurrogate(value: number): boolean { + return value >= 0xd800 && value <= 0xdbff +} + +function isLowSurrogate(value: number): boolean { + return value >= 0xdc00 && value <= 0xdfff +} + +export function clampToSafeSplitIndex(value: string, start: number, end: number): number { + if (end <= start || end >= value.length) { + return end + } + const prev = value.charCodeAt(end - 1) + const next = value.charCodeAt(end) + return isHighSurrogate(prev) && isLowSurrogate(next) ? end - 1 : end +} + +function nextSafeSplitIndex(value: string, start: number): number { + const next = Math.min(value.length, start + 1) + if ( + next < value.length && + isHighSurrogate(value.charCodeAt(start)) && + isLowSurrogate(value.charCodeAt(next)) + ) { + return next + 1 + } + return next +} + +export function splitStreamDataForNdjson( + sessionId: string, + data: string, + maxLineBytes: number, + sequenceChars?: number +): string[] { + if (streamDataEventLineBytes(sessionId, data, sequenceChars) <= maxLineBytes) { + return [data] + } + + const chunks: string[] = [] + let start = 0 + while (start < data.length) { + let low = start + 1 + let high = data.length + let best = start + + while (low <= high) { + const rawMid = Math.floor((low + high) / 2) + const mid = clampToSafeSplitIndex(data, start, rawMid) + if (mid <= start) { + low = rawMid + 1 + continue + } + + if ( + streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars) <= maxLineBytes + ) { + best = mid + low = rawMid + 1 + } else { + high = rawMid - 1 + } + } + + const end = best > start ? best : nextSafeSplitIndex(data, start) + chunks.push(data.slice(start, end)) + start = end + } + + return chunks +} + +export function writeStreamDataEvents( + streamSocket: Pick, + sessionId: string, + data: string, + maxLineBytes: number, + sequenceChars = data.length +): void { + const explicitSequenceChars = sequenceChars === data.length ? undefined : sequenceChars + for (const chunk of splitStreamDataForNdjson( + sessionId, + data, + maxLineBytes, + explicitSequenceChars + )) { + streamSocket.write(encodeStreamDataEvent(sessionId, chunk, explicitSequenceChars)) + } +} diff --git a/src/main/daemon/daemon-stream-events.ts b/src/main/daemon/daemon-stream-events.ts new file mode 100644 index 00000000000..1644642bd09 --- /dev/null +++ b/src/main/daemon/daemon-stream-events.ts @@ -0,0 +1,78 @@ +// ─── Events (Daemon → Client, on stream socket) ──────────────────── +import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' + +export type DataEvent = { + type: 'event' + event: 'data' + sessionId: string + payload: { data: string; sequenceChars?: number } +} + +export type ExitEvent = { + type: 'event' + event: 'exit' + sessionId: string + payload: { code: number } +} + +export type TerminalErrorEvent = { + type: 'event' + event: 'terminalError' + sessionId: string + payload: { message: string } +} + +// Why these ride the stream socket (not control): each marks a POSITION in a +// session's byte stream — scan-authority handoffs and dropped ranges are only +// meaningful relative to the data events around them. Old mains ignore +// unknown stream events, and only new mains send setSessionBackground. The +// v20 bump is for sequence-safe recovery snapshots, not these tolerated events. + +/** Scan-authority handoff marker: bytes before this event were (not) scanned + * by the daemon's transient-fact relay; main flips its own scanners at + * exactly this position so no fact double-fires or goes missing. + * scanSeedAnsi (un-background only) carries the emulator's dangling + * incomplete escape so main can prime its fresh scanner carry — a sequence + * split across the handoff must not mint a phantom bell or lose its fact. */ +export type SessionBackgroundMarkerEvent = { + type: 'event' + event: 'sessionBackgroundMarker' + sessionId: string + payload: { background: boolean; scanSeedAnsi?: string } +} + +/** A backgrounded session's oldest undelivered output was dropped at the + * daemon (keep-tail thinning). The daemon emulator ingested every byte — + * only this monitoring stream is thinned. */ +export type DataGapEvent = { + type: 'event' + event: 'dataGap' + sessionId: string + payload: { droppedChars: number; sequenceChars?: number } +} + +/** Notification-bearing fact detected by the daemon while it holds scan + * authority for a backgrounded session. Title/agent-status facts stay + * main-side: they converge from the kept tail (stale-working-title timer, + * snapshot-restores-title-state) and fuse with main-fabricated synthetic + * frames the daemon never sees. */ +export type DaemonTransientFact = + | { kind: 'bell' } + | { kind: 'command-finished'; exitCode: number | null } + | { kind: 'pr-link'; link: TerminalGitHubPRLink } + | { kind: '2031-subscribe' } + +export type TransientFactEvent = { + type: 'event' + event: 'transientFact' + sessionId: string + payload: DaemonTransientFact +} + +export type DaemonEvent = + | DataEvent + | ExitEvent + | TerminalErrorEvent + | SessionBackgroundMarkerEvent + | DataGapEvent + | TransientFactEvent diff --git a/src/main/daemon/daemon-stream-keep-tail-drop.ts b/src/main/daemon/daemon-stream-keep-tail-drop.ts new file mode 100644 index 00000000000..0dc57e53522 --- /dev/null +++ b/src/main/daemon/daemon-stream-keep-tail-drop.ts @@ -0,0 +1,182 @@ +/** + * Keep-tail thinning for backgrounded sessions' queued stream data. Hidden + * panes' stream copy is a monitoring feed (tail previews, agent status) — the + * daemon emulator holds the complete model and reveal restores from its + * snapshot. Once a backgrounded session's undelivered output exceeds the cap, + * its OLDEST bytes are dropped down to the keep-tail and a dataGap event + * takes their place, so the feed stays tail-fresh, daemon memory stays + * bounded, and the producer is never paused (no reveal catch-up). + */ +import { clampToSafeSplitIndex } from './daemon-stream-data-split' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import type { DaemonEvent, DataGapEvent } from './types' + +// A control entry carries a whole pre-shaped stream event (background marker, +// data gap, transient fact) that must ride at its exact position in the +// session's byte order; its data is always '' so it never counts against the +// gate or drop caps, and drops never remove it. +export type StreamQueueEntry = { + sessionId: string + data: string + /** Original PTY characters represented by data. Salvaged query copies are + * delivered bytes but represent zero new positions in the source stream. */ + sequenceChars?: number + control?: DaemonEvent +} + +export type PendingStreamDataBatch = { + timer: ReturnType | null + queue: StreamQueueEntry[] + queuedChars: number + // Per-session held totals so the flush hold can spare small talkers + // (echo/replies) from waiting behind other sessions' floods. + queuedCharsBySession: Map + // Last droppable-sessions-with-queued-data count seen by the keep-tail + // logic: when it GROWS the shared budget tightens, and sessions that + // finished producing must be re-trimmed (they will never re-enqueue). + lastDroppableSessionCount?: number +} + +// The keep-tail must comfortably cover a full TUI repaint (~cols×rows×SGR ≈ +// 100KB) so the delivered tail always re-renders a coherent screen. +// Hysteresis (cap = 2× keep) bounds drop churn. +// Kill switch: ORCA_DAEMON_BACKGROUND_STREAM_DROP=0 disables thinning. +const BACKGROUND_SESSION_KEEP_TAIL_CHARS = 512 * 1024 +const BACKGROUND_SESSION_MIN_KEEP_TAIL_CHARS = 64 * 1024 +// Why a GLOBAL budget too: the per-session cap bounds each flood, but N +// backgrounded sessions can still queue N×cap in aggregate — and a reveal +// (worktree switch) then waits behind the whole aggregate at the gated drain +// rate (~8MB/s event-loop-turn-bound; measured 9MB queued → 2.5s hidden +// restore vs the 1.5s budget). Shrinking each session's keep-tail as more +// backgrounded sessions queue keeps the total ~2MB, so any reveal drains in +// ~250ms while every pane still keeps at least a full screen of tail. +const BACKGROUND_GLOBAL_KEEP_BUDGET_CHARS = 2 * 1024 * 1024 + +export function backgroundSessionKeepTailChars(droppableSessionsWithQueuedData: number): number { + return Math.min( + BACKGROUND_SESSION_KEEP_TAIL_CHARS, + Math.max( + BACKGROUND_SESSION_MIN_KEEP_TAIL_CHARS, + Math.floor(BACKGROUND_GLOBAL_KEEP_BUDGET_CHARS / Math.max(1, droppableSessionsWithQueuedData)) + ) + ) +} + +export function backgroundSessionDropCapChars(droppableSessionsWithQueuedData: number): number { + return backgroundSessionKeepTailChars(droppableSessionsWithQueuedData) * 2 +} +// Mirrors main's DROPPED_QUERY_SALVAGE_MAX_CHARS: salvage past this means a +// pathological query stream; keep the O(1) memory guarantee. A prior drop's +// salvage entry is itself the oldest data and re-salvages through the next +// drop, so query order is preserved across repeated drops. +const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096 + +/** Trim the session's OLDEST queued data down to the keep-tail and leave (or + * grow) a dataGap control entry where the dropped bytes were. Control + * entries are never dropped. Boundary note: the kept tail can start + * mid-escape-sequence — deliberate; the receiver treats a gap as a + * tail-preview reset and transient-fact scanning is daemon-authoritative + * for droppable sessions, so nothing downstream parses across the cut. */ +export function dropOldestQueuedForSession( + batch: PendingStreamDataBatch, + sessionId: string, + keepTailChars: number, + salvageDroppedData: (dropped: string) => string +): void { + let toDrop = (batch.queuedCharsBySession.get(sessionId) ?? 0) - keepTailChars + if (toDrop <= 0) { + return + } + const totalDropped = toDrop + let droppedSequenceChars = 0 + let salvaged = '' + const salvageIntoCap = (dropped: string): void => { + if (salvaged.length >= DROPPED_QUERY_SALVAGE_MAX_CHARS) { + return + } + salvaged = (salvaged + salvageDroppedData(dropped)).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS) + } + let existingGap: DataGapEvent | null = null + let insertGapAt = -1 + for (let i = 0; i < batch.queue.length && toDrop > 0; i++) { + const entry = batch.queue[i] + if (entry.sessionId !== sessionId) { + continue + } + if (entry.control) { + if (entry.control.event === 'dataGap') { + existingGap = entry.control + } + continue + } + if (entry.data.length <= toDrop) { + toDrop -= entry.data.length + droppedSequenceChars += entry.sequenceChars ?? entry.data.length + salvageIntoCap(entry.data) + if (insertGapAt === -1) { + insertGapAt = i + } + batch.queue.splice(i, 1) + i-- + } else { + const cut = clampToSafeSplitIndex(entry.data, 0, toDrop) + if (cut > 0) { + const entrySequenceChars = entry.sequenceChars ?? entry.data.length + const cutSequenceChars = entrySequenceChars === 0 ? 0 : cut + droppedSequenceChars += cutSequenceChars + salvageIntoCap(entry.data.slice(0, cut)) + entry.data = entry.data.slice(cut) + const remainingSequenceChars = entrySequenceChars - cutSequenceChars + entry.sequenceChars = + remainingSequenceChars === entry.data.length ? undefined : remainingSequenceChars + if (insertGapAt === -1) { + insertGapAt = i + } + } + toDrop = 0 + } + } + const dropped = totalDropped - toDrop + if (dropped <= 0) { + return + } + batch.queuedChars -= dropped + batch.queuedCharsBySession.set( + sessionId, + Math.max(0, (batch.queuedCharsBySession.get(sessionId) ?? 0) - dropped) + ) + if (existingGap) { + const priorSequenceChars = existingGap.payload.sequenceChars ?? existingGap.payload.droppedChars + existingGap.payload.droppedChars += dropped + existingGap.payload.sequenceChars = priorSequenceChars + droppedSequenceChars + } else { + recordDaemonStreamBacklogEvent('backgroundKeepTailDrop', { + sessionIdSuffix: sessionId.slice(-10), + droppedChars: dropped + }) + batch.queue.splice(Math.max(0, insertGapAt), 0, { + sessionId, + data: '', + control: { + type: 'event', + event: 'dataGap', + sessionId, + payload: { droppedChars: dropped, sequenceChars: droppedSequenceChars } + } + }) + insertGapAt = Math.max(0, insertGapAt) + 1 + } + if (salvaged.length > 0) { + // Salvaged query bytes ride as a tiny data entry at the gap position — + // the writing program is blocked on their replies. + const at = existingGap + ? batch.queue.findIndex((e) => e.control === existingGap) + 1 + : insertGapAt + batch.queue.splice(at, 0, { sessionId, data: salvaged, sequenceChars: 0 }) + batch.queuedChars += salvaged.length + batch.queuedCharsBySession.set( + sessionId, + (batch.queuedCharsBySession.get(sessionId) ?? 0) + salvaged.length + ) + } +} diff --git a/src/main/daemon/degraded-daemon-fallback-shutdown.ts b/src/main/daemon/degraded-daemon-fallback-shutdown.ts new file mode 100644 index 00000000000..0d4932d6c20 --- /dev/null +++ b/src/main/daemon/degraded-daemon-fallback-shutdown.ts @@ -0,0 +1,25 @@ +import type { IPtyProvider } from '../providers/types' + +export async function shutdownDegradedFallbackSessions( + sessionProviders: Map, + fallback: T +): Promise { + const ids = [...sessionProviders] + .filter(([, provider]) => provider === fallback) + .map(([id]) => id) + const results = await Promise.allSettled( + ids.map(async (id) => { + await fallback.shutdown(id, { immediate: true }) + sessionProviders.delete(id) + }) + ) + // Why: fallback cleanup must not abort the user's daemon-restart recovery path. + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length > 0) { + console.warn( + `[daemon] ${failed.length} local fallback PTY session(s) failed to shut down during daemon restart; continuing restart`, + ...failed.map((result) => (result as PromiseRejectedResult).reason) + ) + } + return results.length - failed.length +} diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index 7bd656fc6fb..fb0b5087f15 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -4,13 +4,14 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' type ProviderMock = IPtyProvider & { - emitData: (id: string, data: string) => void + emitData: (id: string, data: string, sequenceChars?: number) => void emitReplay: (id: string, data: string) => void emitExit: (id: string, code: number) => void } function createProvider(label: string, sessions: string[] = []): ProviderMock { - const dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = + [] const replayListeners: ((payload: { id: string; data: string }) => void)[] = [] const exitListeners: ((payload: { id: string; code: number }) => void)[] = [] return { @@ -41,15 +42,17 @@ function createProvider(label: string, sessions: string[] = []): ProviderMock { listProcesses: vi.fn(async () => sessions.map((id) => ({ id, cwd: '', title: label }))), getDefaultShell: vi.fn(async () => '/bin/zsh'), getProfiles: vi.fn(async () => []), - onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { - dataListeners.push(callback) - return () => { - const idx = dataListeners.indexOf(callback) - if (idx !== -1) { - dataListeners.splice(idx, 1) + onData: vi.fn( + (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { + dataListeners.push(callback) + return () => { + const idx = dataListeners.indexOf(callback) + if (idx !== -1) { + dataListeners.splice(idx, 1) + } } } - }), + ), onReplay: vi.fn((callback: (payload: { id: string; data: string }) => void) => { replayListeners.push(callback) return () => { @@ -68,9 +71,9 @@ function createProvider(label: string, sessions: string[] = []): ProviderMock { } } }), - emitData: (id: string, data: string) => { + emitData: (id: string, data: string, sequenceChars?: number) => { for (const listener of dataListeners) { - listener({ id, data }) + listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) } }, emitReplay: (id: string, data: string) => { @@ -151,6 +154,30 @@ describe('DegradedDaemonPtyProvider', () => { expect(fallback.write).not.toHaveBeenCalled() }) + it('routes authoritative recovery snapshots to the owning daemon', async () => { + const current = createDaemonAdapter('daemon', ['daemon-session']) + const fallback = createProvider('fallback') + const snapshot = { + data: 'alt frame', + scrollbackAnsi: 'normal history', + cols: 80, + rows: 24, + seq: 42, + source: 'headless' as const + } + current.getBufferSnapshot = vi.fn(async () => snapshot) + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + + await provider.discoverDaemonSessions() + + await expect( + provider.getBufferSnapshot('daemon-session', { scrollbackRows: 50_000 }) + ).resolves.toEqual(snapshot) + expect(current.getBufferSnapshot).toHaveBeenCalledWith('daemon-session', { + scrollbackRows: 50_000 + }) + }) + it('forwards replay output from fallback and daemon providers', () => { const current = createDaemonAdapter('daemon') const fallback = createProvider('fallback') @@ -174,6 +201,22 @@ describe('DegradedDaemonPtyProvider', () => { }) }) + it('preserves explicit sequence accounting on daemon data events', () => { + const current = createDaemonAdapter('daemon') + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + const dataSpy = vi.fn() + provider.onData(dataSpy) + + current.emitData('daemon-session', '\x1b[6n', 0) + + expect(dataSpy).toHaveBeenCalledWith({ + id: 'daemon-session', + data: '\x1b[6n', + sequenceChars: 0 + }) + }) + it('detaches provider subscriptions without disposing the underlying providers', () => { const current = createDaemonAdapter('daemon') const fallback = createProvider('fallback') diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index a7b8c68fa19..4e818c9b6c0 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -1,6 +1,9 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shutdown' import type { IPtyProvider, + PtyBackgroundStreamEvent, + PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnOptions, PtySpawnResult @@ -23,7 +26,11 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { private fallback: ManagedPtyProvider private sessionProviders = new Map() private unsubscribers: (() => void)[] = [] - private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + private dataListeners: ((payload: { + id: string + data: string + sequenceChars?: number + }) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] constructor(opts: { @@ -93,6 +100,18 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { this.providerFor(id).resize(id, cols, rows) } + pauseProducer(id: string): void { + this.providerFor(id).pauseProducer?.(id) + } + + resumeProducer(id: string): void { + this.providerFor(id).resumeProducer?.(id) + } + + setPtyBackgrounded(id: string, background: boolean): void { + this.providerFor(id).setPtyBackgrounded?.(id, background) + } + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { await this.providerFor(id).shutdown(id, opts) if (!opts.keepHistory) { @@ -116,6 +135,15 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { return (await this.providerFor(id).getAppliedSize?.(id)) ?? null } + async getBufferSnapshot( + id: string, + opts?: { scrollbackRows?: number } + ): Promise { + // Why: a preserved legacy daemon can still thin its monitoring stream; + // recovery must reach the adapter that owns that session's full model. + return (await this.providerFor(id).getBufferSnapshot?.(id, opts)) ?? null + } + async clearBuffer(id: string): Promise { await this.providerFor(id).clearBuffer(id) } @@ -155,7 +183,9 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { return this.fallback.getProfiles() } - onData(callback: (payload: { id: string; data: string }) => void): () => void { + onData( + callback: (payload: { id: string; data: string; sequenceChars?: number }) => void + ): () => void { this.dataListeners.push(callback) return () => { const idx = this.dataListeners.indexOf(callback) @@ -165,6 +195,17 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } } + onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { + const unsubscribes = this.allProviders().flatMap( + (provider) => provider.onBackgroundStreamEvent?.(callback) ?? [] + ) + return () => { + for (const unsubscribe of unsubscribes) { + unsubscribe() + } + } + } + onReplay(callback: (payload: { id: string; data: string }) => void): () => void { const unsubscribes = this.allProviders().map((provider) => provider.onReplay(callback)) let active = true @@ -237,28 +278,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } async shutdownFallbackSessions(): Promise { - const ids = [...this.sessionProviders] - .filter(([, provider]) => provider === this.fallback) - .map(([id]) => id) - const results = await Promise.allSettled( - ids.map(async (id) => { - await this.fallback.shutdown(id, { immediate: true }) - this.sessionProviders.delete(id) - }) - ) - // Why: this runs first in the daemon-restart sequence. A throw here would - // abort the whole restart and leave "Restart daemon" — the user's recovery - // path for a wedged terminal — unusable, recreating the original lockup. So - // it is best-effort: log failures, keep restarting, and only count the - // sessions that actually shut down. - const failed = results.filter((result) => result.status === 'rejected') - if (failed.length > 0) { - console.warn( - `[daemon] ${failed.length} local fallback PTY session(s) failed to shut down during daemon restart; continuing restart`, - ...failed.map((result) => (result as PromiseRejectedResult).reason) - ) - } - return results.length - failed.length + return shutdownDegradedFallbackSessions(this.sessionProviders, this.fallback) } getCurrentDaemonSessionIds(): string[] { diff --git a/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts new file mode 100644 index 00000000000..8ce1191722d --- /dev/null +++ b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts @@ -0,0 +1,430 @@ +import { describe, expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' +import { + buildAgentTuiStreamOps, + mulberry32, + splitIntoRandomChunks, + type AgentTuiStreamDims +} from '../../shared/agent-tui-ansi-fuzz-stream' +import { + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, + SNAPSHOT_REPLAY_PREAMBLE_ALT, + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + bufferHasSerializeHostileWrappedRow, + createRendererParityTerminal, + cursorPosition, + normalBufferRowsTrimmed, + visibleRowStyles, + visibleRows, + writeChunksToTerminal +} from '../../shared/terminal-restore-parity-fixture' + +// Differential garble gate for the hidden-terminal model/view contract +// (docs/reference/terminal-model-view-contract.md): with the hidden-delivery +// gate on, a hidden pane receives NOTHING — main's HeadlessEmulator is the +// source of truth and reveal repaints the renderer xterm from +// preamble + rehydrateSequences + snapshotAnsi (applyMainBufferSnapshot). +// This fuzz feeds seeded agent-TUI byte streams to the production emulator +// and to an always-visible renderer-parity terminal, then asserts the +// serialize→replay round trip reproduces the exact screen the renderer would +// have shown. Any diff = a garble bug on reveal. +// +// Runtime knobs: +// FUZZ_ITERATIONS=5000 deep/nightly mode (default 300, <60s combined with +// the reveal-reconciliation suite) +// FUZZ_SEED=1234 re-run exactly one seed (repro from a failure log) + +const DEFAULT_ITERATIONS = 300 +const FIXED_SEED = readPositiveIntEnv('FUZZ_SEED') +const ITERATIONS = + FIXED_SEED !== null ? 1 : (readPositiveIntEnv('FUZZ_ITERATIONS') ?? DEFAULT_ITERATIONS) +// Matches HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS in pty-connection.ts — the +// scrollback budget the reveal restore actually requests from main. +const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 + +function readPositiveIntEnv(name: string): number | null { + const raw = Number(process.env[name]) + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null +} + +const DIMS: readonly AgentTuiStreamDims[] = [ + { cols: 80, rows: 24 }, + { cols: 100, rows: 30 }, + { cols: 120, rows: 40 } +] + +type FidelityCase = { + seed: number + dims: AgentTuiStreamDims + ops: string[] + chunked: boolean +} + +type FidelityDiff = { + stage: string + expected: unknown + actual: unknown + /** True when the always-visible buffer matches the known upstream + * @xterm/addon-serialize blank-leading-wrapped-row bug predicate — see + * bufferHasSerializeHostileWrappedRow and the skipped repro test below. */ + knownSerializeWrapBug?: boolean +} + +function buildCase(seed: number): FidelityCase { + const rng = mulberry32(seed) + const dims = DIMS[Math.floor(rng() * DIMS.length)]! + const opCount = 12 + Math.floor(rng() * 28) + const ops = buildAgentTuiStreamOps(rng, dims, { + includeMouseModes: true, + includeOscHyperlinks: false, + opCount + }) + return { seed, dims, ops, chunked: true } +} + +function firstDiff(stage: string, expected: unknown, actual: unknown): FidelityDiff | null { + return JSON.stringify(expected) === JSON.stringify(actual) ? null : { stage, expected, actual } +} + +async function runFidelityCase(testCase: FidelityCase): Promise { + const stream = testCase.ops.join('') + const chunks = testCase.chunked + ? splitIntoRandomChunks(mulberry32(testCase.seed ^ 0x9e3779b9), stream, { + minLen: 3, + maxLen: 120 + }) + : [stream] + const emulator = new HeadlessEmulator({ cols: testCase.dims.cols, rows: testCase.dims.rows }) + const control = createRendererParityTerminal(testCase.dims) + const restored = createRendererParityTerminal(testCase.dims) + try { + for (const chunk of chunks) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, chunks) + + // Stage 1 — model fidelity: the emulator's screen must already match the + // renderer twin before any serialization enters the picture. + const modelDiff = firstDiff( + 'model-visible (HeadlessEmulator vs renderer twin)', + visibleRows(control.terminal), + emulator.getVisibleLines() + ) + if (modelDiff) { + return modelDiff + } + + // Stage 2 — reveal round trip: serialize exactly like + // serializeHiddenOutputRecoveryBuffer, replay exactly like + // applyMainBufferSnapshot, then compare against the always-visible twin. + const alt = emulator.isAlternateScreen + const snapshot = emulator.getSnapshot({ + scrollbackRows: alt ? 0 : HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS + }) + await writeChunksToTerminal(restored.terminal, [ + alt ? SNAPSHOT_REPLAY_PREAMBLE_ALT : SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + + const diffs = [ + firstDiff( + 'restore-visible-text', + visibleRows(control.terminal), + visibleRows(restored.terminal) + ), + firstDiff( + 'restore-visible-styles', + visibleRowStyles(control.terminal), + visibleRowStyles(restored.terminal) + ), + firstDiff( + 'restore-cursor', + cursorPosition(control.terminal), + cursorPosition(restored.terminal) + ), + firstDiff( + 'restore-mode-bracketed-paste', + control.terminal.modes.bracketedPasteMode, + restored.terminal.modes.bracketedPasteMode + ), + // Why alt is excluded from the two comparisons below: + // - scrollback: serializeHeadlessTerminalBuffer (orca-runtime.ts) + // deliberately forces scrollbackRows=0 while an alt-screen TUI is + // active, so normal-buffer history is not part of the alt contract. + // - application cursor: HeadlessEmulator.getModes reports + // applicationCursor false on the alternate buffer, so rehydrate omits + // ?1h there by design. + alt + ? null + : firstDiff( + 'restore-scrollback-text', + normalBufferRowsTrimmed(control.terminal), + normalBufferRowsTrimmed(restored.terminal) + ), + alt + ? null + : firstDiff( + 'restore-mode-application-cursor', + control.terminal.modes.applicationCursorKeysMode, + restored.terminal.modes.applicationCursorKeysMode + ) + ] + const diff = diffs.find((candidate) => candidate !== null) ?? null + if (!diff) { + return null + } + if (bufferHasSerializeHostileWrappedRow(control.terminal)) { + return { ...diff, knownSerializeWrapBug: true } + } + return diff + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } +} + +/** Greedy op-drop minimizer: re-runs the full differential pipeline on + * smaller op lists so a failure report carries the smallest byte stream that + * still diverges (plus its seed for exact replay via FUZZ_SEED). */ +async function minimizeFailure(testCase: FidelityCase): Promise { + let current = { ...testCase, chunked: false } + if ((await runFidelityCase(current)) === null) { + current = { ...testCase, chunked: true } + } + let budget = 400 + let shrunk = true + while (shrunk && budget > 0) { + shrunk = false + for (let i = current.ops.length - 1; i >= 0 && budget > 0; i--) { + const candidate = { ...current, ops: current.ops.toSpliced(i, 1) } + budget -= 1 + if ((await runFidelityCase(candidate)) !== null) { + current = candidate + shrunk = true + } + } + } + return current +} + +function formatFailure(minimized: FidelityCase, diff: FidelityDiff | null): string { + return [ + `HeadlessEmulator fidelity divergence — stage: ${diff?.stage ?? 'unknown'}`, + `seed: ${minimized.seed} (re-run: FUZZ_SEED=${minimized.seed} pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/headless-emulator-fidelity.fuzz.test.ts)`, + `dims: ${minimized.dims.cols}x${minimized.dims.rows} chunked: ${minimized.chunked}`, + `minimized ops (${minimized.ops.length}): ${JSON.stringify(minimized.ops)}`, + `expected (always-visible renderer twin): ${JSON.stringify(diff?.expected)}`, + `actual (snapshot restore replay): ${JSON.stringify(diff?.actual)}` + ].join('\n') +} + +describe('headless emulator snapshot fidelity fuzz', () => { + // Known-legitimate divergence, pinned so it cannot silently regress into a + // real one: xterm marks OSC 8 hyperlink cells underlined, SerializeAddon + // never re-emits OSC 8, and production compensates by shipping the ranges + // out-of-band in snapshot.oscLinks (collectHeadlessOscLinkRanges) for the + // renderer link provider to re-register. Byte-replay therefore keeps the + // TEXT but not the link underline — the metadata must carry the range. + it('drops OSC 8 underline from byte replay but preserves the range in snapshot metadata', async () => { + const emulator = new HeadlessEmulator({ cols: 60, rows: 10 }) + const restored = createRendererParityTerminal({ cols: 60, rows: 10 }) + try { + await emulator.write('\x1b]8;;https://example.com/pr/7\x07review link\x1b]8;;\x07 tail') + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + expect(visibleRows(restored.terminal)[0]).toBe('review link tail') + expect(snapshot.oscLinks).toContainEqual({ + row: 0, + startCol: 0, + endCol: 11, + uri: 'https://example.com/pr/7' + }) + } finally { + emulator.dispose() + restored.terminal.dispose() + } + }) + + it(`matches an always-visible renderer twin across ${ITERATIONS} seeded agent-TUI streams`, async () => { + // The known-and-pinned serialize wrap bug (A) is tolerated + counted so + // deep mode (FUZZ_ITERATIONS) surfaces only GENUINELY NEW divergences. + // Bugs B (bold-reset, fixed by the addon patch) and C (margin cursor, + // fixed by the absolute-cursor epilogue) are no longer tolerated — a + // regression fails the corpus loudly and the unskipped repros below. + let knownSerializeWrapBugHits = 0 + for (let i = 0; i < ITERATIONS; i++) { + const seed = FIXED_SEED ?? 1 + i + const testCase = buildCase(seed) + const diff = await runFidelityCase(testCase) + if (diff?.knownSerializeWrapBug) { + knownSerializeWrapBugHits += 1 + continue + } + if (diff) { + const minimized = await minimizeFailure(testCase) + const minimizedDiff = await runFidelityCase(minimized) + expect.fail(formatFailure(minimized, minimizedDiff ?? diff)) + } + } + // Guard the tolerance from swallowing the suite: the predicate tripping + // on most seeds means the gate has gone degenerate. + expect(knownSerializeWrapBugHits).toBeLessThan(Math.max(3, ITERATIONS * 0.5)) + }, 600_000) + + // ── HEADLINE FINDING (do not delete while unfixed upstream) ────────────── + // @xterm/addon-serialize 0.15.0-beta.287 does not round-trip null cells + // that touch a soft-wrap boundary. Two variants, both found by this fuzz + // and minimized below. Every Orca snapshot consumer is affected: hidden + // reveal, parked-tab reveal, sleep/wake restore, and mobile subscribe + // replay paint lost/shifted characters or stray '-' fillers whenever a TUI + // erased inside a soft-wrapped line (shell line editing, status lines wider + // than the pane, Claude Code in-place prompt redraws). + // + // V1 — cell loss (found by seed 31, minimized to 2 ops): + // Root cause: the wrap-validity ternary in SerializeAddon.ts (~L214) + // nextRowFirstChar.getChars() && isNextRowFirstCharDoubleWidth + // ? this._nullCellCount <= 1 : this._nullCellCount <= 0 + // binds as `(chars && doubleWidth) ? ...`, so a null-leading wrapped row + // passes as a "natural" wrap. The serializer then emits the previous row as + // full-width text (leaving xterm in wrap-pending) and skips the null cell + // with CUF (`ESC[1C`) — but CUF clamps at the right margin instead of + // crossing the wrap boundary, so the next character overwrites the previous + // row's last cell and the whole tail shifts left by one. + // cols=20: write 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K' + // live rows: ['ABCDEFGHIJKLMNOPQRST', ' VWXYZ12'] + // serialize(): 'ABCDEFGHIJKLMNOPQRST\x1b[1CVWXYZ12\x1b[8D' + // replayed rows: ['ABCDEFGHIJKLMNOPQRSV', 'WXYZ12'] ← 'T' eaten, tail shifted + // + // V2 — stray filler '-' (found by seed 157, minimized below): when the + // SOURCE row of a wrapped pair is entirely null (a TUI erased the whole + // first half of a wrapped line), the addon's forced-wrap "magic" writes + // nullCellCount+1 dashes and then cleans up with + // ESC[A ESC[(length-nullCellCount)C ESC[(nullCellCount)X ... + // With length === nullCellCount that cursor-forward becomes `ESC[0C`, and + // CSI param 0 means 1, so the ECH erase lands one cell right and the first + // '-' stays visible on the restored row. + // Unskip once the upstream fix (or a local serialize post-processor) lands. + it.skip('round-trips a wrapped line whose continuation row starts with an erased cell', async () => { + const emulator = new HeadlessEmulator({ cols: 20, rows: 6 }) + const control = createRendererParityTerminal({ cols: 20, rows: 6 }) + const restored = createRendererParityTerminal({ cols: 20, rows: 6 }) + try { + const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[1A\x1b[1K'] + for (const chunk of bytes) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, bytes) + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal)) + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } + }) + + // V2 repro of the headline finding above (stray '-' filler on a fully + // erased wrapped source row). Unskip alongside the V1 repro. + it.skip('round-trips a wrapped line whose source row was fully erased', async () => { + const emulator = new HeadlessEmulator({ cols: 20, rows: 6 }) + const control = createRendererParityTerminal({ cols: 20, rows: 6 }) + const restored = createRendererParityTerminal({ cols: 20, rows: 6 }) + try { + // Wrap a 28-char line, then erase the entire first (source) row of the + // wrapped pair: cursor up twice onto it, EL 2. + const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[2A\x1b[2K'] + for (const chunk of bytes) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, bytes) + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + // Fails today: restored row 0 shows '-' where the live row is blank. + expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal)) + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } + }) + + // ── Bug B regression guard: SGR bold on a dim→bold-only cell transition ── + // Upstream @xterm/addon-serialize emitted `\x1b[1;22m` for this transition; + // SGR 22 (normalIntensity) clears BOTH bold and dim, so the restored cell + // lost its bold. FIXED by the intensity-group reorder in + // config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch (22 before + // 1/2). Found by fuzz seeds 435, 770, 1321; mechanism in + // notes/garble-fuzz-divergences.md (Bug B). + it('preserves bold when serializing a dim cell followed by a bold-only cell', async () => { + const emulator = new HeadlessEmulator({ cols: 20, rows: 4 }) + const control = createRendererParityTerminal({ cols: 20, rows: 4 }) + const restored = createRendererParityTerminal({ cols: 20, rows: 4 }) + try { + // 'A' dim, 'B' bold-only. Live: A=dim, B=bold. The patched serializer + // emits 22;1 for the B transition (clear before re-set). + const bytes = ['\x1b[2mA\x1b[22m\x1b[1mB\x1b[0m'] + for (const chunk of bytes) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, bytes) + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + expect(visibleRowStyles(restored.terminal)).toEqual(visibleRowStyles(control.terminal)) + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } + }) + + // ── Bug C regression guard: cursor exact when the last row fills the margin ── + // Upstream @xterm/addon-serialize computes its relative cursor-restore from + // a wrap-pending position and lands one column short. FIXED Orca-side: the + // emulator snapshot appends an absolute CUP from the source's authoritative + // cursor (serializeWithAbsoluteCursor). Found by fuzz seeds 454, 1696; + // mechanism in notes/garble-fuzz-divergences.md (Bug C). + it('restores the cursor exactly when the last content row fills the right margin', async () => { + const emulator = new HeadlessEmulator({ cols: 10, rows: 4 }) + const control = createRendererParityTerminal({ cols: 10, rows: 4 }) + const restored = createRendererParityTerminal({ cols: 10, rows: 4 }) + try { + // Fill row 0 to exactly 10 cols (wrap-pending), then CUP the cursor to a + // known lower-row column. Live cursor is (x=4, y=2). + const bytes = ['0123456789\x1b[3;5H'] + for (const chunk of bytes) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, bytes) + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + expect(cursorPosition(restored.terminal)).toEqual(cursorPosition(control.terminal)) + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } + }) +}) diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 0a5832247c6..b9630b8ff7e 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -98,6 +98,74 @@ describe('HeadlessEmulator', () => { uri: 'https://example.com/issue/1234' }) }) + + it('serializes split synchronized rich TUI frames for model-backed replay', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 12 }) + const richFrame = [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + '\x1b[2;36m╭────────────────────────────╮\x1b[0m\r\n', + '\x1b[2;36m│ Codex rich restore 🟢 ███░ │\x1b[0m\r\n', + '\x1b[2;36m│ status streaming │\x1b[0m\r\n', + '\x1b[2;36m╰────────────────────────────╯\x1b[0m', + '\x1b[6;4H\x1b[?25h', + '\x1b[?2026l' + ].join('') + + // Why: hidden rich TUI bytes may arrive split across DEC 2026 frame + // boundaries; model/view work needs the headless model to preserve the + // final visible state before renderer writes can be removed. + await emulator.write(richFrame.slice(0, 17)) + await emulator.write(richFrame.slice(17, 91)) + await emulator.write(richFrame.slice(91)) + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.alternateScreen).toBe(true) + expect(snapshot.snapshotAnsi).toContain('Codex rich restore') + expect(snapshot.snapshotAnsi).toContain('🟢') + expect(snapshot.snapshotAnsi).toContain('███░') + expect(snapshot.snapshotAnsi).toContain('╭') + expect(snapshot.snapshotAnsi).not.toContain('\x1b[?2026h') + + const replay = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows }) + try { + await replay.write(snapshot.rehydrateSequences + snapshot.snapshotAnsi) + const replayed = replay.getSnapshot() + expect(replayed.modes.alternateScreen).toBe(true) + expect(replayed.snapshotAnsi).toContain('Codex rich restore') + expect(replayed.snapshotAnsi).toContain('🟢') + expect(replayed.snapshotAnsi).toContain('███░') + } finally { + replay.dispose() + } + }) + + it('preserves the normal buffer behind an alternate-screen snapshot', async () => { + emulator = new HeadlessEmulator({ cols: 40, rows: 6 }) + await emulator.write('shell history one\r\nshell history two') + await emulator.write('\x1b[?1049h\x1b[2J\x1b[HTUI frame') + + const snapshot = emulator.getSnapshot() + expect(snapshot.scrollbackAnsi).toContain('shell history one') + expect(snapshot.snapshotAnsi).toContain('TUI frame') + expect(snapshot.snapshotAnsi).not.toContain('shell history one') + + const replay = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows }) + try { + await replay.write( + snapshot.scrollbackAnsi + snapshot.rehydrateSequences + snapshot.snapshotAnsi + ) + expect(replay.getVisibleLines().join('\n')).toContain('TUI frame') + + await replay.write('\x1b[?1049l') + expect(replay.getVisibleLines().join('\n')).toContain('shell history one') + expect(replay.getVisibleLines().join('\n')).toContain('shell history two') + } finally { + replay.dispose() + } + }) }) describe('OSC-7 CWD tracking', () => { @@ -305,6 +373,49 @@ describe('HeadlessEmulator', () => { expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(false) }) + it('tracks kitty keyboard flags for emulator re-seed parity', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(0) + + await emulator.write('\x1b[=5;1u') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(5) + }) + + it('round-trips a pushed CSI > 1 u flag through the core-internals read path', async () => { + // Why: getKittyKeyboardFlags reads _core.coreService.kittyKeyboard.flags, + // a private xterm surface. If an xterm upgrade breaks that path this + // must fail loudly instead of the responder silently answering ?0u. + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x1b[>1u') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(1) + }) + + it('snapshots the active-buffer kitty flags (alt screen keeps its own set)', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + // Kitty flags are per screen buffer: entering the alt screen swaps to + // its own (empty) flag set, exactly what a CSI ? u reply would report. + await emulator.write('\x1b[=5;1u\x1b[?1049h') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(0) + + await emulator.write('\x1b[=3;1u') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(3) + + await emulator.write('\x1b[?1049l') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(5) + }) + + it('never pushes kitty flags into rehydrateSequences', async () => { + // Why: POST_REPLAY_REATTACH_RESET's deliberate kitty reset must stay + // authoritative for renderer replays (terminal-query-authority.md). + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[?1049h\x1b[=5;1u') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.kittyKeyboardFlags).toBe(5) + expect(snapshot.rehydrateSequences).not.toContain('u') + }) + it('tracks split SGR mouse reporting sequences', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) @@ -499,14 +610,16 @@ describe('HeadlessEmulator', () => { expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h') }) - it('rehydrates kitty keyboard flags a TUI pushed (CSI > u)', async () => { + it('records kitty flags without pushing them into renderer rehydration', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) // OMP/pi negotiate progressive enhancement with a level-1 push. await emulator.write('\x1b[>1u') const snapshot = emulator.getSnapshot() expect(snapshot.modes.kittyKeyboardFlags).toBe(1) - expect(snapshot.rehydrateSequences).toContain('\x1b[=1;1u') + // Why: renderer replay deliberately resets stale CSI-u state; the daemon + // warm-reattach path re-seeds the model from modes.kittyKeyboardFlags. + expect(snapshot.rehydrateSequences).not.toContain('\x1b[=1;1u') }) it('omits kitty rehydration after the TUI pops its flags', async () => { @@ -519,7 +632,7 @@ describe('HeadlessEmulator', () => { expect(snapshot.rehydrateSequences).not.toContain('u') }) - it('re-arms kitty flags after the alt-screen switch so they land on the negotiated screen', async () => { + it('keeps kitty flags out of alternate-screen renderer rehydration', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) await emulator.write('\x1b[?1049h\x1b[>1u') @@ -527,7 +640,7 @@ describe('HeadlessEmulator', () => { const altScreenIndex = snapshot.rehydrateSequences.indexOf('\x1b[?1049h') const kittyIndex = snapshot.rehydrateSequences.indexOf('\x1b[=1;1u') expect(altScreenIndex).toBeGreaterThanOrEqual(0) - expect(kittyIndex).toBeGreaterThan(altScreenIndex) + expect(kittyIndex).toBe(-1) }) it('drops kitty rehydration after a TUI soft reset (DECSTR)', async () => { diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 7ce69db0cc8..e7da2a4589e 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -3,13 +3,21 @@ import { Terminal } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' import { Unicode11Addon } from '@xterm/addon-unicode11' import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider' +import { + readSavedCursorRegister, + serializeWithAbsoluteCursor +} from '../../shared/terminal-serialize-absolute-cursor' import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail' -import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' -import { extractLastOscTitle } from '../../shared/agent-detection' +import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' -import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction' -import { parseFileUriPath } from './osc7-file-uri' -import { TerminalPrivateModeTracker } from './terminal-private-mode-tracker' +import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences' +import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' +import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner' +import { splitTerminalSnapshotAnsi } from './terminal-snapshot-ansi-buffers' +import { + installTerminalViewAttributeResponder, + type TerminalViewAttributeResponder +} from './terminal-view-attribute-responder' import type { TerminalSnapshot, TerminalModes } from './types' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' @@ -17,48 +25,87 @@ export type HeadlessEmulatorOptions = { cols: number rows: number scrollback?: number + /** Phase-5 model query responder sink (terminal-query-authority.md). + * When set, xterm-core auto-replies generated while parsing a write + * flagged `forwardQueryReplies` are forwarded here; all other emissions + * (seeds, hydration, snapshot replay, unsolicited core pushes) are + * discarded. The daemon Session must NEVER pass this — its emulator + * stays write-only forever (contract invariant: the daemon never + * answers). */ + onQueryReply?: (reply: string) => void pathFlavor?: 'posix' | 'win32' remotePosixFileUriAuthority?: boolean } +export type HeadlessEmulatorWriteOptions = { + /** Reply ownership captured at ingestion for this exact chunk. Default + * false is the main-side replay guard (twin of the renderer's + * replay-guard.ts): seed/hydration/snapshot writes never forward. */ + forwardQueryReplies?: boolean +} + type TerminalWithSynchronousWrite = Terminal & { _core?: { writeSync?: (data: string) => void + // Why: kitty keyboard flags are not on the public IModes; read the core + // service state the CSI =/>/< u handlers mutate. + coreService?: { + kittyKeyboard?: { flags?: number } + } } } const DEFAULT_SCROLLBACK = 5000 -const OSC_SCAN_TAIL_LIMIT = 4096 +// Keep in sync with the renderer twin in terminal-capability-replies.ts +// (main must not import renderer modules). +const CONPTY_DA1_RESPONSE = '\x1b[?61;4c' export class HeadlessEmulator { private terminal: Terminal private serializer: SerializeAddon - private cwd: string | null = null - private lastTitle: string | null = null - private oscScanTail = '' - private privateModes = new TerminalPrivateModeTracker() - private kittyKeyboardModes = new TerminalKittyKeyboardModeTracker() - private restoredOscLinks: TerminalOscLinkRange[] = [] - // Why: a PTY read can end mid-escape-sequence — those bytes live in xterm's - // parser, not the screen buffer, so serialize() drops them and the next - // chunk's continuation renders literally after a remote snapshot restore - // (#7329). Track the unparsed trailing partial at ingest (committed after - // xterm parses the same bytes, like the private-mode mirror) and ship it in - // the snapshot so the restorer can complete the sequence. - private partialEscapeTail = '' - private disposed = false + // Why: our restructure owns cwd/title via TerminalOscCwdTitleScanner and the + // DECSET mouse modes via TerminalMouseModeMirror (functionally identical to + // main's inline cwd/lastTitle/oscScanTail + TerminalPrivateModeTracker, which + // only tracks the same mouse modes). restoredOscLinks/disposed/partialEscapeTail + // are declared below. + private oscText: TerminalOscCwdTitleScanner + private mouseModes = new TerminalMouseModeMirror() private readonly pathFlavor?: 'posix' | 'win32' private readonly remotePosixFileUriAuthority: boolean + private restoredOscLinks: TerminalOscLinkRange[] = [] + private disposed = false + private onQueryReply: ((reply: string) => void) | null + private conptyDa1OverrideInstalled = false + private viewAttributeResponder: TerminalViewAttributeResponder | null = null + // Why: replies must be scoped to the exact write that carried the query. + // The window opens around the parse of a forward-flagged chunk and closes + // with it, so seeds/snapshots and unsolicited core emissions (e.g. native + // 997 pushes from option mutations) can never leak to the PTY. + private queryReplyForwardingDepth = 0 + // Why: a chunk ending mid-escape leaves the sequence in xterm's parser, not + // the buffer, so serialize() drops it and the next chunk's continuation + // renders literal after a restore (Bug E, notes/garble-fuzz-divergences.md). + // Committed alongside mouseModes: only after xterm parsed the same bytes. + private partialEscapeTail = '' constructor(opts: HeadlessEmulatorOptions) { this.pathFlavor = opts.pathFlavor this.remotePosixFileUriAuthority = opts.remotePosixFileUriAuthority === true + this.oscText = new TerminalOscCwdTitleScanner({ + pathFlavor: this.pathFlavor, + remotePosixAuthority: this.remotePosixFileUriAuthority + }) this.terminal = new Terminal({ cols: opts.cols, rows: opts.rows, scrollback: opts.scrollback ?? DEFAULT_SCROLLBACK, allowProposedApi: true, - logLevel: 'off' + logLevel: 'off', + // Why: parity with the renderer's buildDefaultTerminalOptions — parse + // CSI =/>/< u pushes so CSI ? u answers with the flags the hidden app + // actually pushed. Write-only daemon use is unaffected: keyboard state + // never alters serialization (terminal-query-authority.md §kitty). + vtExtensions: { kittyKeyboard: true } }) this.serializer = new SerializeAddon() @@ -72,34 +119,136 @@ export class HeadlessEmulator { this.terminal.loadAddon(new Unicode11Addon()) activateOrcaTerminalUnicodeProvider(this.terminal) - // Why no onData wiring: this emulator exists purely for state tracking - // (snapshots, cwd, mode flags). It MUST NOT respond to terminal query - // sequences (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The emulator parses - // data in-process synchronously before `handleSubprocessData` forwards - // it to the renderer over IPC, so any reply it emits would land on the - // shell's stdin ahead of the renderer's xterm reply and win the race. - // The renderer is the authoritative responder (it has the real theme, - // cursor position, and paste mode); a daemon-side reply would be a - // double-reply with wrong values. OSC 11 was the visible casualty: - // Claude Code's /theme auto always saw the emulator's default-black - // background regardless of Orca's configured terminal theme. + // Why onData is gated behind onQueryReply: by default this emulator is + // pure state tracking and MUST NOT respond to terminal query sequences + // (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The daemon emulator parses data + // in-process synchronously before `handleSubprocessData` forwards it to + // the renderer over IPC, so any reply it emitted would land on the + // shell's stdin ahead of the renderer's xterm reply and win the race — + // a double-reply with default-xterm values (OSC 11 default-black was + // the visible casualty). Only main's runtime per-PTY emulators pass a + // sink, and even then replies flow only for chunks the hidden-delivery + // gate DROPPED, where the renderer never sees the bytes and main is the + // single answerer. See docs/reference/terminal-query-authority.md. + this.onQueryReply = opts.onQueryReply ?? null + if (this.onQueryReply) { + this.terminal.onData((reply) => this.emitQueryReply(reply)) + } } - write(data: string): Promise { + /** Main-side twin of the renderer's terminal-capability-replies.ts: + * ConPTY 1.22+ blocks at spawn waiting for a DA1 reply, and the override + * variant (`CSI ?61;4c`) must win. Returning true consumes the query so + * xterm core's default `?1;2c` cannot double-reply (custom CSI handlers + * run before core's; false falls through). The reply still routes through + * the forwarding window, so replayed/seeded bytes never answer. */ + installConptyPrimaryDeviceAttributesOverride(): void { + // Why idempotent: the spawn mark can land after daemon stream data + // already created the emulator, so the override is installed both at + // creation and retrofitted at mark time — never stacked. + if (this.conptyDa1OverrideInstalled) { + return + } + this.conptyDa1OverrideInstalled = true + this.terminal.parser.registerCsiHandler({ final: 'c' }, (params) => { + const isPrimaryQuery = params.length === 0 || (params.length === 1 && params[0] === 0) + if (!isPrimaryQuery) { + return false + } + this.emitQueryReply(CONPTY_DA1_RESPONSE) + return true + }) + } + + /** Phase-5 slice-2 view-attribute bridge: the headless core has no theme + * service, so OSC 4/10/11/12 queries and DSR ?996n are answered from the + * renderer's pushed attributes via these parser handlers — never from + * emulator defaults. Runtime-only, like onQueryReply: the daemon Session + * must NEVER call this (its emulator stays write-only forever). */ + installViewAttributeResponder(getBaseAttributes: () => TerminalViewAttributes | null): void { + if (this.viewAttributeResponder) { + return + } + this.viewAttributeResponder = installTerminalViewAttributeResponder({ + parser: this.terminal.parser, + getBaseAttributes, + // emitQueryReply keeps replies inside the per-chunk forwarding window, + // so seeded/replayed view-attribute queries answer no one. + emitReply: (reply) => this.emitQueryReply(reply) + }) + } + + /** Applies a renderer view-attribute push: cursor options make xterm core + * answer DECRQSS DECSCUSR / DECRQM 12 renderer-true, and the per-PTY OSC + * color overrides are dropped because a theme apply overwrites mutated + * colors on visible panes too (ThemeService._setTheme parity). Option + * writes happen outside any forwarding window, so any core emission they + * trigger is discarded (main-side replay guard). */ + applyPushedViewAttributes(attributes: TerminalViewAttributes): void { + if (this.disposed) { + return + } + this.terminal.options.cursorStyle = attributes.cursorStyle + this.terminal.options.cursorBlink = attributes.cursorBlink + this.viewAttributeResponder?.clearColorOverrides() + } + + /** Re-seed parity for snapshot `modes.kittyKeyboardFlags` + * (terminal-query-authority.md §kitty): replays the persisted flags + * through the same `CSI = flags ; 1 u` parse a live push uses, so hidden + * `CSI ? u` reports them instead of `?0u`. Routed as an UNFLAGGED write — + * outside any forwarding window, it can never answer anything — and never + * into renderer rehydrateSequences (POST_REPLAY_REATTACH_RESET's kitty + * reset stays authoritative). */ + applyKittyKeyboardFlags(flags: number): Promise { + if (!Number.isInteger(flags) || flags <= 0) { + return Promise.resolve() + } + return this.write(`\x1b[=${flags};1u`) + } + + private emitQueryReply(reply: string): void { + if (this.queryReplyForwardingDepth > 0 && this.onQueryReply) { + this.onQueryReply(reply) + } + } + + /** Severs the reply sink at PTY teardown. Queued writeChain links may + * still parse after dispose is requested, and daemon respawns reuse + * session ids — a late reply must never reach a successor PTY. */ + disableQueryReplyForwarding(): void { + this.onQueryReply = null + } + + write(data: string, opts: HeadlessEmulatorWriteOptions = {}): Promise { if (this.disposed) { return Promise.resolve() } - if (this.tryWriteSync(data)) { + const forwardQueryReplies = opts.forwardQueryReplies === true + if (this.tryWriteSync(data, { forwardQueryReplies })) { return Promise.resolve() } - this.scanInputForOscState(data) + this.oscText.scan(data) + // Why the sentinel: xterm parses queued writes asynchronously, so opening + // the window at enqueue time would leak it over earlier queued unflagged + // chunks (seed/hydration bytes parsing while depth > 0). Write callbacks + // fire in FIFO parse order, so a zero-byte write whose callback opens the + // window brackets the parse of exactly this chunk; the data callback + // closes it. + if (forwardQueryReplies) { + this.terminal.write('', () => { + this.queryReplyForwardingDepth += 1 + }) + } return new Promise((resolve) => { this.terminal.write(data, () => { + if (forwardQueryReplies) { + this.queryReplyForwardingDepth -= 1 + } // Why: snapshots combine serialized xterm state with mirrored mouse // modes. Commit the mirror only after xterm has parsed the same bytes. - this.privateModes.scan(data) - this.kittyKeyboardModes.scan(data) + this.mouseModes.scan(data) this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) resolve() }) @@ -117,31 +266,30 @@ export class HeadlessEmulator { return this.tryWriteSync(data) } - private tryWriteSync(data: string): boolean { + private tryWriteSync(data: string, opts: HeadlessEmulatorWriteOptions = {}): boolean { const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync if (typeof writeSync !== 'function') { return false } - this.scanInputForOscState(data) + this.oscText.scan(data) + const forwardQueryReplies = opts.forwardQueryReplies === true + if (forwardQueryReplies) { + this.queryReplyForwardingDepth += 1 + } // Why: hidden renderer restore snapshots are requested immediately after // PTY bursts; queued headless writes can snapshot half-cleared TUI rows. - writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data) - this.privateModes.scan(data) - this.kittyKeyboardModes.scan(data) + try { + writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data) + } finally { + if (forwardQueryReplies) { + this.queryReplyForwardingDepth -= 1 + } + } + this.mouseModes.scan(data) this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) return true } - private scanInputForOscState(data: string): void { - const oscInput = this.oscScanTail + data - this.oscScanTail = this.extractOscScanTail(oscInput) - this.scanOsc7(oscInput) - const lastTitle = extractLastOscTitle(oscInput) - if (lastTitle !== null) { - this.lastTitle = lastTitle - } - } - resize(cols: number, rows: number): void { if (this.disposed) { return @@ -160,38 +308,65 @@ export class HeadlessEmulator { getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot { const modes = this.getModes() - const snapshotAnsi = this.normalizeSnapshotAnsiForModes( - this.serializer.serialize({ scrollback: opts.scrollbackRows }), - modes + // Why serializeWithAbsoluteCursor: SerializeAddon's relative cursor + // restore lands one column short after a margin-filling final row leaves + // replay wrap-pending; the trailing CUP survives the alt-marker slice. + // The saved-cursor register rides along so a post-restore DECRC lands + // where the hidden TUI saved, not at home. + const serializedAnsi = serializeWithAbsoluteCursor( + this.serializer, + this.terminal, + { scrollback: opts.scrollbackRows }, + readSavedCursorRegister(this.terminal) ) - return { + const { snapshotAnsi, scrollbackAnsi } = splitTerminalSnapshotAnsi(serializedAnsi, modes) + const snapshot: TerminalSnapshot = { snapshotAnsi, - scrollbackAnsi: '', + scrollbackAnsi, oscLinks: collectHeadlessOscLinkRanges( this.terminal, opts.scrollbackRows, this.restoredOscLinks ), - rehydrateSequences: this.buildRehydrateSequences(modes), - cwd: this.cwd, + rehydrateSequences: buildRehydrateSequences(modes), + cwd: this.oscText.cwd, modes, cols: this.terminal.cols, rows: this.terminal.rows, scrollbackLines: this.terminal.buffer.normal.length - this.terminal.rows, - lastTitle: this.lastTitle ?? undefined, + lastTitle: this.oscText.lastTitle ?? undefined, // Why: written LAST by the restorer (after any reset) so the next live // chunk completes this dangling sequence instead of rendering it literally - // (#7329). Its bytes are already counted by the snapshot seq. + // (Bug E / #7329). Its bytes are already counted by the snapshot seq. ...(this.partialEscapeTail.length > 0 ? { pendingEscapeTailAnsi: this.partialEscapeTail } : {}) } + if (this.partialEscapeTail.length > 0) { + // Why a separate field, not part of snapshotAnsi: consumers write their + // own reset sequences after the snapshot body, and any ESC written after + // a dangling partial would abort it. The restorer must write this LAST, + // immediately before post-snapshot live chunks. Its bytes are already + // counted by the snapshot seq (they were ingested), so tail-slicing + // arithmetic is unchanged. + snapshot.pendingEscapeTailAnsi = this.partialEscapeTail + } + return snapshot } get isAlternateScreen(): boolean { return this.terminal.buffer.active.type === 'alternate' } + /** The dangling incomplete escape at the current stream position (empty + * when none). Scan-authority handoffs seed the other side's fact scanners + * with it so a sequence split across the handoff neither mints a phantom + * bell (unseen OSC terminator) nor loses its fact. Contains no complete + * sequence by construction, so seeding can never double-fire. */ + get partialEscapeTailAnsi(): string { + return this.partialEscapeTail + } + /** Why: PSReadLine's Ctrl+L repaint is only safe at an empty prompt — with * pending input it re-renders at a cached buffer row that ConPTY's fixed * viewport doesn't track, painting the input well below the prompt. The @@ -219,15 +394,15 @@ export class HeadlessEmulator { } getCwd(): string | null { - return this.cwd + return this.oscText.cwd } setCwd(cwd: string | null): void { - this.cwd = cwd + this.oscText.cwd = cwd } setLastTitle(title: string): void { - this.lastTitle = title + this.oscText.lastTitle = title } setRestoredOscLinks(links: TerminalOscLinkRange[] | undefined): void { @@ -244,102 +419,25 @@ export class HeadlessEmulator { this.terminal.dispose() } - private scanOsc7(data: string): void { - scanOsc7Uris(data, (uri) => { - this.parseOsc7Uri(uri) - }) - } - - private extractOscScanTail(input: string): string { - return extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT) - } - - private normalizeSnapshotAnsiForModes(snapshotAnsi: string, modes: TerminalModes): string { - if (!modes.alternateScreen) { - return snapshotAnsi - } - const alternateScreenMarker = '\x1b[?1049h' - const start = snapshotAnsi.lastIndexOf(alternateScreenMarker) - if (start === -1) { - return snapshotAnsi - } - // Why: rehydrateSequences already enters the alternate screen and restores - // mouse modes. Dropping SerializeAddon's duplicate ?1049h keeps mobile's - // "slice from last alt-screen marker" replay from discarding those modes. - return snapshotAnsi.slice(start + alternateScreenMarker.length) - } - - private parseOsc7Uri(uri: string): void { - const parsed = parseFileUriPath(uri, { - pathFlavor: this.pathFlavor, - remotePosixAuthority: this.remotePosixFileUriAuthority - }) - if (parsed) { - this.cwd = parsed - } - } - private getModes(): TerminalModes { const buffer = this.terminal.buffer.active - const mouseTrackingMode = this.privateModes.mouseTrackingMode + const mouseTrackingMode = this.mouseModes.mouseTrackingMode return { bracketedPaste: this.terminal.modes.bracketedPasteMode, mouseTracking: mouseTrackingMode !== 'none', mouseTrackingMode, - sgrMouseMode: this.privateModes.sgrMouseMode, - sgrMousePixelsMode: this.privateModes.sgrMousePixelsMode, + sgrMouseMode: this.mouseModes.sgrMouseMode, + sgrMousePixelsMode: this.mouseModes.sgrMousePixelsMode, applicationCursor: buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false, alternateScreen: buffer.type === 'alternate', - kittyKeyboardFlags: this.kittyKeyboardModes.flags + kittyKeyboardFlags: this.getKittyKeyboardFlags() } } - private buildRehydrateSequences(modes: TerminalModes): string { - const seqs: string[] = [] - if (modes.alternateScreen) { - seqs.push('\x1b[?1049h') - } - if (modes.bracketedPaste) { - seqs.push('\x1b[?2004h') - } - if (modes.applicationCursor) { - seqs.push('\x1b[?1h') - } - // Why: mobile alt-screen scroll gestures need xterm's mouse mode restored - // from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way. - switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') { - case 'x10': - seqs.push('\x1b[?9h') - break - case 'vt200': - seqs.push('\x1b[?1000h') - break - case 'drag': - seqs.push('\x1b[?1002h') - break - case 'any': - seqs.push('\x1b[?1003h') - break - case 'none': - break - } - // Why: xterm tracks the mouse protocol and SGR encoding as independent - // modes, so snapshots must preserve the encoding even when reporting is off. - if (modes.sgrMousePixelsMode) { - seqs.push('\x1b[?1016h') - } else if (modes.sgrMouseMode) { - seqs.push('\x1b[?1006h') - } - // Why: kitty keyboard flags are per-screen state SerializeAddon cannot - // capture; without re-arming them, the still-running TUI keeps expecting - // protocol-encoded keys the restored client no longer sends. `=` (set) - // instead of `>` (push) so repeated replays cannot grow the flag stack. - // Emitted after the alt-screen switch above so the flags land on the - // screen the TUI negotiated them on. - if (modes.kittyKeyboardFlags && modes.kittyKeyboardFlags > 0) { - seqs.push(`\x1b[=${modes.kittyKeyboardFlags};1u`) - } - return seqs.join('') + private getKittyKeyboardFlags(): number { + const flags = (this.terminal as TerminalWithSynchronousWrite)._core?.coreService?.kittyKeyboard + ?.flags + return typeof flags === 'number' ? flags : 0 } } diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index 9453488c2d9..47b96b14547 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -132,6 +132,24 @@ export class HistoryManager { }) } + // Why: wake after sleep re-spawns a session whose history was closed by the + // sleep-time kill. Re-register the writer without deleting checkpoint.json + // (still the only recovery data until the next tick) and clear endedAt so + // the next sleep can cold-restore this session again. + reopenSession(sessionId: string): void { + this.disabledSessions.delete(sessionId) + this.registerWriter(sessionId) + const writer = this.writers.get(sessionId) + if (!writer) { + return + } + try { + this.updateMeta(writer.dir, { endedAt: null, exitCode: null }) + } catch (err) { + this.handleWriteError(sessionId, err) + } + } + suspendSession(sessionId: string): void { // Why: if a fresh daemon cannot accept recovered scrollback, leaving its // writer active would let the next checkpoint overwrite the only good copy. diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index f6661b50044..8d31e3c638d 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -152,7 +152,13 @@ export class HistoryReader { }) try { if (checkpoint) { - if (!emulator.writeSync(checkpoint.rehydrateSequences + checkpoint.snapshotAnsi)) { + if ( + !emulator.writeSync( + (checkpoint.scrollbackAnsi ?? '') + + checkpoint.rehydrateSequences + + checkpoint.snapshotAnsi + ) + ) { return null } emulator.setRestoredOscLinks(checkpoint.oscLinks) @@ -198,12 +204,8 @@ export class HistoryReader { cwd: string | null, meta: SessionMeta ): ColdRestoreInfo { - // Why: HeadlessEmulator.getSnapshot() doesn't populate scrollbackAnsi - // (it's always ''). For non-alt-screen snapshots, snapshotAnsi IS the - // normal buffer content and is safe to use as scrollback. For alt-screen - // snapshots, snapshotAnsi is the serialized TUI buffer (not raw PTY - // stream); return empty instead — the adapter skips cold restore when - // scrollbackAnsi is falsy. + // Why: legacy normal snapshots stored their buffer only in snapshotAnsi; + // current alt snapshots carry their normal buffer in scrollbackAnsi. const scrollbackAnsi = snapshot.scrollbackAnsi || (snapshot.modes?.alternateScreen ? '' : snapshot.snapshotAnsi) return { diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index c732a16e1cf..119b3fb9430 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1068,6 +1068,30 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl dead = true } }, + // Why pause/resume work on Windows too: node-pty's base Terminal + // implements both as socket pause/resume (lib/terminal.js), and + // WindowsTerminal wires _socket to the ConPTY conout pipe — pausing stops + // conout reads so ConPTY's bounded buffer backpressures the child. + pause: () => { + if (dead) { + return + } + try { + proc.pause() + } catch { + /* native handle already torn down — flow control is best-effort */ + } + }, + resume: () => { + if (dead) { + return + } + try { + proc.resume() + } catch { + /* native handle already torn down — flow control is best-effort */ + } + }, clear: () => { if (dead) { return diff --git a/src/main/daemon/reattach-snapshot.test.ts b/src/main/daemon/reattach-snapshot.test.ts index a8bdf252442..aec624cbf01 100644 --- a/src/main/daemon/reattach-snapshot.test.ts +++ b/src/main/daemon/reattach-snapshot.test.ts @@ -37,7 +37,7 @@ function createMockSubprocess(): SubprocessHandle & { // for a reattach. Alt-screen sessions include the full ANSI snapshot because function buildReattachPayload(snapshot: ReturnType) { const isAltScreen = snapshot.modes.alternateScreen - const fullPayload = snapshot.rehydrateSequences + snapshot.snapshotAnsi + const fullPayload = snapshot.scrollbackAnsi + snapshot.rehydrateSequences + snapshot.snapshotAnsi return { rehydrateSequences: snapshot.rehydrateSequences, snapshotAnsi: snapshot.snapshotAnsi, @@ -274,6 +274,24 @@ describe('reattach snapshot flow', () => { expect(payload.fullPayload).toContain('Codex TUI content') }) + it('returns to preserved shell history after the reattached TUI exits', async () => { + const emulator = new HeadlessEmulator({ cols: 80, rows: 10 }) + await emulator.write('shell output before Codex\r\n$ codex') + await emulator.write('\x1b[?1049h\x1b[2J\x1b[HCodex TUI content') + const payload = buildReattachPayload(emulator.getSnapshot()) + emulator.dispose() + + const replay = new HeadlessEmulator({ cols: 80, rows: 10 }) + try { + await replay.write(payload.fullPayload) + expect(replay.getVisibleLines().join('\n')).toContain('Codex TUI content') + await replay.write('\x1b[?1049l') + expect(replay.getVisibleLines().join('\n')).toContain('shell output before Codex') + } finally { + replay.dispose() + } + }) + it('SIGWINCH repaint after rehydrate produces clean single render', async () => { const h = createHost() await h.createOrAttach({ diff --git a/src/main/daemon/session-ingest-throughput.bench.test.ts b/src/main/daemon/session-ingest-throughput.bench.test.ts new file mode 100644 index 00000000000..e226ddf869d --- /dev/null +++ b/src/main/daemon/session-ingest-throughput.bench.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { performance } from 'node:perf_hooks' +import { Session, type SubprocessHandle } from './session' + +// Benchmark harness for the terminal performance initiative: measures the +// daemon-side ingest rate (Session.handleSubprocessData -> HeadlessEmulator +// write + pending-output recording + client fanout) for the same workload +// shapes as tools/benchmarks/terminal-pipeline-bench.mjs. Bare headless +// xterm parses these at ~80-100 MB/s; the end-to-end Orca pipeline measured +// 2-15 MB/s (baseline-jul02) — this isolates the daemon layer's share. +// Run with: +// ORCA_TERMINAL_PERF_BENCH=1 pnpm vitest run \ +// src/main/daemon/session-ingest-throughput.bench.test.ts \ +// --config config/vitest.config.ts +const benchEnabled = process.env.ORCA_TERMINAL_PERF_BENCH === '1' + +const COLS = 114 +const ROWS = 85 +const TARGET_BYTES = 10 * 1024 * 1024 +const CHUNK = 64 * 1024 + +function asciiLog(targetBytes: number): string { + const parts: string[] = [] + let bytes = 0 + let line = 0 + while (bytes < targetBytes) { + line++ + const s = `\x1b[32m[build ${String(line).padStart(6, '0')}]\x1b[0m compile transform resolve bundle emit chunk module (${line % 5000}ms)\r\n` + parts.push(s) + bytes += s.length + } + return parts.join('') +} + +function agentTui(targetBytes: number): string { + const statusRows = 10 + const parts: string[] = [] + let bytes = 0 + let frame = 0 + let painted = false + const push = (s: string): void => { + parts.push(s) + bytes += Buffer.byteLength(s, 'utf8') + } + while (bytes < targetBytes) { + frame++ + push('\x1b[?2026h') + if (painted) { + push(`\x1b[${statusRows}A\x1b[0J`) + } + push(`\x1b[2m●\x1b[0m transcript line for frame ${frame} with some words\r\n`) + for (let r = 0; r < statusRows; r++) { + push( + `\x1b[38;5;${33 + (r % 6)}m⠼ task ${frame % 100}·${r}\x1b[0m ${'▇'.repeat((frame + r) % 40)}\r\n` + ) + } + painted = true + push('\x1b[?2026l') + } + return parts.join('') +} + +function makeSubprocess(): SubprocessHandle & { emit: (data: string) => void } { + let onData: ((data: string) => void) | null = null + return { + pid: 4242, + getForegroundProcess: () => 'bench', + write: () => {}, + resize: () => {}, + kill: () => {}, + forceKill: () => {}, + signal: () => {}, + onData: (cb) => { + onData = cb + }, + onExit: () => {}, + dispose: () => {}, + emit: (data: string) => onData?.(data) + } +} + +function ingest(fixture: string, drainPendingEveryChunks: number | null): number { + const subprocess = makeSubprocess() + const session = new Session({ + sessionId: 'bench', + cols: COLS, + rows: ROWS, + subprocess, + shellReadySupported: false + }) + session.attachClient({ onData: () => {}, onExit: () => {} }) + // Warmup primes JIT paths. + subprocess.emit(fixture.slice(0, 256 * 1024)) + session.takePendingOutput(false) + const start = performance.now() + let chunks = 0 + for (let i = 0; i < fixture.length; i += CHUNK) { + subprocess.emit(fixture.slice(i, i + CHUNK)) + chunks++ + // Why: without periodic takes the 2MB pending cap overflows and recording + // short-circuits, understating the real steady-state cost. The 5s adapter + // tick drains in production; drain per ~1.5MB approximates a hot session. + if (drainPendingEveryChunks && chunks % drainPendingEveryChunks === 0) { + session.takePendingOutput(false) + } + } + const ms = performance.now() - start + session.dispose() + return ms +} + +describe.skipIf(!benchEnabled)('daemon session ingest throughput', () => { + it('measures MB/s per workload shape', () => { + const rows: string[] = [] + for (const [name, fixture] of [ + ['ascii-log', asciiLog(TARGET_BYTES)], + ['agent-tui', agentTui(TARGET_BYTES)] + ] as const) { + const bytes = Buffer.byteLength(fixture, 'utf8') + const ms = ingest(fixture, 24) + const rate = bytes / 1024 / 1024 / (ms / 1000) + rows.push( + `${name}: ${rate.toFixed(1)} MB/s (${ms.toFixed(0)}ms for ${(bytes / 1024 / 1024).toFixed(1)}MB)` + ) + } + // eslint-disable-next-line no-console -- bench harness output + console.log(`\n[session-ingest] ${COLS}x${ROWS}\n ${rows.join('\n ')}`) + expect(rows.length).toBe(2) + }) +}) diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 478b4ea99f0..ade8b18fe15 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Session } from './session' +import { PRODUCER_PAUSE_FAILSAFE_MS, Session } from './session' import type { SessionState, ShellReadyState } from './types' // Stub the subprocess — Session talks to it via an interface, not child_process directly. @@ -11,6 +11,8 @@ function createMockSubprocess() { let killed = false let clearCalls = 0 let pid = 12345 + let pauseCalls = 0 + let resumeCalls = 0 return { written, @@ -21,6 +23,12 @@ function createMockSubprocess() { get pid() { return pid }, + get pauseCalls() { + return pauseCalls + }, + get resumeCalls() { + return resumeCalls + }, foregroundProcess: null as string | null, getForegroundProcess(): string | null { return this.foregroundProcess @@ -29,6 +37,12 @@ function createMockSubprocess() { written.push(data) }, resize(_cols: number, _rows: number) {}, + pause() { + pauseCalls++ + }, + resume() { + resumeCalls++ + }, get clearCalls() { return clearCalls }, @@ -174,14 +188,22 @@ describe('Session', () => { describe('emulator does not reply to terminal queries', () => { // Why: daemon emulator parses in-process synchronously — before - // handleSubprocessData forwards bytes to the renderer over IPC — so any - // auto-reply it emits races ahead of the renderer's xterm and clobbers - // it with default-xterm values (no theme, stale cursor). The renderer is - // the authoritative responder; a daemon-side reply to any query is a bug. + // handleSubprocessData forwards bytes onward — so any auto-reply it + // emits races ahead of the live answerer and clobbers it with + // default-xterm values (no theme, stale cursor). Query authority is + // structural (terminal-query-authority.md): a delivered chunk is + // answered by the consuming view's xterm, a hidden-dropped chunk by + // MAIN's runtime model responder. The daemon emulator is neither — it + // stays write-only forever, and these pins are permanent. it.each([ + ['OSC 10 foreground-color', '\x1b]10;?\x07'], ['OSC 11 background-color', '\x1b]11;?\x07'], + ['OSC 12 cursor-color', '\x1b]12;?\x1b\\'], ['DA1 device-attributes', '\x1b[c'], - ['DSR cursor-position', '\x1b[6n'] + ['DA2 secondary device-attributes', '\x1b[>c'], + ['DSR terminal status', '\x1b[5n'], + ['DSR cursor-position', '\x1b[6n'], + ['DECRPM bracketed-paste mode', '\x1b[?2004$p'] ])('does not reply to %s query', async (_label, query) => { createSession({ shellReadySupported: false }) subprocess.simulateData(query) @@ -260,6 +282,15 @@ describe('Session', () => { expect(session.getSnapshot()?.snapshotAnsi).not.toContain('orca-shell-ready') }) + it('publishes an absolute output sequence with live snapshots', () => { + createSession() + subprocess.simulateData('first') + subprocess.simulateData('🟢second') + + expect(session.getSnapshot()?.outputSequence).toBe('first🟢second'.length) + expect(session.takePendingOutput(true)?.snapshot?.outputSequence).toBe('first🟢second'.length) + }) + it('releases held marker-prefix bytes before flushing queued input on timeout', () => { createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 }) const received: string[] = [] @@ -600,4 +631,103 @@ describe('Session', () => { expect(session.state).toBe('exited') }) }) + + describe('producer flow control', () => { + it('pauses the subprocess and auto-resumes via the lost-resume failsafe', () => { + createSession() + session.pauseProducer() + expect(subprocess.pauseCalls).toBe(1) + expect(subprocess.resumeCalls).toBe(0) + + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1) + expect(subprocess.resumeCalls).toBe(0) + vi.advanceTimersByTime(1) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('resumeProducer resumes once and cancels the failsafe timer', () => { + createSession() + session.pauseProducer() + session.resumeProducer() + expect(subprocess.resumeCalls).toBe(1) + + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS * 2) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('resumeProducer without a matching pause is a no-op', () => { + createSession() + session.resumeProducer() + expect(subprocess.resumeCalls).toBe(0) + }) + + it('re-pausing re-arms the failsafe window', () => { + createSession() + session.pauseProducer() + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1_000) + session.pauseProducer() + + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1) + expect(subprocess.resumeCalls).toBe(0) + vi.advanceTimersByTime(1) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('kill() resumes a paused producer before signalling the child', () => { + createSession() + session.pauseProducer() + session.kill() + expect(subprocess.resumeCalls).toBe(1) + expect(subprocess.killed).toBe(true) + }) + + it('dispose() resumes a paused producer and clears the failsafe', () => { + createSession() + session.pauseProducer() + session.dispose() + expect(subprocess.resumeCalls).toBe(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('subprocess exit clears the failsafe without resuming a reaped child', () => { + createSession() + session.pauseProducer() + subprocess.simulateExit(0) + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS * 2) + expect(subprocess.resumeCalls).toBe(0) + }) + + it('ignores pauseProducer on an exited session', () => { + createSession() + subprocess.simulateExit(0) + session.pauseProducer() + expect(subprocess.pauseCalls).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('detaching the last client resumes a paused producer', () => { + createSession() + const token = session.attachClient({ onData: () => {}, onExit: () => {} }) + session.pauseProducer() + session.detachClient(token) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('keeps the pause while another client is still attached', () => { + createSession() + const token = session.attachClient({ onData: () => {}, onExit: () => {} }) + session.attachClient({ onData: () => {}, onExit: () => {} }) + session.pauseProducer() + session.detachClient(token) + expect(subprocess.resumeCalls).toBe(0) + }) + + it('detachAllClients resumes a paused producer', () => { + createSession() + session.attachClient({ onData: () => {}, onExit: () => {} }) + session.pauseProducer() + session.detachAllClients() + expect(subprocess.resumeCalls).toBe(1) + }) + }) }) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 0d377c7f3f0..80987b5a1bb 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -31,6 +31,11 @@ const KILL_TIMEOUT_MS = 5_000 // Worst-case wire size for a full take is ~6x this (each control char // JSON-escapes to six bytes) and must stay under NDJSON_MAX_LINE_BYTES (16MB). const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024 +// Why: producer pause is requested over a fire-and-forget notification, so the +// matching resume can be lost (main crash, dropped socket). A lost resume must +// never wedge a shell: auto-resume after this window; a still-flooded main +// re-asserts the pause on its next watermark check. +export const PRODUCER_PAUSE_FAILSAFE_MS = 5_000 export type SubprocessHandle = { pid: number @@ -42,6 +47,11 @@ export type SubprocessHandle = { startupCommandDeliveredInShellArgs?: boolean write(data: string): void resize(cols: number, rows: number): void + /** Stop reading the PTY fd (node-pty pause()) so the kernel/ConPTY buffer + * fills and a flooding child blocks on write. Optional: handles that + * cannot pause simply omit it and flow control degrades to a no-op. */ + pause?(): void + resume?(): void /** Resync the native PTY's own screen state after a frontend clear. * No-op except on Windows/ConPTY, where a stale ConPTY cursor row makes * the next prompt repaint land below a blank gap. */ @@ -102,6 +112,9 @@ export class Session { private pendingOutputBytes = 0 private pendingOutputOverflowed = false private pendingOutputSeq = 0 + private outputSequence = 0 + private producerPaused = false + private producerPauseFailsafeTimer: ReturnType | null = null private readonly _historySeeded: boolean | undefined constructor(opts: SessionOptions) { @@ -198,12 +211,52 @@ export class Session { this.subprocess.resize(cols, rows) } + /** Producer-side flow control: stop reading the PTY fd so the flooding + * child blocks on write (kernel backpressure). Arms the lost-resume + * failsafe; re-pausing re-arms it (main re-asserts during long floods). */ + pauseProducer(): void { + if (this._state === 'exited' || this._disposed) { + return + } + this.producerPaused = true + this.subprocess.pause?.() + if (this.producerPauseFailsafeTimer) { + clearTimeout(this.producerPauseFailsafeTimer) + } + this.producerPauseFailsafeTimer = setTimeout(() => { + this.producerPauseFailsafeTimer = null + this.producerPaused = false + this.subprocess.resume?.() + }, PRODUCER_PAUSE_FAILSAFE_MS) + } + + resumeProducer(): void { + this.releaseProducerPause({ resume: true }) + } + + private releaseProducerPause(opts: { resume: boolean }): void { + if (this.producerPauseFailsafeTimer) { + clearTimeout(this.producerPauseFailsafeTimer) + this.producerPauseFailsafeTimer = null + } + if (!this.producerPaused) { + return + } + this.producerPaused = false + if (opts.resume) { + this.subprocess.resume?.() + } + } + kill(): void { if (this._state === 'exited' || this._isTerminating) { return } this._isTerminating = true + // Why: a paused child can be blocked inside write(); resume before + // signalling so it can run signal handlers and actually exit. + this.releaseProducerPause({ resume: true }) this.subprocess.kill() this.killTimer = setTimeout(() => { @@ -231,17 +284,30 @@ export class Session { if (idx !== -1) { this.attachedClients.splice(idx, 1) } + // Why: with no attached client, nobody will ever send resumePty — a + // paused shell would sit wedged until the failsafe. Resume eagerly. + if (this.attachedClients.length === 0) { + this.releaseProducerPause({ resume: true }) + } } detachAllClients(): void { this.attachedClients.length = 0 + this.releaseProducerPause({ resume: true }) } - getSnapshot(): TerminalSnapshot | null { + getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { if (this._disposed) { return null } - return this.emulator.getSnapshot() + return { ...this.emulator.getSnapshot(opts), outputSequence: this.outputSequence } + } + + getPartialEscapeTailAnsi(): string { + if (this._disposed) { + return '' + } + return this.emulator.partialEscapeTailAnsi } // Why: the size the PTY actually applied (emulator dims, which Session.resize @@ -282,7 +348,7 @@ export class Session { : records, seq: this.pendingOutputSeq, overflowed, - snapshot: includeSnapshot ? this.emulator.getSnapshot() : null + snapshot: includeSnapshot ? this.getSnapshot() : null } } @@ -424,6 +490,9 @@ export class Session { return } this._disposed = true + // Why: never leave a paused fd behind on any teardown path — the handle's + // own dead-guard makes this a no-op when the child is already reaped. + this.releaseProducerPause({ resume: true }) if (this.killTimer) { clearTimeout(this.killTimer) this.killTimer = null @@ -490,6 +559,10 @@ export class Session { return } + // Why: daemon stream thinning can omit bytes before main sees them. The + // absolute count lets an authoritative snapshot cover those gaps while + // renderer reconciliation deduplicates any queued post-snapshot tail. + this.outputSequence += data.length // Feed data to headless emulator for state tracking this.emulator.write(data) this.recordPendingOutput({ kind: 'output', data }) @@ -507,6 +580,9 @@ export class Session { this._exitCode = code this._state = 'exited' + // Why resume:false — the child is reaped, so there is nothing to unblock; + // only the failsafe timer must not outlive the session. + this.releaseProducerPause({ resume: false }) this.releaseHeldShellReadyBytes() if (this.killTimer) { diff --git a/src/main/daemon/terminal-history-incremental-restore.test.ts b/src/main/daemon/terminal-history-incremental-restore.test.ts index 8cd9a42a307..544ff2a4c75 100644 --- a/src/main/daemon/terminal-history-incremental-restore.test.ts +++ b/src/main/daemon/terminal-history-incremental-restore.test.ts @@ -183,7 +183,7 @@ describe('incremental terminal history restore', () => { expect(restore!.scrollbackAnsi).not.toContain('cleared away') }) - it('skips restorable content for sessions crashed inside the alt screen', async () => { + it('preserves normal history without treating active alt content as scrollback', async () => { await manager.appendIncrements(SESSION_ID, 1, [ { kind: 'output', data: 'normal output\r\n\x1b[?1049halt screen content' } ]) @@ -191,9 +191,8 @@ describe('incremental terminal history restore', () => { const restore = reader.detectColdRestore(SESSION_ID) expect(restore).not.toBeNull() expect(restore!.modes.alternateScreen).toBe(true) - // Why: the adapter skips cold restore when scrollbackAnsi is empty — alt - // buffer contents must not replay into a fresh shell. - expect(restore!.scrollbackAnsi).toBe('') + expect(restore!.scrollbackAnsi).toContain('normal output') + expect(restore!.scrollbackAnsi).not.toContain('alt screen content') }) it('resets the log on checkpoint so old records are not replayed twice', async () => { diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 8b8968931f5..644356a6c4f 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -197,6 +197,21 @@ export class TerminalHost { this.getAliveSession(sessionId).resize(cols, rows) } + // Why null-not-throw (unlike write/resize): pause/resume are best-effort + // flow-control hints; a session that exited while the notify was in flight + // must not surface an error or a synthetic exit. + pauseProducer(sessionId: string): void { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + return + } + session.pauseProducer() + } + + resumeProducer(sessionId: string): void { + this.sessions.get(sessionId)?.resumeProducer() + } + kill(sessionId: string, opts: { immediate?: boolean } = {}): void { const session = this.getAliveSession(sessionId) this.recordTombstone(sessionId) @@ -266,12 +281,22 @@ export class TerminalHost { // Why: unlike getAliveSession (which throws), this returns null for dead/missing // sessions. Checkpoint is best-effort — a session that exited between the timer // firing and the RPC arriving should not throw. - getSnapshot(sessionId: string): TerminalSnapshot | null { + getSnapshot(sessionId: string, opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { const session = this.sessions.get(sessionId) if (!session || !session.isAlive) { return null } - return session.getSnapshot() + return session.getSnapshot(opts) + } + + // Why: scan-authority handoff seed (null-not-throw like getSnapshot) — the + // emulator's dangling incomplete escape at the current stream position. + getPartialEscapeTailAnsi(sessionId: string): string { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + return '' + } + return session.getPartialEscapeTailAnsi() } // Why: read-only readback of the size the PTY actually applied (null-not-throw diff --git a/src/main/daemon/terminal-mode-rehydrate-sequences.ts b/src/main/daemon/terminal-mode-rehydrate-sequences.ts new file mode 100644 index 00000000000..77e3603d4f5 --- /dev/null +++ b/src/main/daemon/terminal-mode-rehydrate-sequences.ts @@ -0,0 +1,47 @@ +import type { TerminalModes } from './types' + +// Why no kitty flags here: rehydrateSequences feeds renderer xterms, and +// POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C +// hazard) must stay authoritative. modes.kittyKeyboardFlags exists for +// emulator re-seed parity only; a re-seeded emulator answers ?0u and +// protocol-conformant programs re-push. +export function buildRehydrateSequences(modes: TerminalModes): string { + const seqs: string[] = [] + if (modes.alternateScreen) { + // Why: normal-buffer serialization can leave its pen active, while the + // separately serialized alt body assumes it starts from default SGR. + seqs.push('\x1b[0m\x1b[?1049h') + } + if (modes.bracketedPaste) { + seqs.push('\x1b[?2004h') + } + if (modes.applicationCursor) { + seqs.push('\x1b[?1h') + } + // Why: mobile alt-screen scroll gestures need xterm's mouse mode restored + // from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way. + switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') { + case 'x10': + seqs.push('\x1b[?9h') + break + case 'vt200': + seqs.push('\x1b[?1000h') + break + case 'drag': + seqs.push('\x1b[?1002h') + break + case 'any': + seqs.push('\x1b[?1003h') + break + case 'none': + break + } + // Why: xterm tracks the mouse protocol and SGR encoding as independent + // modes, so snapshots must preserve the encoding even when reporting is off. + if (modes.sgrMousePixelsMode) { + seqs.push('\x1b[?1016h') + } else if (modes.sgrMouseMode) { + seqs.push('\x1b[?1006h') + } + return seqs.join('') +} diff --git a/src/main/daemon/terminal-mouse-mode-mirror.ts b/src/main/daemon/terminal-mouse-mode-mirror.ts new file mode 100644 index 00000000000..8f8b284f7f4 --- /dev/null +++ b/src/main/daemon/terminal-mouse-mode-mirror.ts @@ -0,0 +1,119 @@ +import type { TerminalModes } from './types' + +type MouseTrackingMode = NonNullable + +// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. +// Keep parser state far beyond normal mode lists while still bounding memory. +const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096 + +/** + * Mirrors DECSET mouse-protocol/encoding state from the raw byte stream. + * xterm's public modes API does not expose which mouse protocol is active, + * so snapshots track it independently of the headless terminal; callers + * must feed `scan()` the same bytes the terminal parsed, in order. + */ +export class TerminalMouseModeMirror { + private scanTail = '' + private trackingModeState: MouseTrackingMode = 'none' + private sgrMouseModeState = false + private sgrMousePixelsModeState = false + + get mouseTrackingMode(): MouseTrackingMode { + return this.trackingModeState + } + + get sgrMouseMode(): boolean { + return this.sgrMouseModeState + } + + get sgrMousePixelsMode(): boolean { + return this.sgrMousePixelsModeState + } + + scan(data: string): void { + // Why the pre-filter: this runs on the daemon's per-chunk hot path for + // every session; a flood chunk with no private-mode/reset introducer + // must not pay the regex pass (measured share of a 2.2x ingest + // regression — findings log 2026-07-03). Split sequences stay correct: + // an introducer split across chunks either left a non-empty scanTail + // (previous partial) or ends this chunk, which extractScanTail retains. + if ( + this.scanTail.length === 0 && + !data.includes('\x1b[?') && + !data.includes('\x1bc') && + !data.includes('\x9b') + ) { + this.scanTail = this.extractScanTail(data) + return + } + const input = this.scanTail.length === 0 ? data : this.scanTail + data + this.scanTail = this.extractScanTail(input) + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g + let match: RegExpExecArray | null + while ((match = privateModeRe.exec(input)) !== null) { + if (match[0] === '\x1bc') { + this.trackingModeState = 'none' + this.sgrMouseModeState = false + this.sgrMousePixelsModeState = false + continue + } + const params = match[1] ?? match[3] + const enabled = (match[2] ?? match[4]) === 'h' + for (const rawParam of params.split(';')) { + if (rawParam === '') { + continue + } + const param = Number(rawParam) + if (!Number.isInteger(param)) { + continue + } + if (param === 9) { + this.trackingModeState = enabled ? 'x10' : 'none' + } + if (param === 1000) { + this.trackingModeState = enabled ? 'vt200' : 'none' + } + if (param === 1002) { + this.trackingModeState = enabled ? 'drag' : 'none' + } + if (param === 1003) { + this.trackingModeState = enabled ? 'any' : 'none' + } + if (param === 1006) { + this.sgrMouseModeState = enabled + this.sgrMousePixelsModeState = false + } + if (param === 1016) { + this.sgrMouseModeState = false + this.sgrMousePixelsModeState = enabled + } + } + } + } + + private extractScanTail(input: string): string { + const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) + if (start === -1) { + return '' + } + const tail = input.slice(start) + if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) { + return '' + } + if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') { + return tail + } + if (tail.startsWith('\x1b[?')) { + return this.isIncompleteParams(tail.slice(3)) ? tail : '' + } + if (tail.startsWith('\x9b?')) { + return this.isIncompleteParams(tail.slice(2)) ? tail : '' + } + return '' + } + + private isIncompleteParams(params: string): boolean { + return /^[0-9;]*$/.test(params) + } +} diff --git a/src/main/daemon/terminal-osc-cwd-title-scanner.ts b/src/main/daemon/terminal-osc-cwd-title-scanner.ts new file mode 100644 index 00000000000..f3736d7c009 --- /dev/null +++ b/src/main/daemon/terminal-osc-cwd-title-scanner.ts @@ -0,0 +1,53 @@ +import { extractLastOscTitle } from '../../shared/agent-detection' +import { parseFileUriPath } from './osc7-file-uri' +import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction' + +const OSC_SCAN_TAIL_LIMIT = 4096 + +/** Mirror of the OSC sequences the emulator tracks outside xterm: OSC 7 cwd + * updates and OSC 0/2 titles. Keeps an unterminated-sequence tail so + * sequences split across PTY chunks still parse. Uses the bounded regex-free + * scanners so giant pasted chunks stay cheap. */ +export type TerminalOscCwdTitleScannerOptions = { + pathFlavor?: 'posix' | 'win32' + remotePosixAuthority?: boolean +} + +export class TerminalOscCwdTitleScanner { + private scanTail = '' + private readonly parseOptions: TerminalOscCwdTitleScannerOptions + cwd: string | null = null + lastTitle: string | null = null + + constructor(options: TerminalOscCwdTitleScannerOptions = {}) { + this.parseOptions = options + } + + scan(data: string): void { + // Why the pre-filter: this runs on the daemon's per-chunk hot path; flood + // chunks with no OSC introducer must not pay the title/URI walks + // (measured share of a 2.2x ingest regression — findings log 2026-07-03). + // Correctness across splits: an OSC intro spanning chunks either left a + // non-empty scanTail or this chunk ends with a bare ESC, which + // extractOscScanTail retains for the next call. + if (this.scanTail.length === 0 && !data.includes('\x1b]')) { + this.scanTail = data.endsWith('\x1b') ? extractOscScanTail(data, OSC_SCAN_TAIL_LIMIT) : '' + return + } + const input = this.scanTail.length === 0 ? data : this.scanTail + data + this.scanTail = extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT) + scanOsc7Uris(input, (uri) => { + const parsed = parseFileUriPath(uri, { + pathFlavor: this.parseOptions.pathFlavor, + remotePosixAuthority: this.parseOptions.remotePosixAuthority + }) + if (parsed) { + this.cwd = parsed + } + }) + const lastTitle = extractLastOscTitle(input) + if (lastTitle !== null) { + this.lastTitle = lastTitle + } + } +} diff --git a/src/main/daemon/terminal-snapshot-ansi-buffers.ts b/src/main/daemon/terminal-snapshot-ansi-buffers.ts new file mode 100644 index 00000000000..e0b16d56d8f --- /dev/null +++ b/src/main/daemon/terminal-snapshot-ansi-buffers.ts @@ -0,0 +1,21 @@ +import type { TerminalModes } from './types' + +export function splitTerminalSnapshotAnsi( + snapshotAnsi: string, + modes: TerminalModes +): { snapshotAnsi: string; scrollbackAnsi: string } { + if (!modes.alternateScreen) { + return { snapshotAnsi, scrollbackAnsi: '' } + } + const alternateScreenMarker = '\x1b[?1049h' + const start = snapshotAnsi.lastIndexOf(alternateScreenMarker) + if (start === -1) { + return { snapshotAnsi, scrollbackAnsi: '' } + } + // Why: rehydrateSequences owns the alt-screen transition. Keeping the + // normal buffer separate lets an already-alt renderer rebuild it safely. + return { + scrollbackAnsi: snapshotAnsi.slice(0, start), + snapshotAnsi: snapshotAnsi.slice(start + alternateScreenMarker.length) + } +} diff --git a/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts b/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts new file mode 100644 index 00000000000..b54227652dd --- /dev/null +++ b/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts @@ -0,0 +1,254 @@ +// Round-trip guards for two @xterm/addon-serialize defects that garbled +// hidden-terminal snapshot restores (serialize a buffer, replay into a fresh +// identical terminal, compare): +// +// BUG B (fixed by config/patches/@xterm__addon-serialize@*.patch): the SGR +// attribute diff emitted bold/dim set params before the shared intensity +// reset 22, so "1;22" wiped a freshly set bold and a bare "22" dropped a +// still-set bold/dim. +// +// BUG C (hardened Orca-side via serializeWithAbsoluteCursor): a final content +// row filled exactly to the right margin leaves replay wrap-pending, and the +// addon's RELATIVE cursor restore then lands one column short. +import './xterm-env-polyfill' +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import { SerializeAddon } from '@xterm/addon-serialize' +import { HeadlessEmulator } from './headless-emulator' +import { serializeWithAbsoluteCursor } from '../../shared/terminal-serialize-absolute-cursor' + +type TerminalHarness = { terminal: Terminal; addon: SerializeAddon } + +function createTerminal(cols = 10, rows = 5, scrollback = 100): TerminalHarness { + const terminal = new Terminal({ cols, rows, scrollback, allowProposedApi: true }) + const addon = new SerializeAddon() + terminal.loadAddon(addon) + return { terminal, addon } +} + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, () => resolve())) +} + +async function replay(data: string, cols = 10, rows = 5, scrollback = 100): Promise { + const { terminal } = createTerminal(cols, rows, scrollback) + await write(terminal, data) + return terminal +} + +function cellAt( + terminal: Terminal, + viewportRow: number, + col: number +): NonNullable< + ReturnType>['getCell']> +> { + const buffer = terminal.buffer.active + const line = buffer.getLine(buffer.baseY + viewportRow) + if (!line) { + throw new Error(`no line at viewport row ${viewportRow}`) + } + const cell = line.getCell(col) + if (!cell) { + throw new Error(`no cell at ${viewportRow},${col}`) + } + return cell +} + +function visibleText(terminal: Terminal): string[] { + const buffer = terminal.buffer.active + const lines: string[] = [] + for (let row = 0; row < terminal.rows; row += 1) { + lines.push(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '') + } + return lines +} + +async function roundTripStyles(source: string): Promise { + const { terminal, addon } = createTerminal() + await write(terminal, source) + return replay(addon.serialize()) +} + +describe('SGR intensity round-trip (BUG B, addon patch)', () => { + it('restores bold set immediately after dim is cleared (minimized repro)', async () => { + const restored = await roundTripStyles('\x1b[2mA\x1b[22m\x1b[1mB') + const b = cellAt(restored, 0, 1) + expect(b.getChars()).toBe('B') + expect(!!b.isBold()).toBe(true) + expect(!!b.isDim()).toBe(false) + }) + + it('keeps bold when dim is dropped from a bold+dim run', async () => { + const restored = await roundTripStyles('\x1b[1;2mA\x1b[22m\x1b[1mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isBold()).toBe(true) + expect(!!b.isDim()).toBe(false) + }) + + it('keeps dim when bold is dropped from a bold+dim run', async () => { + const restored = await roundTripStyles('\x1b[1;2mA\x1b[22m\x1b[2mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isDim()).toBe(true) + expect(!!b.isBold()).toBe(false) + }) + + it('non-regression: bold after normal text', async () => { + const restored = await roundTripStyles('A\x1b[1mB') + expect(!!cellAt(restored, 0, 0).isBold()).toBe(false) + expect(!!cellAt(restored, 0, 1).isBold()).toBe(true) + }) + + it('non-regression: dim after bold', async () => { + const restored = await roundTripStyles('\x1b[1mA\x1b[22m\x1b[2mB') + const a = cellAt(restored, 0, 0) + const b = cellAt(restored, 0, 1) + expect(!!a.isBold()).toBe(true) + expect(!!a.isDim()).toBe(false) + expect(!!b.isDim()).toBe(true) + expect(!!b.isBold()).toBe(false) + }) + + it('non-regression: bold+dim accumulation survives', async () => { + const restored = await roundTripStyles('\x1b[1mA\x1b[2mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isBold()).toBe(true) + expect(!!b.isDim()).toBe(true) + }) + + it('non-regression: italic after underline is cleared (dedicated resets)', async () => { + const restored = await roundTripStyles('\x1b[4mA\x1b[24m\x1b[3mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isItalic()).toBe(true) + expect(!!b.isUnderline()).toBe(false) + }) + + it('non-regression: underline after italic is cleared (dedicated resets)', async () => { + const restored = await roundTripStyles('\x1b[3mA\x1b[23m\x1b[4mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isUnderline()).toBe(true) + expect(!!b.isItalic()).toBe(false) + }) + + it('non-regression: underline dropped alongside bold set', async () => { + const restored = await roundTripStyles('\x1b[4mA\x1b[24m\x1b[1;4mB\x1b[24mC') + const c = cellAt(restored, 0, 2) + expect(!!c.isBold()).toBe(true) + expect(!!c.isUnderline()).toBe(false) + }) +}) + +describe('cursor restore after wrap-pending replay (BUG C, absolute-cursor hardening)', () => { + const REPRO = '0123456789\x1b[3;5H' + + it('documents the upstream defect: plain serialize lands one column short', async () => { + // Why this pin: the Orca hardening exists only because of this relative- + // restore defect. If an addon bump makes this fail, the hardening can go. + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, REPRO) + expect(terminal.buffer.active.cursorX).toBe(4) + const restored = await replay(addon.serialize()) + expect(restored.buffer.active.cursorX).toBe(3) + }) + + it('HeadlessEmulator snapshot restores the exact cursor (minimized repro)', async () => { + const emulator = new HeadlessEmulator({ cols: 10, rows: 5 }) + expect(emulator.writeSync(REPRO)).toBe(true) + const snapshot = emulator.getSnapshot() + const restored = await replay(snapshot.snapshotAnsi) + expect(restored.buffer.active.cursorX).toBe(4) + expect(restored.buffer.active.cursorY).toBe(2) + expect(visibleText(restored)[0]).toBe('0123456789') + emulator.dispose() + }) + + it('serializeWithAbsoluteCursor restores the exact cursor', async () => { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, REPRO) + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(restored.buffer.active.cursorX).toBe(4) + expect(restored.buffer.active.cursorY).toBe(2) + }) + + it('never changes already-correct restores at various cursor positions', async () => { + const positions = ['\x1b[1;1H', '\x1b[2;4H', '\x1b[5;10H', '\x1b[4;1H'] + for (const cup of positions) { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, `hello\r\nworld${cup}`) + const plainRestore = await replay(addon.serialize()) + const hardenedRestore = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(hardenedRestore.buffer.active.cursorX).toBe(terminal.buffer.active.cursorX) + expect(hardenedRestore.buffer.active.cursorY).toBe(terminal.buffer.active.cursorY) + expect(hardenedRestore.buffer.active.cursorX).toBe(plainRestore.buffer.active.cursorX) + expect(hardenedRestore.buffer.active.cursorY).toBe(plainRestore.buffer.active.cursorY) + expect(visibleText(hardenedRestore)).toEqual(visibleText(plainRestore)) + } + }) + + it('leaves a wrap-pending source untouched so replay stays wrap-pending', async () => { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, '0123456789') + // cursorX == cols marks pending wrap; a CUP would clamp and clear it. + expect(terminal.buffer.active.cursorX).toBe(10) + const plain = addon.serialize() + expect(serializeWithAbsoluteCursor(addon, terminal)).toBe(plain) + const restored = await replay(plain) + await write(restored, 'Z') + expect(visibleText(restored)[1]).toBe('Z') + }) + + it('handles a wrap-pending row that is NOT the final content row', async () => { + const { terminal, addon } = createTerminal(10, 5) + // Row 0 fills to the margin and wraps into row 1, then the cursor moves. + await write(terminal, '0123456789ABC\x1b[2;2H') + expect(terminal.buffer.active.cursorY).toBe(1) + expect(terminal.buffer.active.cursorX).toBe(1) + const plainRestore = await replay(addon.serialize()) + const hardenedRestore = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(hardenedRestore.buffer.active.cursorX).toBe(1) + expect(hardenedRestore.buffer.active.cursorY).toBe(1) + expect(visibleText(hardenedRestore)).toEqual(visibleText(plainRestore)) + expect(hardenedRestore.buffer.active.cursorX).toBe(plainRestore.buffer.active.cursorX) + expect(hardenedRestore.buffer.active.cursorY).toBe(plainRestore.buffer.active.cursorY) + }) + + it('restores alt-screen snapshots without disturbing correct positioning', async () => { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, 'shell$\x1b[?1049h\x1b[2J\x1b[HTUI ROW\x1b[2;3H') + expect(terminal.buffer.active.type).toBe('alternate') + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(restored.buffer.active.type).toBe('alternate') + expect(restored.buffer.active.cursorX).toBe(2) + expect(restored.buffer.active.cursorY).toBe(1) + expect(visibleText(restored)[0]).toBe('TUI ROW') + }) + + it('fixes the wrap-pending off-by-one inside the alt screen too', async () => { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, '\x1b[?1049h0123456789\x1b[3;5H') + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(restored.buffer.active.type).toBe('alternate') + expect(restored.buffer.active.cursorX).toBe(4) + expect(restored.buffer.active.cursorY).toBe(2) + }) + + it('restores scrolled-back buffers with the cursor at its base-relative spot', async () => { + const { terminal, addon } = createTerminal(10, 3, 50) + for (let i = 0; i < 8; i += 1) { + await write(terminal, `line${i}\r\n`) + } + await write(terminal, '\x1b[2;3H') + const source = { x: terminal.buffer.active.cursorX, y: terminal.buffer.active.cursorY } + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal), 10, 3, 50) + expect(restored.buffer.active.cursorX).toBe(source.x) + expect(restored.buffer.active.cursorY).toBe(source.y) + expect(visibleText(restored)).toEqual(visibleText(terminal)) + expect(restored.buffer.active.length).toBe(terminal.buffer.active.length) + }) + + it('keeps empty buffers serializing to an empty string', async () => { + const { terminal, addon } = createTerminal(10, 5) + expect(addon.serialize()).toBe('') + expect(serializeWithAbsoluteCursor(addon, terminal)).toBe('') + }) +}) diff --git a/src/main/daemon/terminal-view-attribute-responder.ts b/src/main/daemon/terminal-view-attribute-responder.ts new file mode 100644 index 00000000000..68ff83c349f --- /dev/null +++ b/src/main/daemon/terminal-view-attribute-responder.ts @@ -0,0 +1,191 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): OSC 4/10/11/12 and DSR ?996n responder handlers for the runtime + * headless emulator. The headless xterm core has no theme service, so these + * handlers compute replies from the renderer's pushed attribute snapshot, + * with per-PTY OSC SET mutations layered on top — mirroring exactly what the + * renderer's ThemeService reports for a visible pane. Replies route through + * the caller's emit sink, which the slice-1 forwarding window already gates, + * so seeded/replayed bytes and delivered chunks never produce a reply. + */ +import type { Terminal } from '@xterm/headless' +import { + formatXColorRgbSpec, + parseXColorSpec, + TERMINAL_VIEW_ANSI_COLOR_COUNT, + type TerminalViewAttributes, + type TerminalViewRgb +} from '../../shared/terminal-view-attributes' + +type ViewAttributeParser = Pick + +export type TerminalViewAttributeResponderDeps = { + parser: ViewAttributeParser + /** Last renderer push, or null before the first push. Null means SILENCE + * for every view-attribute query — a fabricated default would resurrect + * the default-black OSC-11 bug (design invariant 3). */ + getBaseAttributes: () => TerminalViewAttributes | null + /** Must already be replay/forwarding-window gated by the caller. */ + emitReply: (reply: string) => void +} + +export type TerminalViewAttributeResponder = { + /** A changed renderer attribute push replaces the whole palette, exactly + * like xterm's ThemeService `_setTheme` overwrites OSC-SET-mutated colors + * on a visible pane's theme apply. Identical re-pushes (fresh renderer + * process) are filtered in main's store and never reach this. */ + clearColorOverrides: () => void +} + +type SpecialColorSlot = 'foreground' | 'background' | 'cursor' + +// OSC 10/11/12 stack extra params onto consecutive slots (xterm's +// _setOrReportSpecialColor): `OSC 10;?;?` reports foreground then background. +const SPECIAL_COLOR_SLOTS: SpecialColorSlot[] = ['foreground', 'background', 'cursor'] +const SPECIAL_COLOR_IDENTS: Record = { + foreground: '10', + background: '11', + cursor: '12' +} + +function isValidColorIndex(value: number): boolean { + return value >= 0 && value < TERMINAL_VIEW_ANSI_COLOR_COUNT +} + +// Mirror of xterm's rgb.relativeLuminance2 (common/Color.ts, WCAG formula) — +// the math CoreBrowserTerminal._reportColorScheme answers ?996n with. +function relativeLuminance([r, g, b]: TerminalViewRgb): number { + const linear = (channel: number): number => { + const c = channel / 255 + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + } + return linear(r) * 0.2126 + linear(g) * 0.7152 + linear(b) * 0.0722 +} + +export function installTerminalViewAttributeResponder( + deps: TerminalViewAttributeResponderDeps +): TerminalViewAttributeResponder { + // Why per-instance maps: SET mutations are per PTY (one emulator per PTY); + // they die with the emulator at teardown, like every other model state. + // They deliberately survive a reveal→re-hide cycle even though the revealed + // xterm restores without palette mutations (SerializeAddon emits no OSC + // color SETs): the TUI never reset its SET, so holding it is + // protocol-correct — the visible-side loss is the pre-existing restore + // limitation, not this model's. + const ansiOverrides = new Map() + const specialOverrides = new Map() + + const reportColor = (ident: string, rgb: TerminalViewRgb): void => { + // Why ST (not BEL) and 16-bit channels: byte-for-byte parity with the + // renderer xterm's reply (CoreBrowserTerminal._handleColorEvent). + deps.emitReply(`\x1b]${ident};${formatXColorRgbSpec(rgb)}\x1b\\`) + } + + const handleSpecialColor = (data: string, offset: number): boolean => { + const slots = data.split(';') + for (let i = 0; i < slots.length; ++i, ++offset) { + if (offset >= SPECIAL_COLOR_SLOTS.length) { + break + } + const slot = SPECIAL_COLOR_SLOTS[offset] + if (slots[i] === '?') { + const base = deps.getBaseAttributes() + if (base) { + reportColor(SPECIAL_COLOR_IDENTS[slot], specialOverrides.get(slot) ?? base[slot]) + } + } else { + const rgb = parseXColorSpec(slots[i]) + if (rgb) { + specialOverrides.set(slot, rgb) + } + } + } + // True consumes the sequence; the headless core's own OSC 10/11/12 + // handler only fires an onColor event nothing consumes. + return true + } + + deps.parser.registerOscHandler(4, (data) => { + const slots = data.split(';') + while (slots.length > 1) { + const idx = slots.shift() as string + const spec = slots.shift() as string + if (!/^\d+$/.test(idx)) { + continue + } + const index = Number.parseInt(idx, 10) + if (!isValidColorIndex(index)) { + continue + } + if (spec === '?') { + const base = deps.getBaseAttributes() + if (base) { + reportColor(`4;${index}`, ansiOverrides.get(index) ?? base.ansi[index]) + } + } else { + const rgb = parseXColorSpec(spec) + if (rgb) { + ansiOverrides.set(index, rgb) + } + } + } + return true + }) + deps.parser.registerOscHandler(10, (data) => handleSpecialColor(data, 0)) + deps.parser.registerOscHandler(11, (data) => handleSpecialColor(data, 1)) + deps.parser.registerOscHandler(12, (data) => handleSpecialColor(data, 2)) + + // OSC 104/110/111/112 restore the themed color — dropping the override + // falls back to the pushed base, the model twin of ThemeService.restoreColor. + deps.parser.registerOscHandler(104, (data) => { + if (!data) { + ansiOverrides.clear() + return true + } + for (const slot of data.split(';')) { + if (/^\d+$/.test(slot)) { + ansiOverrides.delete(Number.parseInt(slot, 10)) + } + } + return true + }) + deps.parser.registerOscHandler(110, () => { + specialOverrides.delete('foreground') + return true + }) + deps.parser.registerOscHandler(111, () => { + specialOverrides.delete('background') + return true + }) + deps.parser.registerOscHandler(112, () => { + specialOverrides.delete('cursor') + return true + }) + + deps.parser.registerCsiHandler({ prefix: '?', final: 'n' }, (params) => { + if (params[0] !== 996) { + // Fall through to the core for every other private DSR (?6n CPR etc.). + return false + } + const base = deps.getBaseAttributes() + if (base) { + // Why luminance and not base.colorSchemeMode: a visible xterm answers + // ?996n from the relative luminance of the CURRENT (OSC-SET-mutated) + // background vs foreground (CoreBrowserTerminal._reportColorScheme), + // so a dark terminal theme in a light app mode still answers dark. + // colorSchemeMode is the app mode and feeds the 2031/997 path only. + const background = specialOverrides.get('background') ?? base.background + const foreground = specialOverrides.get('foreground') ?? base.foreground + const dark = relativeLuminance(background) < relativeLuminance(foreground) + deps.emitReply(`\x1b[?997;${dark ? 1 : 2}n`) + } + return true + }) + + return { + clearColorOverrides: () => { + ansiOverrides.clear() + specialOverrides.clear() + } + } +} diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index faf0df30996..663166e6fca 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -3,11 +3,13 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges // ─── Protocol Version ──────────────────────────────────────────────── import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' -// Why: daemons survive app updates; bump for IPC shape or baked behavior that -// wrapper refresh cannot deliver, so old daemons reject unsupported RPCs. -export const PROTOCOL_VERSION = 19 +// Why: daemons can survive app updates. Bump for IPC wire-shape changes, or +// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. +// Why: bump when adding daemon wire behavior so same-version old daemons do +// not silently accept the handshake and then reject new RPCs. +export const PROTOCOL_VERSION = 20 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 ] as const // ─── Session State Machine ────────────────────────────────────────── @@ -18,23 +20,27 @@ export type ShellReadyState = 'pending' | 'ready' | 'timed_out' | 'unsupported' // ─── Terminal Snapshot ────────────────────────────────────────────── export type TerminalSnapshot = { snapshotAnsi: string - /** Scrollback portion only (rows above the visible viewport). Write this - * to preserve history without interfering with TUI repaints. */ + /** Trailing incomplete escape sequence the emulator ingested but xterm's + * parser is still holding (a PTY read ended mid-escape). Restorers must + * write this LAST — after their own post-replay resets, immediately before + * post-snapshot live chunks — so the continuation bytes complete it + * exactly as live (Bug E / #7329, notes/garble-fuzz-divergences.md). Its + * bytes are already counted by the snapshot seq. */ + pendingEscapeTailAnsi?: string + /** Normal buffer captured separately while snapshotAnsi holds an active + * alternate buffer. Empty for normal-screen snapshots. */ scrollbackAnsi: string oscLinks?: TerminalOscLinkRange[] rehydrateSequences: string - /** The trailing partial escape sequence left unparsed in the emulator when a - * PTY read ended mid-escape. serialize() cannot carry it (it lives in the - * parser, not the buffer), so the restorer must write it LAST — after any - * post-snapshot reset — so the next live chunk's continuation completes the - * sequence instead of rendering literally (#7329). */ - pendingEscapeTailAnsi?: string cwd: string | null modes: TerminalModes cols: number rows: number scrollbackLines: number lastTitle?: string + /** Absolute UTF-16 character count ingested by this live daemon session. + * Optional because persisted snapshots and older v19 daemons lack it. */ + outputSequence?: number } export type TerminalModes = { @@ -45,9 +51,14 @@ export type TerminalModes = { sgrMousePixelsMode?: boolean applicationCursor: boolean alternateScreen: boolean - /** Kitty keyboard protocol flags (CSI > u) the session's TUI negotiated; - * 0/absent when inactive. SerializeAddon cannot capture these, so the - * emulator mirrors them for snapshot rehydration. */ + /** Kitty keyboard protocol flags (CSI = u pushes) for emulator re-seed + * parity ONLY. Consumed by the daemon warm-reattach path: the spawn + * result threads them into seedHeadlessTerminal, which re-applies them to + * the fresh runtime emulator (HeadlessEmulator.applyKittyKeyboardFlags) + * so hidden `CSI ? u` answers the real flags instead of ?0u. + * rehydrateSequences must never push these into a renderer xterm — + * POST_REPLAY_REATTACH_RESET's deliberate kitty reset stays authoritative + * (terminal-query-authority.md §kitty). */ kittyKeyboardFlags?: number } @@ -134,6 +145,38 @@ export type ResizeRequest = { } } +// ─── Producer flow control (v19+) ─────────────────────────────────── +// Why fire-and-forget notifications (like write/resize): pause/resume ride the +// hot data path and are best-effort — the daemon-side 5s failsafe, not an RPC +// reply, is what guarantees a paused shell can never stay wedged. +export type PausePtyRequest = { + id: string + type: 'pausePty' + payload: { + sessionId: string + } +} + +export type ResumePtyRequest = { + id: string + type: 'resumePty' + payload: { + sessionId: string + } +} + +// Why the notification stays backward-tolerated: unknown notify types are +// swallowed by old daemons. The adapter's v20 capability gate separately +// prevents v19 thinning without a sequence-safe recovery snapshot. +export type SetSessionBackgroundRequest = { + id: string + type: 'setSessionBackground' + payload: { + sessionId: string + background: boolean + } +} + export type KillRequest = { id: string type: 'kill' @@ -217,6 +260,7 @@ export type GetSnapshotRequest = { type: 'getSnapshot' payload: { sessionId: string + scrollbackRows?: number } } @@ -277,6 +321,9 @@ export type DaemonRequest = | CancelCreateOrAttachRequest | WriteRequest | ResizeRequest + | PausePtyRequest + | ResumePtyRequest + | SetSessionBackgroundRequest | KillRequest | SignalRequest | ListSessionsRequest @@ -351,30 +398,9 @@ export type DaemonSessionInfo = SessionInfo & { protocolVersion: number } -// ─── Events (Daemon → Client, on stream socket) ──────────────────── - -export type DataEvent = { - type: 'event' - event: 'data' - sessionId: string - payload: { data: string } -} - -export type ExitEvent = { - type: 'event' - event: 'exit' - sessionId: string - payload: { code: number } -} - -export type TerminalErrorEvent = { - type: 'event' - event: 'terminalError' - sessionId: string - payload: { message: string } -} - -export type DaemonEvent = DataEvent | ExitEvent | TerminalErrorEvent +// Stream-socket event shapes live in daemon-stream-events.ts; re-exported so +// existing importers keep one types entry point. +export * from './daemon-stream-events' // ─── Binary Frame Protocol (Daemon ↔ PTY Subprocess) ──────────────── // @@ -399,23 +425,10 @@ export const FRAME_MAX_PAYLOAD = 1024 * 1024 // 1MB export const NOTIFY_PREFIX = 'notify_' // ─── Error types ──────────────────────────────────────────────────── -export class TerminalAttachCanceledError extends Error { - constructor(sessionId: string) { - super(`Attach canceled for session ${sessionId}`) - this.name = 'TerminalAttachCanceledError' - } -} - -export class DaemonProtocolError extends Error { - constructor(message: string) { - super(message) - this.name = 'DaemonProtocolError' - } -} - -export class SessionNotFoundError extends Error { - constructor(sessionId: string) { - super(`Session not found: ${sessionId}`) - this.name = 'SessionNotFoundError' - } -} +// Re-exported so existing importers of `./types` keep working; the classes +// live in daemon-errors.ts (this file is capped for wire-shape declarations). +export { + TerminalAttachCanceledError, + DaemonProtocolError, + SessionNotFoundError +} from './daemon-errors' diff --git a/src/main/index.ts b/src/main/index.ts index 2ff6ca879fc..975e3a4596b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -179,6 +179,7 @@ import { type SyntheticTitleSpinnerEntry } from './synthetic-title-spinner' import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility' +import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing' import { isCrashReportReason } from '../shared/crash-reporting' import { getSyntheticAgentTitleProfile, @@ -187,6 +188,7 @@ import { } from '../shared/synthetic-agent-title' import type { AgentStatusState } from '../shared/agent-status-types' import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' +import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import { KeybindingService } from './keybindings/keybinding-service' import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' @@ -1504,7 +1506,17 @@ function sendSyntheticTitle(ptyId: string, data: string, options: { force?: bool ) { return } - mainWindow.webContents.send('pty:data', { id: ptyId, data }) + // Why: feed the per-PTY tracker directly (never onPtyData — emulator state, + // tails, transcripts, and stats must not see fabricated bytes) so synthetic + // titles/BELs reach pty:sideEffect consumers when main holds side-effect + // authority. + runtime?.ingestSyntheticTitleFrame(ptyId, data) + // Why: only the kill-switch-off renderer still byte-parses synthetic frames; + // under main authority the copy would just mint phantom ACKs for unmetered + // bytes (see synthetic-title-frame-routing.ts). + if (shouldCopySyntheticTitleFrameToPtyData(store?.getSettings())) { + mainWindow.webContents.send('pty:data', { id: ptyId, data }) + } } function isSyntheticTitleWindowVisible(): boolean { @@ -1805,6 +1817,20 @@ app.whenReady().then(async () => { onTerminalAgentStatus: (event) => { agentHookServer.ingestTerminalStatus(event) }, + // Why: derived title/bell/agent facts ride one batched main→renderer + // channel (terminal-side-effect-authority.md). The renderer's authority + // kill switch decides whether to consume. Headless serve never creates a + // window, so the dep is omitted entirely — the runtime then skips fact + // batch construction and the per-chunk bell walk. + ...(isServeMode + ? {} + : { + onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('pty:sideEffect', batch) + } + } + }), // Why: hook-reported agent status is the same source the desktop sidebar // reads. worktree.ps pulls it at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(), diff --git a/src/main/ipc/pty-hidden-delivery-gate.test.ts b/src/main/ipc/pty-hidden-delivery-gate.test.ts new file mode 100644 index 00000000000..c85c4e2c8e0 --- /dev/null +++ b/src/main/ipc/pty-hidden-delivery-gate.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + clearHiddenRendererPtyDeliveryState, + getHiddenRendererPtyDeliveryDebug, + isHiddenPtyDeliveryGateEnabled, + markHiddenRendererPty, + recordHiddenRendererPtyDataDrop, + resetRendererScopedHiddenPtyDeliveryState, + setRendererPtyDeliveryInterest, + shouldDropHiddenRendererPtyData, + unmarkHiddenRendererPty +} from './pty-hidden-delivery-gate' + +const PTY_ID = 'pty-1' + +describe('pty hidden delivery gate', () => { + beforeEach(() => { + _resetHiddenRendererPtyDeliveryGateForTest() + }) + + it('only operates when both kill switches are on (default on)', () => { + expect(isHiddenPtyDeliveryGateEnabled(undefined)).toBe(true) + expect(isHiddenPtyDeliveryGateEnabled({})).toBe(true) + expect(isHiddenPtyDeliveryGateEnabled({ terminalHiddenDeliveryGate: false })).toBe(false) + expect(isHiddenPtyDeliveryGateEnabled({ terminalMainSideEffectAuthority: false })).toBe(false) + }) + + it('drops only hidden PTYs without registered delivery interest', () => { + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + + markHiddenRendererPty(PTY_ID) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true) + expect(shouldDropHiddenRendererPtyData(PTY_ID, { terminalHiddenDeliveryGate: false })).toBe( + false + ) + + setRendererPtyDeliveryInterest(PTY_ID, true) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + setRendererPtyDeliveryInterest(PTY_ID, false) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true) + }) + + it('requests the restore marker exactly once per drop episode, re-armed by unmark', () => { + markHiddenRendererPty(PTY_ID) + expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(true) + expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(false) + + // Why: unmark consumes the latch (and re-emits via its own return value); + // the next hidden period's first drop reports again. + unmarkHiddenRendererPty(PTY_ID) + markHiddenRendererPty(PTY_ID) + expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(true) + }) + + it('keeps drop memory when an already-dropped PTY is re-marked hidden', () => { + // Why: a hidden remount or renderer reload re-marks without an unhide in + // between — clearing the latch there would make reveal skip the restore. + markHiddenRendererPty(PTY_ID) + recordHiddenRendererPtyDataDrop(PTY_ID, 10) + markHiddenRendererPty(PTY_ID) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) + }) + + it('reports drops on unhide so reveal can heal a replaced renderer view', () => { + markHiddenRendererPty(PTY_ID) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(false) + + markHiddenRendererPty(PTY_ID) + recordHiddenRendererPtyDataDrop(PTY_ID, 10) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + }) + + it('clears renderer-scoped state on reload while preserving drop memory', () => { + markHiddenRendererPty(PTY_ID) + recordHiddenRendererPtyDataDrop(PTY_ID, 10) + setRendererPtyDeliveryInterest('pty-2', true) + markHiddenRendererPty('pty-2') + + resetRendererScopedHiddenPtyDeliveryState() + + // Hidden marks and interest holds died with the old renderer process. + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0 + }) + // pty-2's leaked interest is gone: re-marking gates it again. + markHiddenRendererPty('pty-2') + expect(shouldDropHiddenRendererPtyData('pty-2', {})).toBe(true) + // Drop memory survives so the new renderer's first unhide still restores. + markHiddenRendererPty(PTY_ID) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) + }) + + it('clears all per-PTY state on teardown and tracks debug counters', () => { + markHiddenRendererPty(PTY_ID) + setRendererPtyDeliveryInterest('pty-2', true) + recordHiddenRendererPtyDataDrop(PTY_ID, 7) + recordHiddenRendererPtyDataDrop(PTY_ID, 5) + + expect(getHiddenRendererPtyDeliveryDebug()).toEqual({ + hiddenDeliveryGatedPtyCount: 1, + deliveryInterestPtyCount: 1, + hiddenDeliveryDroppedChars: 12, + hiddenDeliveryDroppedChunks: 2 + }) + + clearHiddenRendererPtyDeliveryState(PTY_ID) + clearHiddenRendererPtyDeliveryState('pty-2') + expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0 + }) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + }) +}) diff --git a/src/main/ipc/pty-hidden-delivery-gate.ts b/src/main/ipc/pty-hidden-delivery-gate.ts new file mode 100644 index 00000000000..77230f7300c --- /dev/null +++ b/src/main/ipc/pty-hidden-delivery-gate.ts @@ -0,0 +1,154 @@ +/** + * Main-side hidden-delivery gate for renderer PTY byte delivery (Phase 4 of + * the terminal model/view architecture). + * + * The renderer marks a PTY hidden when no visible view consumes its bytes; + * main then drops renderer-bound delivery AFTER model ingestion — the runtime + * already parsed the chunk, and reveal restores from the model snapshot via + * the existing seq-guarded machinery. Any renderer party that still needs raw + * bytes (dispatcher sidecars) registers delivery + * interest, which suppresses the gate for that PTY. + * See docs/reference/terminal-side-effect-authority.md (Open Items). + */ +import type { GlobalSettings } from '../../shared/types' + +export type HiddenPtyDeliveryGateSettings = Pick< + GlobalSettings, + 'terminalMainSideEffectAuthority' | 'terminalHiddenDeliveryGate' +> + +const hiddenRendererPtys = new Set() +// Why: sidecar consumers (paste-draft pacing, background agent launches, +// automation observers, and the kill-switch-off parked 2031 responder) need +// live bytes even while no visible view exists. Any +// registered interest suppresses the gate for that PTY. +const deliveryInterestRendererPtys = new Set() +// Why: reveal must restore from the model only when bytes were actually +// dropped. Doubles as the one-shot marker latch: the first gated drop emits a +// restore marker, and the latch is consumed only by unmark (which re-emits) +// or full PTY teardown — never by re-marking hidden, so drop memory survives +// hidden remounts and renderer reloads. +const droppedSinceHiddenPtys = new Set() + +let droppedHiddenDeliveryChars = 0 +let droppedHiddenDeliveryChunks = 0 + +/** Gate kill switches, both read main-side: the gate only operates under main + * side-effect authority AND the gate-specific setting (both default on). */ +export function isHiddenPtyDeliveryGateEnabled( + settings: HiddenPtyDeliveryGateSettings | null | undefined +): boolean { + return ( + settings?.terminalMainSideEffectAuthority !== false && + settings?.terminalHiddenDeliveryGate !== false + ) +} + +/** Renderer-reported "no visible view needs bytes" bit. Never clears drop + * memory: a hidden remount or renderer reload re-marks an already-dropped + * PTY, and erasing the latch there would make the eventual reveal skip the + * restore. Unmark is the only consumer of the latch. */ +export function markHiddenRendererPty(id: string): void { + hiddenRendererPtys.add(id) +} + +/** Clears the hidden bit. Returns whether bytes were dropped while hidden so + * the caller can emit a restore marker to the now-visible renderer. */ +export function unmarkHiddenRendererPty(id: string): { droppedWhileHidden: boolean } { + hiddenRendererPtys.delete(id) + const droppedWhileHidden = droppedSinceHiddenPtys.delete(id) + return { droppedWhileHidden } +} + +export function isHiddenRendererPty(id: string): boolean { + return hiddenRendererPtys.has(id) +} + +/** For freeze diagnostics only: hidden ptys must appear in the per-pty report + * table even when the gate dropped every byte before any send/accounting. */ +export function getHiddenRendererPtyIds(): string[] { + return [...hiddenRendererPtys] +} + +/** Renderer-side ref-counted interest, surfaced as boolean transitions. */ +export function setRendererPtyDeliveryInterest(id: string, interested: boolean): void { + if (interested) { + deliveryInterestRendererPtys.add(id) + } else { + deliveryInterestRendererPtys.delete(id) + } +} + +export function shouldDropHiddenRendererPtyData( + id: string, + settings: HiddenPtyDeliveryGateSettings | null | undefined +): boolean { + return ( + isHiddenPtyDeliveryGateEnabled(settings) && + hiddenRendererPtys.has(id) && + !deliveryInterestRendererPtys.has(id) + ) +} + +/** Record one gated drop. Returns whether the caller should emit the one-shot + * empty restore-marker chunk (first drop since this PTY went hidden). */ +export function recordHiddenRendererPtyDataDrop( + id: string, + chars: number +): { shouldEmitRestoreMarker: boolean } { + droppedHiddenDeliveryChars += chars + droppedHiddenDeliveryChunks += 1 + if (droppedSinceHiddenPtys.has(id)) { + return { shouldEmitRestoreMarker: false } + } + droppedSinceHiddenPtys.add(id) + return { shouldEmitRestoreMarker: true } +} + +/** Renderer process replaced (reload / crash): its ref-counted interest + * holds and hidden marks died with it, so keeping them would gate (or + * force-feed) PTYs no live renderer party asked about. Drop memory is + * preserved — surviving daemon/SSH PTYs may have dropped bytes the old + * renderer never restored; the new renderer's first hidden/visible sync + * re-marks or unmarks and the unmark path re-emits the restore marker. */ +export function resetRendererScopedHiddenPtyDeliveryState(): void { + hiddenRendererPtys.clear() + deliveryInterestRendererPtys.clear() +} + +/** Full per-PTY teardown — wired into clearProviderPtyState so every exit + * path (local, daemon, SSH, connection teardown) releases gate state. */ +export function clearHiddenRendererPtyDeliveryState(id: string): void { + hiddenRendererPtys.delete(id) + deliveryInterestRendererPtys.delete(id) + droppedSinceHiddenPtys.delete(id) +} + +export type HiddenRendererPtyDeliveryDebug = { + hiddenDeliveryGatedPtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number +} + +export function getHiddenRendererPtyDeliveryDebug(): HiddenRendererPtyDeliveryDebug { + return { + hiddenDeliveryGatedPtyCount: hiddenRendererPtys.size, + deliveryInterestPtyCount: deliveryInterestRendererPtys.size, + hiddenDeliveryDroppedChars: droppedHiddenDeliveryChars, + hiddenDeliveryDroppedChunks: droppedHiddenDeliveryChunks + } +} + +export function resetHiddenRendererPtyDeliveryDebugCounters(): void { + droppedHiddenDeliveryChars = 0 + droppedHiddenDeliveryChunks = 0 +} + +/** Test seam: reset all module state between tests. */ +export function _resetHiddenRendererPtyDeliveryGateForTest(): void { + hiddenRendererPtys.clear() + deliveryInterestRendererPtys.clear() + droppedSinceHiddenPtys.clear() + resetHiddenRendererPtyDeliveryDebugCounters() +} diff --git a/src/main/ipc/pty-producer-flow-control.test.ts b/src/main/ipc/pty-producer-flow-control.test.ts new file mode 100644 index 00000000000..ae7fcb6b655 --- /dev/null +++ b/src/main/ipc/pty-producer-flow-control.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PRODUCER_FLOW_HIGH_WATERMARK_CHARS, + PRODUCER_FLOW_LOW_WATERMARK_CHARS, + PRODUCER_PAUSE_REASSERT_INTERVAL_MS, + PtyProducerFlowController +} from './pty-producer-flow-control' + +const HIGH = PRODUCER_FLOW_HIGH_WATERMARK_CHARS +const LOW = PRODUCER_FLOW_LOW_WATERMARK_CHARS + +describe('PtyProducerFlowController', () => { + let pauseProducer: ReturnType void>> + let resumeProducer: ReturnType void>> + let controller: PtyProducerFlowController + + beforeEach(() => { + vi.useFakeTimers() + pauseProducer = vi.fn<(id: string) => void>() + resumeProducer = vi.fn<(id: string) => void>() + controller = new PtyProducerFlowController({ + pauseProducer, + resumeProducer + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('does not pause at or below the high watermark', () => { + controller.update('pty-1', 0) + controller.update('pty-1', LOW) + controller.update('pty-1', HIGH) + expect(pauseProducer).not.toHaveBeenCalled() + expect(controller.isPaused('pty-1')).toBe(false) + }) + + it('pauses exactly once when pending crosses the high watermark, not per chunk', () => { + controller.update('pty-1', HIGH + 1) + controller.update('pty-1', HIGH + 64 * 1024) + controller.update('pty-1', HIGH + 128 * 1024) + expect(pauseProducer).toHaveBeenCalledTimes(1) + expect(pauseProducer).toHaveBeenCalledWith('pty-1') + expect(controller.isPaused('pty-1')).toBe(true) + }) + + it('resumes exactly once when pending drains below the low watermark', () => { + controller.update('pty-1', HIGH + 1) + controller.update('pty-1', LOW - 1) + expect(resumeProducer).toHaveBeenCalledTimes(1) + expect(resumeProducer).toHaveBeenCalledWith('pty-1') + expect(controller.isPaused('pty-1')).toBe(false) + // A second drain report on the now-unpaused pty must not resume again. + controller.update('pty-1', 0) + expect(resumeProducer).toHaveBeenCalledTimes(1) + }) + + it('holds hysteresis: no flapping while pending sits between the watermarks', () => { + controller.update('pty-1', HIGH + 1) + expect(pauseProducer).toHaveBeenCalledTimes(1) + // Draining but still above LOW: stay paused, no extra calls either way. + controller.update('pty-1', HIGH - 16 * 1024) + controller.update('pty-1', 128 * 1024) + controller.update('pty-1', LOW) + expect(pauseProducer).toHaveBeenCalledTimes(1) + expect(resumeProducer).not.toHaveBeenCalled() + expect(controller.isPaused('pty-1')).toBe(true) + // An unpaused pty hovering in the same band must not pause. + controller.update('pty-2', LOW + 1) + controller.update('pty-2', HIGH) + expect(pauseProducer).toHaveBeenCalledTimes(1) + }) + + it('re-asserts the pause after the failsafe interval while still flooded', () => { + controller.update('pty-1', HIGH + 1) + expect(pauseProducer).toHaveBeenCalledTimes(1) + + // Within the failsafe window: no re-assert even far above HIGH. + vi.advanceTimersByTime(PRODUCER_PAUSE_REASSERT_INTERVAL_MS - 1) + controller.update('pty-1', HIGH * 4) + expect(pauseProducer).toHaveBeenCalledTimes(1) + + // After the window (daemon failsafe has auto-resumed by now): re-pause. + vi.advanceTimersByTime(1) + controller.update('pty-1', HIGH * 4) + expect(pauseProducer).toHaveBeenCalledTimes(2) + + // The re-assert re-stamps the clock — no immediate third pause. + controller.update('pty-1', HIGH * 4) + expect(pauseProducer).toHaveBeenCalledTimes(2) + }) + + it('release resumes only ptys that are actually paused', () => { + controller.update('paused-pty', HIGH + 1) + controller.release('paused-pty') + controller.release('never-paused-pty') + expect(resumeProducer).toHaveBeenCalledTimes(1) + expect(resumeProducer).toHaveBeenCalledWith('paused-pty') + expect(controller.isPaused('paused-pty')).toBe(false) + }) + + it('releaseAll resumes every paused pty', () => { + controller.update('pty-1', HIGH + 1) + controller.update('pty-2', HIGH + 1) + controller.update('pty-3', LOW) + controller.releaseAll() + expect(resumeProducer).toHaveBeenCalledTimes(2) + expect(resumeProducer).toHaveBeenCalledWith('pty-1') + expect(resumeProducer).toHaveBeenCalledWith('pty-2') + expect(controller.isPaused('pty-1')).toBe(false) + expect(controller.isPaused('pty-2')).toBe(false) + }) + + it('keeps bookkeeping consistent when the transport throws', () => { + pauseProducer.mockImplementation(() => { + throw new Error('provider gone') + }) + resumeProducer.mockImplementation(() => { + throw new Error('provider gone') + }) + expect(() => controller.update('pty-1', HIGH + 1)).not.toThrow() + expect(controller.isPaused('pty-1')).toBe(true) + expect(() => controller.update('pty-1', 0)).not.toThrow() + expect(controller.isPaused('pty-1')).toBe(false) + }) + + it('tracks watermark state per pty independently', () => { + controller.update('pty-1', HIGH + 1) + controller.update('pty-2', HIGH + 1) + controller.update('pty-1', 0) + expect(pauseProducer).toHaveBeenCalledTimes(2) + expect(resumeProducer).toHaveBeenCalledTimes(1) + expect(controller.isPaused('pty-1')).toBe(false) + expect(controller.isPaused('pty-2')).toBe(true) + }) +}) diff --git a/src/main/ipc/pty-producer-flow-control.ts b/src/main/ipc/pty-producer-flow-control.ts new file mode 100644 index 00000000000..48a9fc0b678 --- /dev/null +++ b/src/main/ipc/pty-producer-flow-control.ts @@ -0,0 +1,105 @@ +// Producer-side PTY flow control (notes/terminal-performance-initiative.md §5). +// Main tracks per-PTY renderer-pending chars; past HIGH it asks the provider to +// pause the actual PTY read (node-pty pause() → kernel backpressure → the +// flooding shell blocks on write), and below LOW it resumes. The wide +// HIGH/LOW gap is deliberate hysteresis so a draining queue cannot flap +// pause/resume once per flush slice. + +export const PRODUCER_FLOW_HIGH_WATERMARK_CHARS = 256 * 1024 +export const PRODUCER_FLOW_LOW_WATERMARK_CHARS = 32 * 1024 +// Why: the daemon auto-resumes a pause after its 5s lost-resume failsafe. If +// pending is still above HIGH after that window, the pause must be re-asserted +// or a sustained flood would run unthrottled after the first failsafe fires. +export const PRODUCER_PAUSE_REASSERT_INTERVAL_MS = 5_000 + +export type ProducerFlowControlTransport = { + pauseProducer: (id: string) => void + resumeProducer: (id: string) => void +} + +export class PtyProducerFlowController { + private transport: ProducerFlowControlTransport + private highWatermarkChars: number + private lowWatermarkChars: number + private reassertIntervalMs: number + private pausedAtByPty = new Map() + + constructor( + transport: ProducerFlowControlTransport, + opts: { + highWatermarkChars?: number + lowWatermarkChars?: number + reassertIntervalMs?: number + } = {} + ) { + this.transport = transport + this.highWatermarkChars = opts.highWatermarkChars ?? PRODUCER_FLOW_HIGH_WATERMARK_CHARS + this.lowWatermarkChars = opts.lowWatermarkChars ?? PRODUCER_FLOW_LOW_WATERMARK_CHARS + this.reassertIntervalMs = opts.reassertIntervalMs ?? PRODUCER_PAUSE_REASSERT_INTERVAL_MS + } + + /** Reports the current pending chars for a PTY. Fires pause exactly once at + * the HIGH crossing (re-asserted only after the failsafe interval) and + * resume exactly once when pending drains below LOW. */ + update(id: string, pendingChars: number): void { + const pausedAt = this.pausedAtByPty.get(id) + if (pausedAt === undefined) { + if (pendingChars > this.highWatermarkChars) { + this.pausedAtByPty.set(id, Date.now()) + this.safePause(id) + } + return + } + if (pendingChars < this.lowWatermarkChars) { + this.pausedAtByPty.delete(id) + this.safeResume(id) + return + } + if ( + pendingChars > this.highWatermarkChars && + Date.now() - pausedAt >= this.reassertIntervalMs + ) { + this.pausedAtByPty.set(id, Date.now()) + this.safePause(id) + } + } + + /** Resumes a PTY if it was paused. For teardown paths (exit, kill) where + * the pending bookkeeping is being dropped rather than drained. */ + release(id: string): void { + if (this.pausedAtByPty.delete(id)) { + this.safeResume(id) + } + } + + /** Resumes every paused PTY. For wholesale bookkeeping wipes (window + * destroyed) — a local PTY left paused here would stay wedged forever. */ + releaseAll(): void { + // Deleting the visited entry during Map key iteration is spec-safe. + for (const id of this.pausedAtByPty.keys()) { + this.release(id) + } + } + + isPaused(id: string): boolean { + return this.pausedAtByPty.has(id) + } + + // Why swallow: pause/resume are optimizations riding the terminal data + // path — a provider throw must never break delivery or exit handling. + private safePause(id: string): void { + try { + this.transport.pauseProducer(id) + } catch { + /* best-effort */ + } + } + + private safeResume(id: string): void { + try { + this.transport.resumeProducer(id) + } catch { + /* best-effort */ + } + } +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 407f1710436..66a0c1c0750 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -9,6 +9,7 @@ import { TERMINAL_INPUT_MAX_BYTES } from '../../shared/terminal-input' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' +import { redactPtyIdForDiagnostics } from '../../shared/pty-delivery-diagnostics' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' const isWindowsHost = process.platform === 'win32' @@ -93,7 +94,11 @@ const { vi.mock('electron', () => ({ app: { isPackaged: true, - getPath: getPathMock + getPath: getPathMock, + getVersion: () => '0.0.0-test' + }, + powerMonitor: { + on: vi.fn() }, nativeTheme: { shouldUseDarkColors: true @@ -192,6 +197,11 @@ import { unregisterSshPtyProvider, getLocalPtyProvider } from './pty' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + isHiddenRendererPty +} from './pty-hidden-delivery-gate' +import { OrcaRuntimeService } from '../runtime/orca-runtime' import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' import * as livePtyGate from '../claude-accounts/live-pty-gate' import { @@ -237,6 +247,9 @@ describe('registerPtyHandlers', () => { const handlers = new Map unknown>() const mainWindow = { isDestroyed: () => false, + isFocused: () => true, + isVisible: () => true, + isMinimized: () => false, webContents: { on: vi.fn(), send: vi.fn(), @@ -324,6 +337,9 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() mainWindow.webContents.removeListener.mockReset() + // Why: hidden-delivery gate state is module-level by design (PTY-keyed, + // not window-keyed); tests must not leak hidden bits across cases. + _resetHiddenRendererPtyDeliveryGateForTest() // Why: mirror real Electron — ipcMain.handle throws on a duplicate channel // unless removeHandler cleared it first. This catches a re-registration @@ -401,7 +417,21 @@ describe('registerPtyHandlers', () => { afterEach(() => { _resetWslCachesForTests() vi.useRealTimers() - unregisterSshPtyProvider('ssh-1') + // Why: sshProviders is module-level state; any id left registered leaks + // into later tests (pty:listSessions sweeps every registered provider). + for (const leakedConnectionId of [ + 'ssh-1', + 'ssh-a', + 'ssh-b', + 'ssh-expired-runtime', + 'ssh-fresh-fail', + 'ssh-reattach-1', + 'ssh-reattach-fail', + 'ssh-reattach-ok', + 'ssh-runtime-env' + ]) { + unregisterSshPtyProvider(leakedConnectionId) + } setLocalPtyProvider(new LocalPtyProvider()) if (savedProcessPlatform) { Object.defineProperty(process, 'platform', savedProcessPlatform) @@ -542,12 +572,21 @@ describe('registerPtyHandlers', () => { const spawn = vi.fn(async (options: { sessionId?: string }) => ({ id: options.sessionId ?? 'daemon-pty' })) + const write = vi.fn() + const pauseProducer = vi.fn() + const resumeProducer = vi.fn() let dataHandler: ((payload: { id: string; data: string }) => void) | null = null let exitHandler: ((payload: { id: string; code: number }) => void) | null = null + let backgroundStreamHandler: + | ((payload: { id: string; kind: 'dataGap'; droppedChars: number }) => void) + | null = null + const getBufferSnapshot = vi.fn() setLocalPtyProvider({ spawn, - write: vi.fn(), + write, resize: vi.fn(), + pauseProducer, + resumeProducer, kill: vi.fn(), shutdown: vi.fn(), sendSignal: vi.fn(), @@ -564,6 +603,13 @@ describe('registerPtyHandlers', () => { return () => {} }), onReplay: vi.fn(() => () => {}), + onBackgroundStreamEvent: vi.fn( + (handler: (payload: { id: string; kind: 'dataGap'; droppedChars: number }) => void) => { + backgroundStreamHandler = handler + return () => {} + } + ), + getBufferSnapshot, onExit: vi.fn((handler: (payload: { id: string; code: number }) => void) => { exitHandler = handler return () => {} @@ -575,20 +621,29 @@ describe('registerPtyHandlers', () => { } as never) return { spawn, + write, + pauseProducer, + resumeProducer, + getBufferSnapshot, emitData: (id: string, data: string) => dataHandler?.({ id, data }), - emitExit: (id: string, code = 0) => exitHandler?.({ id, code }) + emitExit: (id: string, code = 0) => exitHandler?.({ id, code }), + emitDataGap: (id: string, droppedChars: number) => + backgroundStreamHandler?.({ id, kind: 'dataGap', droppedChars }) } } function getPtyAckDataListener(): ( event: unknown, - args: { id: string; charCount: number } + args: { id: string; charCount?: number; processedChars?: number } ) => void { const ackCall = onMock.mock.calls.find((call: unknown[]) => call[0] === 'pty:ackData') if (!ackCall) { throw new Error('missing pty:ackData listener') } - return ackCall[1] as (event: unknown, args: { id: string; charCount: number }) => void + return ackCall[1] as ( + event: unknown, + args: { id: string; charCount?: number; processedChars?: number } + ) => void } function getPtySetActiveRendererPtyListener(): ( @@ -654,6 +709,32 @@ describe('registerPtyHandlers', () => { ) => void } + function getPtySetHiddenRendererPtyListener(): ( + event: unknown, + args: { id: string; hidden: boolean } + ) => void { + const hiddenCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:setHiddenRendererPty' + ) + if (!hiddenCall) { + throw new Error('missing pty:setHiddenRendererPty listener') + } + return hiddenCall[1] as (event: unknown, args: { id: string; hidden: boolean }) => void + } + + function getPtySetDeliveryInterestListener(): ( + event: unknown, + args: { id: string; interested: boolean } + ) => void { + const interestCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:setPtyDeliveryInterest' + ) + if (!interestCall) { + throw new Error('missing pty:setPtyDeliveryInterest listener') + } + return interestCall[1] as (event: unknown, args: { id: string; interested: boolean }) => void + } + /** Helper: trigger pty:spawn and return the env passed to node-pty. */ async function spawnAndGetEnv( argsEnv?: Record, @@ -1632,6 +1713,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -1817,6 +1899,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -3342,6 +3425,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn(() => 13), @@ -3375,11 +3459,16 @@ describe('registerPtyHandlers', () => { const result = await pendingSpawn daemon.emitData(result.id, 'daemon output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) daemon.emitExit(result.id, 0) expect(daemon.spawn).toHaveBeenCalledTimes(1) - expect(runtime.onPtyData).toHaveBeenCalledWith(result.id, 'daemon output', expect.any(Number)) + expect(runtime.onPtyData).toHaveBeenCalledWith( + result.id, + 'daemon output', + expect.any(Number), + 'daemon output'.length + ) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: result.id, data: 'daemon output', @@ -3396,76 +3485,6 @@ describe('registerPtyHandlers', () => { } }) - // Why: the unsent main->renderer backlog was only bounded by renderer ACKs. A - // background-throttled/frozen renderer (Win/Linux) stops ACKing while a busy - // pane keeps producing, so the per-pty pending string grew at raw PTY rate - // (MB->GB). The cap trims it to the most-recent span and flags droppedBacklog - // exactly once so the renderer rebuilds the dropped span from the main snapshot. - it('caps the unsent pending backlog and flags droppedBacklog once when the renderer never acks', async () => { - vi.useFakeTimers() - let seq = 0 - const runtime = { - setPtyController: vi.fn(), - registerPty: vi.fn(), - onPtySpawned: vi.fn(), - onPtyExit: vi.fn(), - onPtyData: vi.fn((_id: string, data: string) => { - seq += data.length - return seq - }), - createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-cap'), - registerPreAllocatedHandleForPty: vi.fn() - } - try { - registerPtyHandlers( - mainWindow as never, - runtime as never, - undefined, - undefined, - undefined, - undefined, - { awaitLocalPtyStartup: () => Promise.resolve() } - ) - const pendingSpawn = handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - sessionId: 'backlog-cap-session' - }) as Promise<{ id: string }> - await Promise.resolve() - const daemon = installObservableDaemonTestProvider() - rebindLocalProviderListeners() - const result = await pendingSpawn - - // Never ack: in-flight pins at the high-water and the flush gates, so the - // unsent backlog would grow unbounded. Emit far more than the 2 MB cap. - const HUGE = 'x'.repeat(5 * 1024 * 1024) - daemon.emitData(result.id, HUGE) - await vi.advanceTimersByTimeAsync(50) - - const dataSends = mainWindow.webContents.send.mock.calls.filter( - (call) => call[0] === 'pty:data' && (call[1] as { id: string }).id === result.id - ) - expect(dataSends.length).toBeGreaterThan(0) - // Sanity bound only: with no acks the sent total is already gated by the - // pre-existing in-flight high-water caps, so this holds independent of the - // trim. The actual retained-backlog cap is proven by droppedBacklog below. - const totalChars = dataSends.reduce( - (sum, call) => sum + (call[1] as { data: string }).data.length, - 0 - ) - expect(totalChars).toBeLessThan(3 * 1024 * 1024) - // The trim signals the renderer exactly once, on the first emitted chunk. - expect((dataSends[0][1] as { droppedBacklog?: boolean }).droppedBacklog).toBe(true) - expect( - dataSends.filter( - (call) => (call[1] as { droppedBacklog?: boolean }).droppedBacklog === true - ) - ).toHaveLength(1) - } finally { - vi.useRealTimers() - } - }) - // Why: the cap and its flag must never fire in the common case (renderer keeps // up), so ordinary small output carries no droppedBacklog. it('does not flag droppedBacklog for ordinary small output under the cap', async () => { @@ -3519,6 +3538,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -4216,6 +4236,7 @@ describe('registerPtyHandlers', () => { } as never) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn() } @@ -4502,6 +4523,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_wrong'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -4553,6 +4575,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -4592,6 +4615,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5039,6 +5063,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5110,6 +5135,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5196,6 +5222,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5293,6 +5320,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5401,6 +5429,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5494,6 +5523,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5699,6 +5729,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5757,6 +5788,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5804,6 +5836,7 @@ describe('registerPtyHandlers', () => { it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => { const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_trusted'), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5831,6 +5864,7 @@ describe('registerPtyHandlers', () => { }) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_wsl'), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -6865,7 +6899,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData('background output') expect(mainWindow.webContents.send).not.toHaveBeenCalled() - vi.advanceTimersByTime(7) + vi.advanceTimersByTime(1) expect(mainWindow.webContents.send).not.toHaveBeenCalled() vi.advanceTimersByTime(1) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { @@ -6894,7 +6928,7 @@ describe('registerPtyHandlers', () => { setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) mockProc.emitData('visible output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'visible output' @@ -6904,7 +6938,7 @@ describe('registerPtyHandlers', () => { setRendererPtyVisible(null, { id: spawnResult.id, visible: false }) mockProc.emitData('\x1b[2Khidden-width redraw') setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -6938,7 +6972,7 @@ describe('registerPtyHandlers', () => { // Reloaded page's dispatcher re-registers, releasing held sends (§1b). handleRendererDispatcherReady() mockProc.emitData('reload-gap output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -6949,7 +6983,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) mockProc.emitData('visible output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -7398,7 +7432,7 @@ describe('registerPtyHandlers', () => { resizePty(null, { id: spawnResult.id, cols: 72, rows: 24 }) setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) mockProc.emitData('\x1b[2Khidden-resize redraw') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -7409,7 +7443,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() resizePty(null, { id: spawnResult.id, cols: 80, rows: 24 }) mockProc.emitData('visible repaint') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -7442,7 +7476,7 @@ describe('registerPtyHandlers', () => { setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) writePty(mainWindowIpcEvent, { id: spawnResult.id, data: 'x' }) mockProc.emitData('x') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -7477,7 +7511,7 @@ describe('registerPtyHandlers', () => { expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'ready' @@ -7511,7 +7545,7 @@ describe('registerPtyHandlers', () => { expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'ready' @@ -7543,7 +7577,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData(`${query}ready`) expect(mockProc.proc.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: `${query}ready` @@ -7577,7 +7611,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData(`${command}ready`) expect(mockProc.proc.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: `${command}ready` @@ -7638,7 +7672,7 @@ describe('registerPtyHandlers', () => { expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]11;rgb:1111/1111/1111\x1b\\') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'daemon-ready' @@ -7713,7 +7747,7 @@ describe('registerPtyHandlers', () => { id: spawnResult.id, data: '\x1b]10;?\x1b\\\x1b]11;?\x1b\\ready' }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]11;rgb:1111/1111/1111\x1b\\') @@ -7753,7 +7787,7 @@ describe('registerPtyHandlers', () => { id: spawnResult.id, data: '\x1b[20;2Hredraw' }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() @@ -7809,7 +7843,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData(largeOutput) expect(mainWindow.webContents.send).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: largeOutput @@ -7845,7 +7879,7 @@ describe('registerPtyHandlers', () => { } expect(mainWindow.webContents.send).toHaveBeenCalledTimes(64) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(65) expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(65, 'pty:data', { id: spawnResult.id, @@ -7883,7 +7917,7 @@ describe('registerPtyHandlers', () => { id: spawnResult.id, data: redraw }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() @@ -7915,7 +7949,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData('redraw') expect(mainWindow.webContents.send).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: `${pendingOutput}redraw` @@ -7950,7 +7984,7 @@ describe('registerPtyHandlers', () => { secondProc.emitData('second-terminal-output') firstProc.emitData(`${firstChunk}${firstRemainder}`) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(1, 'pty:data', { @@ -7996,7 +8030,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() firstProc.emitData('x'.repeat(600 * 1024)) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 31; index++) { vi.advanceTimersByTime(1) } @@ -8021,7 +8055,7 @@ describe('registerPtyHandlers', () => { }) secondProc.emitData('second-terminal-output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(33) expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(33, 'pty:data', { @@ -8060,6 +8094,775 @@ describe('registerPtyHandlers', () => { } }) + it('caps per-PTY pending output while the renderer is starved and heals via a droppedOutput sentinel', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const ackData = getPtyAckDataListener() + mainWindow.webContents.send.mockClear() + + // Saturate the renderer in-flight window (512 KB) with no ACKs — the + // frozen/starved-renderer shape from the field reports. + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(2) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + + // Keep flooding well past the 2 MB per-PTY pending cap. Main must not + // buffer this unboundedly (previously: unbounded string concat). + mockProc.emitData('y'.repeat(3 * 1024 * 1024)) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 0 + }) + + // Later output while dropped must stay O(1), not start re-accumulating. + mockProc.emitData('z'.repeat(64 * 1024)) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 0 + }) + + // Renderer recovers and ACKs: the flush must deliver the droppedOutput + // sentinel so the pane repaints from the main-owned snapshot. + mainWindow.webContents.send.mockClear() + ackData(null, { id: spawn.id, charCount: 512 * 1024 }) + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawn.id, + data: '', + droppedOutput: true + }) + + // Fresh output after the sentinel flows normally again. + mainWindow.webContents.send.mockClear() + mockProc.emitData('back to normal') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawn.id, + data: 'back to normal' + }) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('carves reply-eliciting queries out of a pending-cap bulk drop so probes survive', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const ackData = getPtyAckDataListener() + mainWindow.webContents.send.mockClear() + + // Saturate the in-flight window so everything after buffers in pendingData. + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(2) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + + // Flood past the pending cap WITH an embedded DSR probe — the program + // that wrote it blocks on the reply (the bench DSR timeout). + mockProc.emitData(`${'y'.repeat(2 * 1024 * 1024)}\x1b[6n${'y'.repeat(1024 * 1024)}`) + // While latched, a later probe must also be carved out (bounded). + mockProc.emitData(`${'z'.repeat(32 * 1024)}\x1b[0c${'z'.repeat(32 * 1024)}`) + + mainWindow.webContents.send.mockClear() + ackData(null, { id: spawn.id, charCount: 512 * 1024 }) + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawn.id, + data: '\x1b[6n\x1b[0c', + droppedOutput: true + }) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('scales the pending-output cap with the scrollback setting', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + // 50k-row scrollback ⇒ 6 MB pending cap instead of the 2 MB floor. + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => ({ + terminalScrollbackRows: 50_000 + })) as never) + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' }) + mainWindow.webContents.send.mockClear() + + // Saturate the in-flight window with no ACKs, then buffer 3 MB — over + // the floor, under the scaled cap: it must be retained, not dropped. + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(2) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + mockProc.emitData('y'.repeat(3 * 1024 * 1024)) + expect(getPtyRendererDeliveryDebugSnapshot().pendingChars).toBeGreaterThan(3 * 1024 * 1024) + + // The scaled cap still bounds a runaway flood. + mockProc.emitData('z'.repeat(4 * 1024 * 1024)) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 0 + }) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('pauses the producer at the pending high watermark and resumes after drain', async () => { + vi.useFakeTimers() + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + + // Flood in 64KB chunks like a `yes`-style producer that honors pause — + // node-pty pause() stops the fd read, so a real producer stops emitting. + const chunk = 'x'.repeat(64 * 1024) + let chunks = 0 + while (provider.pauseProducer.mock.calls.length === 0 && chunks < 100) { + provider.emitData('flood-pty', chunk) + chunks++ + } + + // Pause fires exactly once, on the first chunk past the 256KB high + // watermark (the 5th 64KB chunk), not once per chunk. + expect(provider.pauseProducer).toHaveBeenCalledTimes(1) + expect(provider.pauseProducer).toHaveBeenCalledWith('flood-pty') + expect(chunks).toBe(5) + // Bounded: main buffered at most HIGH + one chunk while paused. + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 320 * 1024, + peakPendingChars: 320 * 1024 + }) + + // Drain to the renderer. Resume must fire exactly once — when pending + // drops below the 32KB low watermark — with no pause/resume flapping + // while pending crosses the 32-256KB hysteresis band. + vi.runAllTimers() + expect(provider.resumeProducer).toHaveBeenCalledTimes(1) + expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty') + expect(provider.pauseProducer).toHaveBeenCalledTimes(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingChars: 0 }) + } finally { + vi.useRealTimers() + } + }) + + it('resumes a paused producer when the PTY exits before draining', async () => { + vi.useFakeTimers() + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + + provider.emitData('flood-pty', 'x'.repeat(320 * 1024)) + expect(provider.pauseProducer).toHaveBeenCalledTimes(1) + + // Exit while pending is still above the low watermark: the exit path + // must release the pause instead of leaving a stale mark behind. + provider.emitExit('flood-pty', 0) + expect(provider.resumeProducer).toHaveBeenCalledTimes(1) + expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty') + } finally { + vi.useRealTimers() + } + }) + + const DELIVERY_RESYNC_UNANSWERED_WARNING = + '[pty] delivery resync probe unanswered — renderer IPC unresponsive' + + function countResyncUnansweredWarnings(warnSpy: { mock: { calls: unknown[][] } }): number { + return warnSpy.mock.calls.filter((call) => call[0] === DELIVERY_RESYNC_UNANSWERED_WARNING) + .length + } + + function getPtyDataSendCalls(): unknown[][] { + return mainWindow.webContents.send.mock.calls.filter( + (call: unknown[]) => call[0] === 'pty:data' + ) + } + + function getDeliveryResyncProbeCalls(): unknown[][] { + return mainWindow.webContents.send.mock.calls.filter( + (call: unknown[]) => call[0] === 'pty:requestDeliveryResync' + ) + } + + function getDeliveryResyncResponseListener(): ( + event: unknown, + args: { requestId: number; processedCharsByPty: Record } + ) => void { + const responseCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:deliveryResyncResponse' + ) + if (!responseCall) { + throw new Error('missing pty:deliveryResyncResponse listener') + } + return responseCall[1] as ( + event: unknown, + args: { requestId: number; processedCharsByPty: Record } + ) => void + } + + /** Saturates one PTY to its 512 KiB in-flight cap so delivery is fully + * gated for that PTY. Leaves 88 KiB pending and no timers scheduled. */ + async function spawnAndSaturateRendererDeliveryGate( + mockProc: ReturnType + ): Promise<{ id: string }> { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(8) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + return spawnResult + } + + it('self-heals lost ACKs when a later cumulative ACK arrives', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + expect(getPtyDataSendCalls()).toHaveLength(32) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + rendererInFlightPtyCount: 1 + }) + + // Every per-chunk ACK was lost, but the next ACK carries the renderer's + // full cumulative total — the debt clears without any timer or reset. + const ackData = getPtyAckDataListener() + ackData(null, { id: spawnResult.id, processedChars: 512 * 1024 }) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + rendererInFlightPtyCount: 0 + }) + + vi.runOnlyPendingTimers() + expect(getPtyDataSendCalls()).toHaveLength(33) + } finally { + vi.useRealTimers() + } + }) + + it('applies cumulative ACKs idempotently and ignores stale reordered totals', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + const ackData = getPtyAckDataListener() + + ackData(null, { id: spawnResult.id, processedChars: 256 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 256 * 1024, + maxRendererInFlightCharsByPty: 256 * 1024 + }) + + // Replayed duplicate credits nothing further. + ackData(null, { id: spawnResult.id, processedChars: 256 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 256 * 1024 + }) + + // A stale reordered total can never move accounting backwards. + ackData(null, { id: spawnResult.id, processedChars: 128 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 256 * 1024 + }) + } finally { + vi.useRealTimers() + } + }) + + it('tolerates mixed legacy delta and cumulative ACK payloads', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + const ackData = getPtyAckDataListener() + + // Legacy delta shape (no processedChars) still credits per chunk. + ackData(null, { id: spawnResult.id, charCount: 16 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 496 * 1024 + }) + + // A cumulative total then supersedes without double-crediting the delta. + ackData(null, { id: spawnResult.id, processedChars: 512 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + rendererInFlightPtyCount: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('forwards only newly acknowledged cumulative bytes to provider ACK backpressure', async () => { + vi.useFakeTimers() + const acknowledgeDataEvent = vi.fn() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: 'cumulative-pty' })), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent, + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn((callback) => { + mockProc.proc.onData((data: string) => callback({ id: 'cumulative-pty', data })) + return () => {} + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + registerPtyHandlers(mainWindow as never) + const ackData = getPtyAckDataListener() + mainWindow.webContents.send.mockClear() + + mockProc.emitData('remote-output') + vi.advanceTimersByTime(8) + + // Why: cumulative totals are clamped to what main actually sent, and a + // replayed total must credit SSH/relay flow control with zero, not + // duplicate bytes. + ackData(null, { id: 'cumulative-pty', processedChars: 1024 }) + ackData(null, { id: 'cumulative-pty', processedChars: 1024 }) + + expect(acknowledgeDataEvent).toHaveBeenNthCalledWith( + 1, + 'cumulative-pty', + 'remote-output'.length + ) + expect(acknowledgeDataEvent).toHaveBeenNthCalledWith(2, 'cumulative-pty', 0) + } finally { + vi.useRealTimers() + } + }) + + it('probes for a delivery resync when data arrives for a fully gated PTY and reconciles on reply', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + + mockProc.emitData('stuck-output') + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) + const probePayload = getDeliveryResyncProbeCalls()[0]![1] as { requestId: number } + + // Only one probe may be outstanding at a time. + mockProc.emitData('still-stuck') + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) + + const respondDeliveryResync = getDeliveryResyncResponseListener() + respondDeliveryResync(null, { + requestId: probePayload.requestId, + processedCharsByPty: { [spawnResult.id]: 512 * 1024 } + }) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + rendererInFlightPtyCount: 0 + }) + + // The reconciled gate lets the held pendingData flush again (one 2ms + // batch window = one 16KB slice). + vi.advanceTimersByTime(2) + expect(getPtyDataSendCalls()).toHaveLength(33) + } finally { + vi.useRealTimers() + } + }) + + it('ignores resync replies with stale request ids', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + mockProc.emitData('stuck-output') + const probePayload = getDeliveryResyncProbeCalls()[0]![1] as { requestId: number } + + const respondDeliveryResync = getDeliveryResyncResponseListener() + respondDeliveryResync(null, { + requestId: probePayload.requestId + 41, + processedCharsByPty: { [spawnResult.id]: 512 * 1024 } + }) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024 + }) + } finally { + vi.useRealTimers() + } + }) + + it('clears an unanswered resync probe, warns once per silent streak, and never mutates counters', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + await spawnAndSaturateRendererDeliveryGate(mockProc) + + mockProc.emitData('stuck-output') + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) + + vi.advanceTimersByTime(4_999) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(0) + + vi.advanceTimersByTime(1) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(1) + expect(warnSpy).toHaveBeenCalledWith( + DELIVERY_RESYNC_UNANSWERED_WARNING, + expect.objectContaining({ + rendererInFlightChars: 512 * 1024, + pendingPtyCount: 1 + }) + ) + // No blind reset: counters and pending output are untouched. + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + pendingChars: 88 * 1024 + 'stuck-output'.length + }) + expect(getPtyDataSendCalls()).toHaveLength(32) + + // The cleared flag lets the next gated arrival probe again, but a + // still-silent renderer does not spam a second warn. + mockProc.emitData('still-stuck') + expect(getDeliveryResyncProbeCalls()).toHaveLength(2) + vi.advanceTimersByTime(5_000) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024 + }) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('clears resync probe state when the window is destroyed', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + let destroyed = false + const destroyableWindow = { + isDestroyed: () => destroyed, + isFocused: () => true, + isVisible: () => true, + isMinimized: () => false, + webContents: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() } + } + + try { + registerPtyHandlers(destroyableWindow as never) + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' }) + destroyableWindow.webContents.send.mockClear() + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(8) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + mockProc.emitData('stuck-output') + // The outstanding probe's hygiene timeout is the only remaining timer; + // the dispatcher-ready handshake already drained the pending flush. + expect(vi.getTimerCount()).toBe(1) + + destroyed = true + mockProc.emitData('post-destroy output') + + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(60_000) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(0) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + // ── Renderer-initiated delivery health/heal (pty:reportRendererDeliveryState) ── + // Field wedge repro (v1.4.121-rc.0 snapshot): push delivery dead, invoke + // alive. The solicited-resync probe above rides push and can never be + // answered in that state; this invoke lane is the recovery that can. + + function reportRendererDeliveryState(args: { + receivedCharsByPty: Record + processedCharsByPty: Record + heal?: boolean + rendererPtyDataListenerCount?: number | null + }): { + inFlightTotalChars: number + inFlightPtyCount: number + msSinceLastAck: number | null + writtenOff?: { id: string; markerSeq?: number; writtenOffChars: number }[] + } { + const handler = handlers.get('pty:reportRendererDeliveryState') + if (!handler) { + throw new Error('missing pty:reportRendererDeliveryState handler') + } + return handler(null, args) as ReturnType + } + + it('reports delivery health over invoke without mutating any delivery state', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + await spawnAndSaturateRendererDeliveryGate(mockProc) + + // The field wedge in miniature: renderer received nothing, no ACK ever. + const health = reportRendererDeliveryState({ + receivedCharsByPty: {}, + processedCharsByPty: {} + }) + + expect(health).toMatchObject({ + inFlightTotalChars: 512 * 1024, + inFlightPtyCount: 1, + msSinceLastAck: null + }) + expect(health.writtenOff).toBeUndefined() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + pendingChars: 88 * 1024 + }) + } finally { + vi.useRealTimers() + } + }) + + it('merges cumulative processed totals from a health report as a repair lane', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + + // Lost-ACK variant: renderer processed everything; only the ACK + // messages vanished. A plain report (no heal) must drain the debt. + const health = reportRendererDeliveryState({ + receivedCharsByPty: { [spawnResult.id]: 512 * 1024 }, + processedCharsByPty: { [spawnResult.id]: 512 * 1024 } + }) + + expect(health).toMatchObject({ inFlightTotalChars: 0, inFlightPtyCount: 0 }) + expect(health.writtenOff).toBeUndefined() + // Fully reopened gate drains one 16K slice per batcher tick (0/1/2 ms). + vi.advanceTimersByTime(2) + expect(getPtyDataSendCalls()).toHaveLength(35) + } finally { + vi.useRealTimers() + } + }) + + it('heals a dead push channel: writes off unreceived bytes and returns restore markers', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + + const healed = reportRendererDeliveryState({ + receivedCharsByPty: {}, + processedCharsByPty: {}, + heal: true, + rendererPtyDataListenerCount: 1 + }) + + // The 512 KiB the renderer provably never received is written off; the + // 88 KiB still pending is dropped because the snapshot restore covers + // everything at or before the marker (hidden-drop parity). + expect(healed.writtenOff).toEqual([{ id: spawnResult.id, writtenOffChars: 512 * 1024 }]) + expect(healed).toMatchObject({ inFlightTotalChars: 0, inFlightPtyCount: 0 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + pendingChars: 0, + pendingDroppedChars: 88 * 1024 + }) + expect(warnSpy).toHaveBeenCalledWith( + '[pty] delivery heal: wrote off renderer-bound bytes lost in push channel', + expect.objectContaining({ rendererPtyDataListenerCount: 1 }) + ) + + // Delivery is unwedged: fresh output flows to the renderer again. + mockProc.emitData('after-heal') + vi.advanceTimersByTime(2) + expect(getPtyDataSendCalls()).toHaveLength(33) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('never writes off bytes the renderer received but has not parsed yet', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + + // Parse backpressure, not a wedge: every byte arrived, ACK credit is + // deferred to the scheduler consume point and will still repay this. + const health = reportRendererDeliveryState({ + receivedCharsByPty: { [spawnResult.id]: 512 * 1024 }, + processedCharsByPty: {}, + heal: true, + rendererPtyDataListenerCount: 1 + }) + + expect(health.writtenOff).toBeUndefined() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + pendingChars: 88 * 1024 + }) + } finally { + vi.useRealTimers() + } + }) + + it('refuses a heal while main has seen a recent ACK', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + const ackData = getPtyAckDataListener() + ackData(null, { id: spawnResult.id, processedChars: 16 * 1024 }) + + // Some pty still round-trips ACKs — whatever the renderer thinks, the + // channel is not dead, so a heal request must not destroy accounting. + const blocked = reportRendererDeliveryState({ + receivedCharsByPty: {}, + processedCharsByPty: {}, + heal: true + }) + expect(blocked.writtenOff).toBeUndefined() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 496 * 1024 + }) + + // Once main-side ACK silence crosses the floor, the same heal proceeds. + // (The ACK freed a 16K window slot, so one more pending slice shipped + // during the advance — the write-off covers 512K un-received again.) + vi.advanceTimersByTime(10_000) + const healed = reportRendererDeliveryState({ + receivedCharsByPty: {}, + processedCharsByPty: {}, + heal: true + }) + expect(healed.writtenOff).toEqual([{ id: spawnResult.id, writtenOffChars: 512 * 1024 }]) + } finally { + vi.useRealTimers() + } + }) + + it('zeroes renderer in-flight delivery counters when the renderer lifecycle resets', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + await spawnAndSaturateRendererDeliveryGate(mockProc) + const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightPtyCount: 1, + rendererInFlightChars: 512 * 1024 + }) + + handleRendererLoading() + + // Why: reload kills the renderer dispatcher that would have ACKed, so + // keeping the counters would gate PTYs in the fresh renderer forever. + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0 + }) + // Main now holds sends until the replacement page confirms its dispatcher + // is installed; the lifecycle reset arms a bounded handshake watchdog. + expect(vi.getTimerCount()).toBe(1) + + mockProc.emitData('after-reload') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) + + getPtyRendererDispatcherReadyListener()() + // One 2ms batch window releases the fresh page's held output. + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(33) + } finally { + vi.useRealTimers() + } + }) + it('forwards only actually in-flight bytes to provider ACK backpressure', async () => { vi.useFakeTimers() const acknowledgeDataEvent = vi.fn() @@ -8097,7 +8900,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() mockProc.emitData('remote-output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: 'remote-like-pty', @@ -8148,7 +8951,7 @@ describe('registerPtyHandlers', () => { for (const proc of bulkProcs) { proc.emitData('x'.repeat(600 * 1024)) } - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 400; index++) { vi.advanceTimersByTime(1) } @@ -8183,7 +8986,10 @@ describe('registerPtyHandlers', () => { data: 'a' }) interactiveProc.emitData(reserveChunk) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(529) + // Why: the reserve-exhausted send stays gated, and the fully gated + // arrival now also emits one delivery resync probe (not pty:data). + expect(getPtyDataSendCalls()).toHaveLength(529) + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) } finally { vi.useRealTimers() } @@ -8214,7 +9020,7 @@ describe('registerPtyHandlers', () => { for (const proc of procs) { proc.emitData('x'.repeat(600 * 1024)) } - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 400; index++) { vi.advanceTimersByTime(1) } @@ -8255,7 +9061,7 @@ describe('registerPtyHandlers', () => { for (let index = 0; index < procs.length - 1; index++) { procs[index]!.emitData('x'.repeat(600 * 1024)) } - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 400; index++) { vi.advanceTimersByTime(1) } @@ -8264,13 +9070,18 @@ describe('registerPtyHandlers', () => { const activeIndex = procs.length - 1 procs[activeIndex]!.emitData('active-output') setActiveRendererPty(null, { id: spawns[activeIndex]!.id, active: true }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(513) - expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(513, 'pty:data', { - id: spawns[activeIndex]!.id, - data: 'active-output' - }) + // Why: the fully gated arrival also emits one delivery resync probe, so + // count pty:data sends rather than raw webContents.send calls. + expect(getPtyDataSendCalls()).toHaveLength(513) + expect(getPtyDataSendCalls()[512]).toEqual([ + 'pty:data', + { + id: spawns[activeIndex]!.id, + data: 'active-output' + } + ]) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ activeRendererPtyCount: 1, pendingPtyCount: procs.length - 1, @@ -8298,7 +9109,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() activeProc.emitData('x'.repeat(768 * 1024)) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 31; index++) { vi.advanceTimersByTime(1) } @@ -8329,6 +9140,809 @@ describe('registerPtyHandlers', () => { } }) + describe('hidden renderer delivery gate', () => { + it('drops hidden PTY data after model ingestion and emits one out-of-band restore marker', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'hidden output') + vi.advanceTimersByTime(50) + + // Model ingestion still ran — only renderer delivery was dropped. + expect(runtime.onPtyData).toHaveBeenCalledWith( + result.id, + 'hidden output', + expect.any(Number), + 'hidden output'.length + ) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + // Why out-of-band: an in-band empty pty:data chunk is ambiguous with + // chunks fully consumed by renderer OSC-9999 stripping. + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'hidden-drop', + markerSeq: 42 + }) + + // Subsequent gated chunks drop silently — the marker is one-shot. + daemon.emitData(result.id, 'more hidden output') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 1, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + hiddenDeliveryDroppedChars: 'hidden output'.length + 'more hidden output'.length, + hiddenDeliveryDroppedChunks: 2, + pendingPtyCount: 0, + rendererInFlightChars: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('surfaces the hidden-yet-visible contradiction in the snapshot and warns on drop', async () => { + // Why: v1.4.124-rc.2.perf field snapshot — blank terminal with 2 ptys + // hidden-gated and 78MB dropped. The aggregate counts could not say + // whether the pane the user was staring at was one of them; this + // overlap counter + warn makes the next occurrence decisive. + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setVisible = getPtySetRendererPtyVisibleListener() + + // The two renderer visibility signals contradict: the pane reports + // itself visible while the hidden-delivery gate still holds it. + setVisible(null, { id: result.id, visible: true }) + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'starved visible output') + vi.advanceTimersByTime(50) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 1, + hiddenDeliveryGatedVisiblePtyCount: 1, + hiddenDeliveryDroppedChars: 'starved visible output'.length + }) + expect(warnSpy).toHaveBeenCalledWith( + '[pty] hidden-delivery gate is dropping bytes for a visible/active pty', + expect.objectContaining({ id: result.id, visible: true }) + ) + + // Unhiding resolves the contradiction. + setHidden(null, { id: result.id, hidden: false }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0 + }) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('embeds one-paste freeze diagnostics: per-pty table and breadcrumb history', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setVisible = getPtySetRendererPtyVisibleListener() + setVisible(null, { id: result.id, visible: true }) + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'starved visible output') + vi.advanceTimersByTime(50) + + const { diagnostics } = getPtyRendererDeliveryDebugSnapshot() + expect(diagnostics.appVersion).toBe('0.0.0-test') + expect(diagnostics.windowFocused).toBe(true) + expect(diagnostics.windowVisible).toBe(true) + const entry = diagnostics.perPty.find( + (candidate) => candidate.id === redactPtyIdForDiagnostics(result.id) + ) + expect(entry).toMatchObject({ + hidden: true, + visible: true, + inFlightChars: 0, + pendingChars: 0 + }) + // Why redaction is pinned here: daemon session ids embed worktree + // paths; the report must never carry the raw id. + expect(diagnostics.perPty.some((candidate) => candidate.id === result.id)).toBe(false) + const breadcrumbKinds = diagnostics.breadcrumbs.map((crumb) => crumb.kind) + expect(breadcrumbKinds).toContain('gate-mark') + expect(breadcrumbKinds).toContain('hidden-drop-visible') + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('keeps the interactive bypass gated for hidden PTYs', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writeListener = getPtyWriteListener() + const setHidden = getPtySetHiddenRendererPtyListener() + + writeListener(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) + setHidden(null, { id: spawnResult.id, hidden: true }) + mainWindow.webContents.send.mockClear() + + // A keystroke-sized redraw would take the immediate path when visible. + mockProc.emitData('\x1b[20;2Hredraw') + vi.advanceTimersByTime(2) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it('suppresses the gate while renderer delivery interest is registered', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setInterest = getPtySetDeliveryInterestListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + setInterest(null, { id: spawnResult.id, interested: true }) + mockProc.emitData('sidecar bytes') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'sidecar bytes' + }) + + setInterest(null, { id: spawnResult.id, interested: false }) + mainWindow.webContents.send.mockClear() + mockProc.emitData('gated bytes') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + ['terminalHiddenDeliveryGate', { terminalHiddenDeliveryGate: false }], + ['terminalMainSideEffectAuthority', { terminalMainSideEffectAuthority: false }] + ])('keeps delivery when the %s kill switch is off', async (_name, settings) => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => settings) as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('still delivered') + vi.advanceTimersByTime(2) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'still delivered' + }) + } finally { + vi.useRealTimers() + } + }) + + it('drops queued pending data when a PTY is marked hidden', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + mockProc.emitData('queued before hidden') + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + setHidden(null, { id: spawnResult.id, hidden: true }) + + // The queued bytes are model-owned; only the restore marker goes out. + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingPtyCount: 0 }) + } finally { + vi.useRealTimers() + } + }) + + it('re-emits the restore marker on unhide and resumes delivery', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('dropped while hidden') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Why: a renderer reload can replace the view that latched + // restore-needed; unhide repeats the marker so the live view heals. + setHidden(null, { id: spawnResult.id, hidden: false }) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'unhide' + }) + + mockProc.emitData('visible again') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: 'visible again' + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not emit an unhide marker when nothing was dropped', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + setHidden(null, { id: spawnResult.id, hidden: false }) + + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('clears gate state on PTY exit', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + + setHidden(null, { id: spawnResult.id, hidden: true }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 1 + }) + + mockProc.emitExit(0) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps drop memory across a hidden remount so reveal still restores', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('dropped while hidden') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Why: a hidden remount (tab move, parking handoff) re-marks the PTY + // without an unhide in between. The fresh view never saw the first + // marker, so re-marking must NOT erase the drop memory. + setHidden(null, { id: spawnResult.id, hidden: true }) + setHidden(null, { id: spawnResult.id, hidden: false }) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'unhide' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps drop memory across a renderer reload while clearing hidden/interest state', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + // Why daemon provider: it survives renderer reloads (the scenario + // under test) and keeps the LocalPtyProvider orphan-kill handler off + // this webContents, so 'did-finish-load' maps to the gate reset only. + const reloadHandlers = mainWindow.webContents.on.mock.calls + .filter((call: unknown[]) => call[0] === 'did-finish-load') + .map((call: unknown[]) => call[1] as () => void) + expect(reloadHandlers).toHaveLength(1) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'dropped while hidden') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Renderer reload: hidden marks die with the old renderer, but the + // dropped bytes were never restored — memory must survive. + reloadHandlers[0]() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0 + }) + + // The reloaded pane's first sync re-marks hidden, then reveals. + setHidden(null, { id: result.id, hidden: true }) + setHidden(null, { id: result.id, hidden: false }) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'unhide', + markerSeq: 42 + }) + } finally { + vi.useRealTimers() + } + }) + + it('clears leaked delivery interest on renderer reload so the gate re-engages', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const reloadHandlers = mainWindow.webContents.on.mock.calls + .filter((call: unknown[]) => call[0] === 'did-finish-load') + .map((call: unknown[]) => call[1] as () => void) + expect(reloadHandlers).toHaveLength(1) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setInterest = getPtySetDeliveryInterestListener() + mainWindow.webContents.send.mockClear() + + // A sidecar holds interest, so hidden bytes still flow. + setInterest(null, { id: result.id, interested: true }) + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'sidecar bytes') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith( + 'pty:data', + expect.objectContaining({ id: result.id, data: 'sidecar bytes' }) + ) + + // Why: the renderer reload killed the sidecar's ref count without a + // release IPC — the leaked hold must not force-feed the PTY forever. + reloadHandlers[0]() + mainWindow.webContents.send.mockClear() + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'gated after reload') + vi.advanceTimersByTime(50) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'hidden-drop', + markerSeq: 42 + }) + } finally { + vi.useRealTimers() + } + }) + }) + + describe('hidden-at-spawn mark (initiallyHidden)', () => { + // terminal-query-authority.md §races: the renderer declares hidden-at- + // spawn so main marks the PTY before its first byte — the spawn-time + // query window where neither side replied (the non-codex DA1 loss) is + // closed by the gate + responder owning queries from byte one. + function createRuntimeMock() { + return { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + } + + it('marks a daemon PTY hidden before spawn resolves so byte zero is gated', async () => { + vi.useFakeTimers() + const runtime = createRuntimeMock() + const daemon = installObservableDaemonTestProvider() + const spawnGate = makeDeferred() + daemon.spawn.mockImplementation(async (options: { sessionId?: string }) => { + await spawnGate.promise + return { id: options.sessionId ?? 'daemon-pty' } + }) + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const spawnPromise = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + }) as Promise<{ id: string }> + // Let the handler run up to the awaited provider.spawn. + await Promise.resolve() + mainWindow.webContents.send.mockClear() + + // Daemon PTYs can emit prompt bytes before spawn() resolves — the + // pre-spawn mark must already gate them. + expect(isHiddenRendererPty('daemon-session')).toBe(true) + daemon.emitData('daemon-session', 'pre-spawn prompt\x1b[c') + vi.advanceTimersByTime(50) + expect(runtime.onPtyData).toHaveBeenCalledWith( + 'daemon-session', + 'pre-spawn prompt\x1b[c', + expect.any(Number), + 'pre-spawn prompt\x1b[c'.length + ) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: 'daemon-session', + reason: 'hidden-drop', + markerSeq: 42 + }) + + spawnGate.resolve() + const result = await spawnPromise + expect(isHiddenRendererPty(result.id)).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('clears the pre-spawn hidden mark when the spawn fails', async () => { + const daemon = installObservableDaemonTestProvider() + daemon.spawn.mockRejectedValue(new Error('spawn exploded')) + registerPtyHandlers(mainWindow as never) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + }) + ).rejects.toThrow('spawn exploded') + + // A later visible attach reusing this session id must not start gated. + expect(isHiddenRendererPty('daemon-session')).toBe(false) + }) + + it('marks local PTYs hidden after spawn, before their first data task', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + initiallyHidden: true + })) as { id: string } + mainWindow.webContents.send.mockClear() + + expect(isHiddenRendererPty(spawnResult.id)).toBe(true) + mockProc.emitData('first chunk') + vi.advanceTimersByTime(2) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps spawns without the flag delivering to the renderer (visible unchanged)', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + expect(isHiddenRendererPty(spawnResult.id)).toBe(false) + mockProc.emitData('visible output') + vi.advanceTimersByTime(2) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'visible output' + }) + } finally { + vi.useRealTimers() + } + }) + + it('answers DA1 from the model on the first chunk of a hidden-at-spawn PTY', async () => { + // End-to-end through a REAL runtime: spawn-marked → first chunk dropped + // → runtime emulator parses the query → reply written to the provider + // input path (the renderer never saw the bytes; main is the answerer). + const daemon = installObservableDaemonTestProvider() + const runtime = new OrcaRuntimeService({ + getRepo: () => undefined, + getRepos: () => [], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getGitHubCache: () => ({ pr: {}, issue: {} }) as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true + }) + } as never) + + registerPtyHandlers(mainWindow as never, runtime as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + })) as { id: string } + + daemon.emitData(result.id, '\x1b[c') + // Settle the per-PTY emulator writeChain (and the reply it forwards). + await runtime.serializeMainTerminalBuffer(result.id) + + expect(daemon.write).toHaveBeenCalledWith(result.id, '\x1b[?1;2c') + }) + }) + + it('caps pending renderer delivery per PTY with oldest-drop and one restore marker', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + // 3 MB in one starved pending entry: the scrollback-scaled cap (2 MB at + // default settings) drops the buffered bytes to O(1) memory. One + // out-of-band restore marker fires; the droppedOutput sentinel then + // routes the pane through the main-owned snapshot repaint. + mockProc.emitData('x'.repeat(1024 * 1024) + 'y'.repeat(2 * 1024 * 1024)) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'pending-cap' + }) + + // A second overflow before the entry drains must not re-mark. + mockProc.emitData('z'.repeat(64 * 1024)) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: '', + droppedOutput: true + }) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + ['terminalHiddenDeliveryGate', { terminalHiddenDeliveryGate: false }], + ['terminalMainSideEffectAuthority', { terminalMainSideEffectAuthority: false }] + ])( + 'keeps the pending cap active without a restore marker when the %s kill switch is off', + async (_name, settings) => { + // Why: the scrollback-scaled pending cap ships independently of the gate + // (#7150) — the droppedOutput sentinel repaints the pane from the + // main-owned snapshot even with the model/view kill switches off. Only the + // gate's out-of-band pty:modelRestoreNeeded marker is switch-scoped. + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => settings) as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + mockProc.emitData('x'.repeat(3 * 1024 * 1024)) + + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:modelRestoreNeeded', + expect.anything() + ) + + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: '', + droppedOutput: true + }) + } finally { + vi.useRealTimers() + } + } + ) + it('batches stale PTY output after the interactive window expires', async () => { vi.useFakeTimers() const mockProc = createMockProc() @@ -8353,7 +9967,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData('stale redraw') expect(mainWindow.webContents.send).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'stale redraw' @@ -8500,6 +10114,34 @@ describe('registerPtyHandlers', () => { expect(mockProc.proc.write).not.toHaveBeenCalled() }) + it('silently drops writes to a live PTY after ownership loss until pty:listSessions rebuilds it (frozen-terminal repro)', async () => { + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } + const write = getPtyWriteListener() + + write(mainWindowIpcEvent, { id: result.id, data: 'alive' }) + expect(mockProc.proc.write).toHaveBeenCalledWith('alive') + + // Field failure shape (Discord #performance / #2836): a pane can keep + // rendering with a ptyId whose ownership entry is gone while the provider + // still holds the live PTY — every keystroke then vanishes with no error, + // no log, and no signal back to the renderer. + deletePtyOwnership(result.id) + write(mainWindowIpcEvent, { id: result.id, data: 'dropped' }) + expect(mockProc.proc.write).not.toHaveBeenCalledWith('dropped') + + // pty:listSessions rebuilds ownership from provider sessions — the + // revival lever the frozen-pane e2e probes depend on. + await handlers.get('pty:listSessions')!(null, undefined) + write(mainWindowIpcEvent, { id: result.id, data: 'revived' }) + expect(mockProc.proc.write).toHaveBeenCalledWith('revived') + }) + it('chunks large acknowledged pty writes before provider writes', async () => { const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) @@ -8583,6 +10225,7 @@ describe('registerPtyHandlers', () => { } as never) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), seedHeadlessTerminal: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), @@ -8848,6 +10491,7 @@ describe('registerPtyHandlers', () => { } const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), onPtyExit: vi.fn(), @@ -8888,6 +10532,7 @@ describe('registerPtyHandlers', () => { } const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), onPtyExit: vi.fn(), @@ -8896,16 +10541,23 @@ describe('registerPtyHandlers', () => { spawnMock.mockReturnValue(proc) registerPtyHandlers(mainWindow as never, runtime as never) - const didFinishLoad = mainWindow.webContents.on.mock.calls.find( - ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined - expect(didFinishLoad).toBeTypeOf('function') + // Why both: a reload fires the hidden-delivery gate reset AND the orphan + // cleanup; invoke every registered listener like a real did-finish-load. + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + expect(didFinishLoadHandlers.length).toBeGreaterThan(0) + const didFinishLoad = (): void => { + for (const handler of didFinishLoadHandlers) { + handler() + } + } await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) // The first load after spawn only advances generation. The second one sees // this PTY as belonging to a prior page load and kills it as orphaned. - didFinishLoad?.() - didFinishLoad?.() + didFinishLoad() + didFinishLoad() expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( killSpy.mock.invocationCallOrder[0] @@ -8918,6 +10570,9 @@ describe('registerPtyHandlers', () => { it('removes the previous orphan-cleanup listener from its original webContents', () => { const firstWindow = { isDestroyed: () => false, + isFocused: () => true, + isVisible: () => true, + isMinimized: () => false, webContents: { on: vi.fn(), send: vi.fn(), @@ -8926,6 +10581,9 @@ describe('registerPtyHandlers', () => { } const secondWindow = { isDestroyed: () => false, + isFocused: () => true, + isVisible: () => true, + isMinimized: () => false, webContents: { on: vi.fn(), send: vi.fn(), @@ -8934,10 +10592,12 @@ describe('registerPtyHandlers', () => { } registerPtyHandlers(firstWindow as never) - const didFinishLoad = firstWindow.webContents.on.mock.calls.find( + // Two listeners on the first (LocalPtyProvider) window: the renderer-gate + // reset and the orphan cleanup. + const firstWindowLoadHandlers = firstWindow.webContents.on.mock.calls.filter( ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined - expect(didFinishLoad).toBeTypeOf('function') + ) + expect(firstWindowLoadHandlers).toHaveLength(2) setLocalPtyProvider({ spawn: vi.fn(), @@ -8952,13 +10612,20 @@ describe('registerPtyHandlers', () => { } as never) registerPtyHandlers(secondWindow as never) - expect(firstWindow.webContents.removeListener).toHaveBeenCalledWith( - 'did-finish-load', - didFinishLoad - ) + // Every first-window load listener was detached from its webContents. + for (const [, handler] of firstWindowLoadHandlers) { + expect(firstWindow.webContents.removeListener).toHaveBeenCalledWith( + 'did-finish-load', + handler + ) + } + // The non-Local provider keeps orphan cleanup off the second window — + // only the renderer-gate reset listener remains. expect( - secondWindow.webContents.on.mock.calls.some(([eventName]) => eventName === 'did-finish-load') - ).toBe(false) + secondWindow.webContents.on.mock.calls.filter( + ([eventName]) => eventName === 'did-finish-load' + ) + ).toHaveLength(1) }) // Why (#5787): a crash/freeze-recovery reload re-fires did-finish-load on the @@ -8995,10 +10662,14 @@ describe('registerPtyHandlers', () => { undefined, { isRecoveryReloadInFlight } ) - const didFinishLoad = mainWindow.webContents.on.mock.calls.find( - ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined - expect(didFinishLoad).toBeTypeOf('function') + // This branch registers two did-finish-load listeners (renderer delivery-gate + // reset first, orphan sweep second); a real reload fires both, so must we — + // otherwise the suppression assertion passes vacuously without reaching the sweep. + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + expect(didFinishLoadHandlers.length).toBeGreaterThan(0) + const didFinishLoad = (): void => didFinishLoadHandlers.forEach((handler) => handler()) const spawnResult = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { id: string @@ -9006,8 +10677,8 @@ describe('registerPtyHandlers', () => { // Without the guard the second load would sweep this PTY as a prior-generation // orphan. Under recovery-in-flight neither load may touch it. - didFinishLoad?.() - didFinishLoad?.() + didFinishLoad() + didFinishLoad() expect(killSpy).not.toHaveBeenCalled() expect(runtime.onPtyExit).not.toHaveBeenCalled() @@ -9050,9 +10721,12 @@ describe('registerPtyHandlers', () => { undefined, { isRecoveryReloadInFlight } ) - const didFinishLoad = mainWindow.webContents.on.mock.calls.find( - ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined + // This branch registers two did-finish-load listeners (renderer delivery-gate + // reset first, orphan sweep second); a real reload fires both, so must we. + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + const didFinishLoad = (): void => didFinishLoadHandlers.forEach((handler) => handler()) const spawnResult = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { id: string @@ -9060,8 +10734,8 @@ describe('registerPtyHandlers', () => { // First load only advances the generation; the second sees this PTY as a // prior-load orphan. With the flag false the guard must NOT suppress the sweep. - didFinishLoad?.() - didFinishLoad?.() + didFinishLoad() + didFinishLoad() expect(killSpy).toHaveBeenCalled() expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1) @@ -9092,9 +10766,12 @@ describe('registerPtyHandlers', () => { undefined, { isRecoveryReloadInFlight } ) - const didFinishLoad = mainWindow.webContents.on.mock.calls.find( - ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined + // Fire ALL did-finish-load listeners (gate reset + orphan sweep), as a + // real reload does — the sweep listener is the one under test. + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + const didFinishLoad = (): void => didFinishLoadHandlers.forEach((handler) => handler()) spawnMock.mockReturnValue({ onData: vi.fn(() => makeDisposable()), @@ -9109,7 +10786,7 @@ describe('registerPtyHandlers', () => { // Advance the generation without sweeping (recovery-in-flight), then spawn a // second PTY so the two live in different load generations. - didFinishLoad?.() + didFinishLoad() spawnMock.mockReturnValue({ onData: vi.fn(() => makeDisposable()), @@ -9122,7 +10799,7 @@ describe('registerPtyHandlers', () => { }) const ptyB = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { id: string } - didFinishLoad?.() + didFinishLoad() expect(killSpyA).not.toHaveBeenCalled() expect(killSpyB).not.toHaveBeenCalled() @@ -9384,6 +11061,7 @@ describe('registerPtyHandlers', () => { it('returns a hidden-output recovery snapshot with clamped scrollback', async () => { const runtime = { setPtyController: vi.fn(), + getPtyOutputSequence: vi.fn(() => 42), serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ data: 'snapshot\r\n', cols: 120, @@ -9404,14 +11082,146 @@ describe('registerPtyHandlers', () => { expect(runtime.serializeHiddenOutputRecoveryBuffer).toHaveBeenCalledWith('pty-1', { scrollbackRows: 50_000 }) + // Why pendingDeliveryStartSeq === seq: the pending renderer-delivery + // queue is empty, so the renderer's post-restore duplicate window is + // empty too — low-seq live chunks (fresh seq domain) must not be + // dropped against the snapshot baseline. expect(result).toEqual({ data: 'snapshot\r\n', cols: 120, rows: 40, cwd: '/projects/restored', seq: 42, + pendingDeliveryStartSeq: 42, source: 'headless' }) }) + + it('uses the complete provider model after daemon stream thinning', async () => { + const provider = installObservableDaemonTestProvider() + provider.getBufferSnapshot.mockResolvedValue({ + data: 'complete daemon scrollback\r\n', + cols: 100, + rows: 30, + seq: 900, + source: 'headless' + }) + const runtime = { + setPtyController: vi.fn(), + getPtyOutputSequence: vi.fn(() => 640), + notePtyDataGap: vi.fn(), + onPtyExit: vi.fn(), + serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ + data: 'kept tail only\r\n', + cols: 100, + rows: 30, + seq: 640, + source: 'headless' + }) + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + provider.emitDataGap('daemon-pty', 512) + + const result = await handlers.get('pty:getMainBufferSnapshot')!(null, { + id: 'daemon-pty', + opts: { scrollbackRows: 5000 } + }) + + expect(runtime.notePtyDataGap).toHaveBeenCalledWith('daemon-pty', 512) + expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-pty', { + scrollbackRows: 5000 + }) + expect(runtime.serializeHiddenOutputRecoveryBuffer).not.toHaveBeenCalled() + expect(result).toEqual({ + data: 'complete daemon scrollback\r\n', + cols: 100, + rows: 30, + seq: 900, + source: 'headless', + // Bytes between main's current absolute seq and the daemon snapshot + // may still be queued on the stream socket and must dedupe on arrival. + pendingDeliveryStartSeq: 640 + }) + provider.emitExit('daemon-pty') + }) + + it("never paints main's incomplete tail when a required provider snapshot is unavailable", async () => { + const provider = installObservableDaemonTestProvider() + provider.getBufferSnapshot.mockResolvedValue(null) + const runtime = { + setPtyController: vi.fn(), + getPtyOutputSequence: vi.fn(() => 640), + notePtyDataGap: vi.fn(), + onPtyExit: vi.fn(), + serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ + data: 'kept tail only\r\n', + cols: 100, + rows: 30, + seq: 640, + source: 'headless' + }) + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + provider.emitDataGap('daemon-pty', 512) + + const result = await handlers.get('pty:getMainBufferSnapshot')!(null, { + id: 'daemon-pty', + opts: { scrollbackRows: 5000 } + }) + + expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-pty', { + scrollbackRows: 5000 + }) + expect(runtime.serializeHiddenOutputRecoveryBuffer).not.toHaveBeenCalled() + expect(result).toBeNull() + provider.emitExit('daemon-pty') + }) + + it('reports where the undelivered pending backlog starts alongside the snapshot', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + preAllocateHandleForPty: vi.fn(() => null), + getPtyOutputSequence: vi.fn(() => 2_472), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot\r\n', + cols: 100, + rows: 30, + seq: 2_472, + source: 'headless' + }) + } + try { + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + + // Starved pending entry: bytes ingested up to seq 2_472 but not yet + // flushed to the renderer — they can still arrive after the snapshot. + mockProc.emitData('frame-bytes') + + const result = (await handlers.get('pty:getMainBufferSnapshot')!(null, { + id: spawnResult.id + })) as { pendingDeliveryStartSeq?: number } + + expect(result.pendingDeliveryStartSeq).toBe(2_472 - 'frame-bytes'.length) + } finally { + vi.useRealTimers() + } + }) }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 7564b09b750..0ca456f7607 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -12,12 +12,28 @@ import { type IpcMainInvokeEvent, type WebContents, ipcMain, - app + app, + powerMonitor } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { Store } from '../persistence' import type { GlobalSettings, TuiAgent } from '../../shared/types' +import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy' +import type { + PtyDeliveryWriteOff, + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../../shared/pty-renderer-delivery-health' +import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' +import { + type PtyMainDeliveryDiagnostics, + type PtyPerPtyDeliveryDiagnostics, + EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, + createPtyDeliveryBreadcrumbRing, + redactPtyIdForDiagnostics +} from '../../shared/pty-delivery-diagnostics' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { @@ -48,6 +64,7 @@ import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/s import { createPtySpawnTiming } from './pty-spawn-timing' import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' +import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' @@ -96,6 +113,29 @@ import { import { parseWslPath } from '../wsl' import { mergePersistedWindowsPath } from '../pty/windows-environment-path' import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' +import { PtyProducerFlowController } from './pty-producer-flow-control' +import { + clearHiddenRendererPtyDeliveryState, + getHiddenRendererPtyDeliveryDebug, + getHiddenRendererPtyIds, + isHiddenPtyDeliveryGateEnabled, + isHiddenRendererPty, + markHiddenRendererPty, + recordHiddenRendererPtyDataDrop, + resetHiddenRendererPtyDeliveryDebugCounters, + resetRendererScopedHiddenPtyDeliveryState, + setRendererPtyDeliveryInterest, + shouldDropHiddenRendererPtyData, + unmarkHiddenRendererPty +} from './pty-hidden-delivery-gate' +import { + clearNativeWindowsConptyPty, + isNativeWindowsLocalPtySpawn, + markNativeWindowsConptyPty +} from '../runtime/terminal-model-query-authority' +import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-store' +import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes' +import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' @@ -125,6 +165,10 @@ type FreshLocalFallbackProvider = IPtyProvider & { } const sshProviders = new Map() const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 +// Why: producer flow control changes terminal physics — a flooding shell now +// blocks on write instead of buffering in main. Kill switch: flip this one +// line to disable pause/resume entirely without untangling the wiring. +const PRODUCER_FLOW_CONTROL_ENABLED = true // Why: PTY IDs are assigned at spawn time with a connectionId, but subsequent // write/resize/kill calls only carry the PTY ID. This map lets us route // post-spawn operations to the correct provider without the renderer needing @@ -1069,6 +1113,14 @@ export function clearProviderPtyState(id: string): void { pendingHiddenRendererResizeOutputPtys.delete(id) deliveredHiddenRendererResizeOutputPtys.delete(id) clearStartupTerminalColorQueryReplies(id) + // Why: every PTY teardown path funnels through here (local exit, daemon + // shutdown, SSH exit/connection teardown) — hidden/interest gate bits must + // not outlive the PTY or a reused map entry could silently gate a new one. + clearHiddenRendererPtyDeliveryState(id) + clearBackgroundedDeliverySyncForPty(id) + providerSnapshotRequiredPtys.delete(id) + // Why: the Phase-5 ConPTY DA1 spawn record must not leak onto a reused id. + clearNativeWindowsConptyPty(id) const paneKey = ptyPaneKey.get(id) const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false // Why: drop the memory-collector registration so a dead PTY does not keep @@ -1144,10 +1196,25 @@ export function setPtyOwnership(id: string, connectionId: string | null): void { // duplicate listeners that forward every event twice. let localDataUnsub: (() => void) | null = null let localExitUnsub: (() => void) | null = null +let localBackgroundStreamUnsub: (() => void) | null = null let didFinishLoadHandler: (() => void) | null = null let didFinishLoadWebContents: WebContents | null = null let rendererLifecycleResetWebContents: WebContents | null = null let rendererLifecycleResetHandler: (() => void) | null = null +// Why: the hidden-delivery gate's interest/hidden registries mirror renderer +// state (ref-counted holds, per-pane hidden marks). A reload or renderer +// crash destroys the owners without unregistering, so the registries are +// reset whenever the renderer process is replaced +// (resetRendererScopedHiddenPtyDeliveryState preserves drop memory). +let rendererGateResetLoadHandler: (() => void) | null = null +let rendererGateResetGoneHandler: (() => void) | null = null +let rendererGateResetWebContents: WebContents | null = null +// Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers +// closure but teardown funnels through module-scope clearProviderPtyState. +let clearBackgroundedDeliverySyncForPty: (id: string) => void = () => {} +// Why: after daemon keep-tail thinning, main's mirror contains only the kept +// tail. Recovery must keep consulting the daemon's complete model until exit. +const providerSnapshotRequiredPtys = new Set() // Why: did-start-loading also fires for in-page subframe loads (e.g. the // sandboxed srcDoc iframes notebook HTML output renders), which are not renderer // lifecycle resets. A dedicated handler filters those via isLoadingMainFrame so a @@ -1180,6 +1247,18 @@ export type PtyRendererDeliveryDebugSnapshot = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + /** Hidden-gated ptys the renderer ALSO reports visible/active — a + * contradiction that should be zero; nonzero means the user may be staring + * at a pane main is deliberately starving (v1.4.124-rc.2.perf field lead). */ + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + /** One-paste freeze diagnostics: per-pty delivery table + event history. */ + diagnostics: PtyMainDeliveryDiagnostics // Why: a nonzero lastLifecycleResetClearedChars is the exact signature of the // leaked-delivery-accounting freeze this reset fixes; the count tracks how // many renderer lifecycle resets have run since launch. @@ -1192,6 +1271,30 @@ export type PtyRendererDeliveryDebugSnapshot = { rendererDispatcherReadyForcedCount: number } +// Why module scope: breadcrumb writers live both inside registerPtyHandlers +// (gate marks, heals) and outside it (renderer lifecycle resets). +const mainDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing() +let lastPowerSuspendAtMs: number | null = null +let lastPowerResumeAtMs: number | null = null +let powerSignalBreadcrumbsInstalled = false + +// Why: both field freeze variants correlate with display sleep; suspend/resume +// timestamps in the report let us line breadcrumbs up against the wake. +function installPowerSignalBreadcrumbs(): void { + if (powerSignalBreadcrumbsInstalled) { + return + } + powerSignalBreadcrumbsInstalled = true + powerMonitor.on('suspend', () => { + lastPowerSuspendAtMs = Date.now() + mainDeliveryBreadcrumbs.record('power-suspend') + }) + powerMonitor.on('resume', () => { + lastPowerResumeAtMs = Date.now() + mainDeliveryBreadcrumbs.record('power-resume') + }) +} + const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = { pendingPtyCount: 0, pendingChars: 0, @@ -1206,6 +1309,14 @@ const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapsh peakRendererInFlightChars: 0, peakMaxRendererInFlightCharsByPty: 0, ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0, + diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, rendererLifecycleResetCount: 0, lastLifecycleResetClearedChars: 0, rendererPtyDispatcherReady: false, @@ -1241,6 +1352,9 @@ function clearDidFinishLoadHandler(): void { } function markRendererPtysHiddenForRendererLifecycleReset(): void { + // A reload/crash in the history is load-bearing context for any freeze + // report ("did the user already reload before capturing?"). + mainDeliveryBreadcrumbs.record('renderer-lifecycle-reset') // Why: renderer-owned hints die with the page; keep known-visibility state so // surviving daemon/SSH PTYs fail closed until the new renderer reports again. activeRendererPtys.clear() @@ -1293,6 +1407,23 @@ function registerRendererLifecycleResetHandlers(webContents: WebContents): void webContents.on('destroyed', rendererLifecycleResetHandler) } +function clearRendererGateResetHandlers(): void { + if (rendererGateResetWebContents) { + if (rendererGateResetLoadHandler) { + rendererGateResetWebContents.removeListener('did-finish-load', rendererGateResetLoadHandler) + } + if (rendererGateResetGoneHandler) { + rendererGateResetWebContents.removeListener( + 'render-process-gone', + rendererGateResetGoneHandler + ) + } + } + rendererGateResetLoadHandler = null + rendererGateResetGoneHandler = null + rendererGateResetWebContents = null +} + // Why: the "Restart daemon" flow needs to detach listeners from the current // adapter *after* synthetic pty:exit events fan out (so the renderer receives // them) but *before* replaceDaemonProvider swaps in the new adapter (so the @@ -1301,8 +1432,10 @@ function registerRendererLifecycleResetHandlers(webContents: WebContents): void export function unbindLocalProviderListeners(): void { localDataUnsub?.() localExitUnsub?.() + localBackgroundStreamUnsub?.() localDataUnsub = null localExitUnsub = null + localBackgroundStreamUnsub = null } // ─── IPC Registration ─────────────────────────────────────────────── @@ -1353,12 +1486,15 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:settlePaneSerializer') ipcMain.removeHandler('pty:clearPendingPaneSerializer') ipcMain.removeHandler('pty:getMainBufferSnapshot') + ipcMain.removeHandler('pty:sideEffectSnapshot') ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') ipcMain.removeHandler('pty:resetRendererDeliveryDebug') + ipcMain.removeHandler('pty:reportRendererDeliveryState') ipcMain.removeHandler('pty:writeAccepted') ipcMain.removeAllListeners('pty:write') ipcMain.removeAllListeners('pty:ackColdRestore') ipcMain.removeAllListeners('pty:ackData') + ipcMain.removeAllListeners('pty:deliveryResyncResponse') ipcMain.removeAllListeners('pty:serializeBuffer:response') // Configure the local provider with app-specific hooks. @@ -1434,10 +1570,12 @@ export function registerPtyHandlers( data: string startSeq?: number containsBackgroundOutput?: boolean - /** Set once this pty's unsent backlog was trimmed past the cap; rides the - * next payload so the renderer rebuilds the dropped span from the main - * headless snapshot (see appendPendingPtyData / dataCallback). */ - droppedBacklog?: boolean + // Why droppedOutput (not main's droppedBacklog trim): this branch bounds + // the unsent backlog with the O(1) drop-to-sentinel + query-salvage + + // snapshot-restore mechanism below, which strictly supersedes main's + // #7630 keep-2MB-tail trim — carrying both would race two cap policies + // over the same buffer. + droppedOutput?: true } type PtyDataPayload = { @@ -1446,37 +1584,70 @@ export function registerPtyHandlers( seq?: number rawLength?: number background?: boolean - droppedBacklog?: boolean + droppedOutput?: boolean } const pendingData = new Map() - const rendererInFlightCharsByPty = new Map() + // Why: one restore marker per overflow episode — cleared when the entry + // fully drains so a later overflow re-marks the renderer exactly once. + const pendingOverflowMarkedPtys = new Set() + // Why: TCP-style cumulative delivery accounting. Relative in-flight counters + // make every lost ACK a permanent debt; monotonic sent/acked totals self-heal + // as soon as any later ACK (or resync reply) reports the renderer's full + // processed count. + type RendererPtyDeliveryAccounting = { + sentChars: number + ackedChars: number + lastSendAtMs: number + lastAckAtMs: number | null + } + const rendererDeliveryAccountingByPty = new Map() const trustedTerminalHandleEnv = new Set() let flushTimer: ReturnType | null = null let rendererInFlightTotalChars = 0 - const PTY_BATCH_INTERVAL_MS = 8 + let pendingDroppedChars = 0 + let deliveryResyncRequestSerial = 0 + let deliveryResyncOutstandingRequestId: number | null = null + let deliveryResyncTimer: ReturnType | null = null + let deliveryResyncUnansweredWarnLogged = false + let lastAckReceivedAtMs: number | null = null + // Why 2ms: pairs with the daemon stream batcher (see + // daemon-stream-data-batcher.ts) — both hops charged an expected + // half-window per chunk; 2ms keeps flood coalescing at negligible IPC + // overhead while cutting the pipeline's fixed latency tax. + const PTY_BATCH_INTERVAL_MS = 2 const PTY_BATCH_DRAIN_CONTINUE_MS = 1 const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024 const PTY_BATCH_FLUSH_MAX_WRITES = 2 const PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024 const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024 + // Why: while the renderer cannot receive (frozen, starved, mid-reload), a + // chatty PTY used to grow its pendingData string without bound — main-process + // heap ballooning that a renderer reload cannot clear (main's #7630 fixed the + // same Win/Linux background-throttled-renderer leak with a 2MB tail trim; + // this branch's sentinel mechanism supersedes it). Beyond this cap the + // buffered bytes are dropped and the pane heals from the main-owned buffer + // snapshot via the droppedOutput sentinel (renderer hidden-output restore). + // Why read settings live: the cap scales with the user's scrollback setting + // so power users don't lose lines their scrollback would have retained. + const pendingDataCapChars = (): number => + terminalOutputBacklogCapChars(getSettings?.().terminalScrollbackRows) // Why: self-heal bound for the dispatcher-ready gate — if a reloaded page never // sends pty:rendererDispatcherReady (lost IPC), force sends back on after this // window so a dropped handshake can't itself become a permanent hold. const PTY_DISPATCHER_READY_WATCHDOG_MS = 10_000 - // Why: the in-flight caps above bound only SENT-but-unacked bytes; the unsent - // `pendingData` backlog is drained only when the renderer ACKs. A background- - // throttled/frozen renderer (Win/Linux — macOS disables throttling) stops - // ACKing while a busy pane keeps producing, so without this the per-pty queue - // grows at raw PTY throughput (MB->GB). Cap it (matching the daemon 2 MB - // pendingOutput and renderer 2 MB scheduler caps); the trimmed span is - // recovered from the main headless buffer via the renderer's hidden-output - // snapshot restore, flagged by droppedBacklog — so no output is lost. - const PENDING_DATA_MAX_CHARS = 2 * 1024 * 1024 const PTY_RENDERER_INTERACTIVE_RESERVE_CHARS = 256 * 1024 // Why: active panes need a bounded lane through old hidden bulk output so a // keystroke redraw can reach the renderer before every background ACK lands. const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024 + // Why: request/response hygiene only — this timeout never mutates delivery + // state. It clears the outstanding-probe flag so a later gated arrival can + // probe again, and logs once per silent streak for field diagnosis. + const PTY_DELIVERY_RESYNC_TIMEOUT_MS = 5_000 + // Why: a heal write-off destroys delivery accounting; require main to have + // seen zero ACKs for this long too, independent of the renderer's own + // two-silent-ticks evidence, before believing the channel is dead. + const PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS = 10_000 // Why: keep the immediate path bounded to keystroke-sized TUI redraws; // large output and non-interactive output must still use the batcher. const INTERACTIVE_OUTPUT_WINDOW_MS = 100 @@ -1502,12 +1673,75 @@ export function registerPtyHandlers( let rendererPtyDispatcherReady = false let dispatcherReadyWatchdogTimer: ReturnType | null = null - function getMaxMapValue(values: Iterable): number { - let max = 0 - for (const value of values) { - max = Math.max(max, value) + // Why: watermark-driven producer pause/resume (terminal-performance + // initiative §5). Signal source is per-PTY pendingData only — renderer + // in-flight is already bounded by the ACK window above, while pendingData + // is what grows without bound when the renderer cannot keep up. Providers + // without support (SSH, legacy daemon protocol) surface no pauseProducer + // and the call chain no-ops; the pending cap still bounds memory then. + const producerFlowControl = new PtyProducerFlowController({ + pauseProducer: (id) => tryGetProviderForPty(id)?.pauseProducer?.(id), + resumeProducer: (id) => tryGetProviderForPty(id)?.resumeProducer?.(id) + }) + + function updateProducerFlowControl(id: string): void { + if (!PRODUCER_FLOW_CONTROL_ENABLED) { + return } - return max + producerFlowControl.update(id, pendingData.get(id)?.data.length ?? 0) + } + + // Why this exists: hidden ptys are exempt from pendingData flow control + // (their bytes are dropped after model ingestion, so pendingData never + // grows), which let background agents run 100MB+ ahead of main in the + // daemon's stream-socket buffer and bury the visible pane's echo. The + // provider transport keep-tail thins backgrounded ptys' monitoring stream + // under backlog; this sync tells it which ptys qualify. + // Why keyed on the visibility registry (NOT gate marks or gate-effective + // shouldDrop): delivery claims and raw-byte sidecars describe transport + // ownership, while thinning asks the semantic question "does any visible + // view show this PTY?" Remote view subscribers (mobile/web live terminals) + // consume raw bytes from main's fan-out, so their presence vetoes thinning + // outright. Dedupe keeps visibility-sync churn off the wire; `?? false` + // also swallows the initial + // not-background state. + const backgroundedDeliverySyncByPty = new Map() + function syncPtyBackgroundedDelivery(id: string, caller: string): void { + const background = + rendererPtyIsKnownHidden(id) && !(runtime?.hasRemoteTerminalViewSubscriber(id) ?? false) + if ((backgroundedDeliverySyncByPty.get(id) ?? false) === background) { + return + } + const provider = tryGetProviderForPty(id) + if (!provider?.setPtyBackgrounded) { + return + } + recordDaemonStreamBacklogEvent('mainBackgroundSync', { + sessionIdSuffix: id.slice(-10), + background, + caller, + known: rendererVisibilityKnownPtys.has(id), + visible: visibleRendererPtys.has(id) + }) + backgroundedDeliverySyncByPty.set(id, background) + provider.setPtyBackgrounded(id, background) + } + clearBackgroundedDeliverySyncForPty = (id: string) => { + backgroundedDeliverySyncByPty.delete(id) + } + if (runtime) { + runtime.onRemoteTerminalViewPresenceChanged = (id) => + syncPtyBackgroundedDelivery(id, 'remote-view') + } + function resyncBackgroundedDeliveriesAfterGateReset(): void { + for (const id of backgroundedDeliverySyncByPty.keys()) { + syncPtyBackgroundedDelivery(id, 'gate-reset') + } + } + + function getRendererInFlightCharsForPty(id: string): number { + const accounting = rendererDeliveryAccountingByPty.get(id) + return accounting ? accounting.sentChars - accounting.ackedChars : 0 } function readCurrentPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { @@ -1518,13 +1752,38 @@ export function registerPtyHandlers( pendingChars += chars maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) } + const hiddenDeliveryDebug = getHiddenRendererPtyDeliveryDebug() + let rendererInFlightPtyCount = 0 + let maxRendererInFlightCharsByPty = 0 + for (const accounting of rendererDeliveryAccountingByPty.values()) { + const inFlight = accounting.sentChars - accounting.ackedChars + if (inFlight > 0) { + rendererInFlightPtyCount++ + } + maxRendererInFlightCharsByPty = Math.max(maxRendererInFlightCharsByPty, inFlight) + } + // Why: the two renderer visibility signals must agree; a pty both + // hidden-gated and reported visible means main is starving a pane the + // user can see (v1.4.124-rc.2.perf blank-terminal field lead). + let hiddenDeliveryGatedVisiblePtyCount = 0 + for (const id of visibleRendererPtys) { + if (isHiddenRendererPty(id)) { + hiddenDeliveryGatedVisiblePtyCount++ + } + } + let hiddenDeliveryGatedActivePtyCount = 0 + for (const id of activeRendererPtys) { + if (isHiddenRendererPty(id)) { + hiddenDeliveryGatedActivePtyCount++ + } + } return { pendingPtyCount: pendingData.size, pendingChars, maxPendingCharsByPty, - rendererInFlightPtyCount: rendererInFlightCharsByPty.size, + rendererInFlightPtyCount, rendererInFlightChars: rendererInFlightTotalChars, - maxRendererInFlightCharsByPty: getMaxMapValue(rendererInFlightCharsByPty.values()), + maxRendererInFlightCharsByPty, activeRendererPtyCount: activeRendererPtys.size, flushScheduled: flushTimer !== null, peakPendingChars, @@ -1532,6 +1791,11 @@ export function registerPtyHandlers( peakRendererInFlightChars, peakMaxRendererInFlightCharsByPty, ackGatedFlushSkipCount, + ...hiddenDeliveryDebug, + hiddenDeliveryGatedVisiblePtyCount, + hiddenDeliveryGatedActivePtyCount, + pendingDroppedChars, + diagnostics: buildMainDeliveryDiagnostics(), rendererLifecycleResetCount, lastLifecycleResetClearedChars, rendererPtyDispatcherReady, @@ -1539,6 +1803,81 @@ export function registerPtyHandlers( } } + const DELIVERY_DIAGNOSTICS_MAX_PTYS = 30 + + // Built only when the debug snapshot is actually read — never on the data + // path. Aggregate counters can't say WHICH pty is wedged or WHEN the state + // arose; this per-pty table + both-process breadcrumb history can. + function buildMainDeliveryDiagnostics(): PtyMainDeliveryDiagnostics { + const now = Date.now() + // Hidden/visible/active set members are included even with no accounting + // entry: a pty gated before its first byte is exactly the wedge case the + // table must surface. + const ids = new Set([ + ...rendererDeliveryAccountingByPty.keys(), + ...pendingData.keys(), + ...getHiddenRendererPtyIds(), + ...visibleRendererPtys, + ...activeRendererPtys + ]) + const perPty: PtyPerPtyDeliveryDiagnostics[] = [] + for (const id of ids) { + const accounting = rendererDeliveryAccountingByPty.get(id) + perPty.push({ + id: redactPtyIdForDiagnostics(id), + sentChars: accounting?.sentChars ?? 0, + ackedChars: accounting?.ackedChars ?? 0, + inFlightChars: accounting ? accounting.sentChars - accounting.ackedChars : 0, + pendingChars: pendingData.get(id)?.data.length ?? 0, + hidden: isHiddenRendererPty(id), + visible: visibleRendererPtys.has(id), + active: activeRendererPtys.has(id), + msSinceLastSend: accounting ? now - accounting.lastSendAtMs : null, + msSinceLastAck: accounting?.lastAckAtMs == null ? null : now - accounting.lastAckAtMs + }) + } + perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars)) + const windowAlive = !mainWindow.isDestroyed() + return { + appVersion: app.getVersion(), + mainUptimeMs: Math.round(process.uptime() * 1000), + windowFocused: windowAlive ? mainWindow.isFocused() : null, + windowVisible: windowAlive ? mainWindow.isVisible() : null, + windowMinimized: windowAlive ? mainWindow.isMinimized() : null, + msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs, + msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs, + perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS), + breadcrumbs: mainDeliveryBreadcrumbs.snapshot() + } + } + + // Why rate-limited: the contradiction persists chunk after chunk while + // latched; one line per minute keeps field logs readable but present. + let lastHiddenDropContradictionWarnAtMs = 0 + function warnIfDroppingHiddenBytesForVisiblePty(id: string, droppedChars: number): void { + if (!visibleRendererPtys.has(id) && !activeRendererPtys.has(id)) { + return + } + // Recorded before the warn rate limit: the ring coalesces repeats itself, + // and the contradiction must appear in the freeze report either way. + mainDeliveryBreadcrumbs.record('hidden-drop-visible', { + id: redactPtyIdForDiagnostics(id), + droppedChars + }) + const now = Date.now() + if (now - lastHiddenDropContradictionWarnAtMs < 60_000) { + return + } + lastHiddenDropContradictionWarnAtMs = now + console.warn('[pty] hidden-delivery gate is dropping bytes for a visible/active pty', { + id, + droppedChars, + visible: visibleRendererPtys.has(id), + active: activeRendererPtys.has(id), + ...readCurrentPtyRendererDeliveryDebugSnapshot() + }) + } + function recordPtyRendererDeliveryPressure(): void { // Why: this fires on every PTY delivery event (per send, per flush, per // onData append). Update the four diagnostic peaks directly instead of @@ -1555,9 +1894,18 @@ export function registerPtyHandlers( peakPendingChars = Math.max(peakPendingChars, pendingChars) peakMaxPendingCharsByPty = Math.max(peakMaxPendingCharsByPty, maxPendingCharsByPty) peakRendererInFlightChars = Math.max(peakRendererInFlightChars, rendererInFlightTotalChars) + // Why derived per entry: this branch tracks cumulative sent/acked totals + // (TCP-style), not a per-pty in-flight map — in-flight is the difference. + let maxRendererInFlightCharsByPty = 0 + for (const accounting of rendererDeliveryAccountingByPty.values()) { + maxRendererInFlightCharsByPty = Math.max( + maxRendererInFlightCharsByPty, + accounting.sentChars - accounting.ackedChars + ) + } peakMaxRendererInFlightCharsByPty = Math.max( peakMaxRendererInFlightCharsByPty, - getMaxMapValue(rendererInFlightCharsByPty.values()) + maxRendererInFlightCharsByPty ) } @@ -1568,6 +1916,8 @@ export function registerPtyHandlers( peakRendererInFlightChars = 0 peakMaxRendererInFlightCharsByPty = 0 ackGatedFlushSkipCount = 0 + pendingDroppedChars = 0 + resetHiddenRendererPtyDeliveryDebugCounters() recordPtyRendererDeliveryPressure() } resetRendererDeliveryAccountingForLifecycleReset = () => { @@ -1577,9 +1927,15 @@ export function registerPtyHandlers( // which are fed before the pendingData append so they always superset it. lastLifecycleResetClearedChars = rendererInFlightTotalChars rendererLifecycleResetCount += 1 - rendererInFlightCharsByPty.clear() + // Why: pending bytes and outstanding credits belonged to the dead page. + // Release producer pauses before clearing them so no shell stays wedged. + producerFlowControl.releaseAll() + clearDeliveryResyncProbe() + deliveryResyncUnansweredWarnLogged = false + rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 pendingData.clear() + pendingOverflowMarkedPtys.clear() // Why: the reloading page's pty:data listener is gone until it re-registers // and re-sends the handshake; hold sends until then so the boot window can't // re-pin the gate with bytes dropped into a listener-less page. @@ -1630,8 +1986,7 @@ export function registerPtyHandlers( id: string, data: string, startSeq: number | undefined, - containsBackgroundOutput: boolean | undefined, - droppedBacklog?: boolean + containsBackgroundOutput: boolean | undefined ): PtyDataPayload { const payload: PtyDataPayload = { id, data } if (typeof startSeq === 'number') { @@ -1641,9 +1996,6 @@ export function registerPtyHandlers( if (containsBackgroundOutput === true) { payload.background = true } - if (droppedBacklog === true) { - payload.droppedBacklog = true - } return payload } @@ -1660,15 +2012,147 @@ export function registerPtyHandlers( const ptyLimit = PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS + (options.interactive === true ? PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS : 0) - return ( - (rendererInFlightCharsByPty.get(id) ?? 0) < ptyLimit && - rendererInFlightTotalChars < totalLimit + return getRendererInFlightCharsForPty(id) < ptyLimit && rendererInFlightTotalChars < totalLimit + } + + // Why: max-merge on cumulative totals is idempotent and reorder-tolerant — + // a replayed or out-of-order ACK can never double-credit, and a lost ACK + // self-heals when any later ACK reports the full processed count. Returns + // the newly acknowledged delta so provider (SSH/daemon) backpressure is only + // credited for bytes main actually tracked in flight, never negative. + function applyCumulativeAck(id: string, processedChars: number): number { + const accounting = rendererDeliveryAccountingByPty.get(id) + if (!accounting) { + return 0 + } + // Clamped to sentChars so a corrupt payload cannot drive in-flight negative. + const nextAckedChars = Math.min( + accounting.sentChars, + Math.max(accounting.ackedChars, processedChars) ) + const acknowledged = nextAckedChars - accounting.ackedChars + accounting.ackedChars = nextAckedChars + if (acknowledged > 0) { + accounting.lastAckAtMs = Date.now() + } + rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged) + return acknowledged + } + + function clearDeliveryResyncProbe(): void { + deliveryResyncOutstandingRequestId = null + if (deliveryResyncTimer) { + clearTimeout(deliveryResyncTimer) + deliveryResyncTimer = null + } + } + + // Why: event-triggered verified-state recovery. Data arriving for a fully + // gated PTY is the deterministic signal that delivery may be stuck on lost + // ACKs (e.g. dropped across a system suspend); ask the renderer for its + // authoritative processed totals instead of resetting on a wall-clock guess. + function requestDeliveryResyncForGatedPty(): void { + if (deliveryResyncOutstandingRequestId !== null || mainWindow.isDestroyed()) { + return + } + deliveryResyncRequestSerial += 1 + const requestId = deliveryResyncRequestSerial + deliveryResyncOutstandingRequestId = requestId + deliveryResyncTimer = setTimeout(() => { + if (deliveryResyncOutstandingRequestId !== requestId) { + return + } + clearDeliveryResyncProbe() + // Why: no state mutation on timeout — a renderer that cannot answer has + // dead IPC, and only a reload cures that. Log once per silent streak so + // field diagnosis is captured without spamming every probe cycle. + if (deliveryResyncUnansweredWarnLogged) { + return + } + deliveryResyncUnansweredWarnLogged = true + console.warn('[pty] delivery resync probe unanswered — renderer IPC unresponsive', { + msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, + ...readCurrentPtyRendererDeliveryDebugSnapshot() + }) + }, PTY_DELIVERY_RESYNC_TIMEOUT_MS) + deliveryResyncTimer.unref?.() + mainWindow.webContents.send('pty:requestDeliveryResync', { requestId }) + } + + // Why: bytes sent but never counted received by the renderer after a + // confirmed wedge are gone — no ACK message can ever repay them (unlike a + // lost ACK, which any later cumulative total heals). Write the debt off and + // hand back restore markers so panes repaint from the main-owned snapshot; + // the caller routes them locally because push markers cannot arrive. + function writeOffLostRendererDelivery( + report: PtyRendererDeliveryStateReport + ): PtyDeliveryWriteOff[] { + const writtenOff: PtyDeliveryWriteOff[] = [] + for (const [id, accounting] of rendererDeliveryAccountingByPty) { + if (accounting.sentChars - accounting.ackedChars <= 0) { + continue + } + const received = report.receivedCharsByPty?.[id] + const receivedChars = + typeof received === 'number' && Number.isFinite(received) ? Math.max(0, received) : 0 + // Why skip a parse-pending window: received-but-unparsed bytes sit alive + // in the renderer write queue; their deferred ACK still repays this debt. + if (receivedChars > accounting.ackedChars) { + continue + } + const acknowledged = applyCumulativeAck(id, accounting.sentChars) + if (acknowledged <= 0) { + continue + } + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + // Why drop pending: everything at or before markerSeq comes from the + // snapshot (hidden-drop parity); flushing pre-marker bytes afterward + // would double-paint what the restore already covers. + const pending = pendingData.get(id) + if (pending) { + pendingDroppedChars += pending.data.length + pendingData.delete(id) + pendingOverflowMarkedPtys.delete(id) + updateProducerFlowControl(id) + } + const markerSeq = runtime?.getPtyOutputSequence(id) + writtenOff.push({ + id, + ...(typeof markerSeq === 'number' ? { markerSeq } : {}), + writtenOffChars: acknowledged + }) + } + if (writtenOff.length > 0) { + clearDeliveryResyncProbe() + deliveryResyncUnansweredWarnLogged = false + mainDeliveryBreadcrumbs.record('delivery-heal-writeoff', { + writtenOffPtyCount: writtenOff.length, + writtenOffChars: writtenOff.reduce((sum, { writtenOffChars }) => sum + writtenOffChars, 0) + }) + console.warn('[pty] delivery heal: wrote off renderer-bound bytes lost in push channel', { + rendererPtyDataListenerCount: report.rendererPtyDataListenerCount ?? null, + msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, + writtenOffByPty: writtenOff.map(({ id, writtenOffChars }) => ({ id, writtenOffChars })), + ...readCurrentPtyRendererDeliveryDebugSnapshot() + }) + } + return writtenOff } function sendPtyDataToRenderer(id: string, payload: PtyDataPayload): void { const charCount = getPtyPayloadCharCount(payload) - rendererInFlightCharsByPty.set(id, (rendererInFlightCharsByPty.get(id) ?? 0) + charCount) + const accounting = rendererDeliveryAccountingByPty.get(id) + if (accounting) { + accounting.sentChars += charCount + accounting.lastSendAtMs = Date.now() + } else { + rendererDeliveryAccountingByPty.set(id, { + sentChars: charCount, + ackedChars: 0, + lastSendAtMs: Date.now(), + lastAckAtMs: null + }) + } rendererInFlightTotalChars += charCount recordPtyRendererDeliveryPressure() mainWindow.webContents.send('pty:data', payload) @@ -1701,6 +2185,26 @@ export function registerPtyHandlers( deliveredHiddenRendererResizeOutputPtys.delete(id) } + // Why: when main drops renderer delivery (hidden gate / pending cap), an + // explicit out-of-band pty:modelRestoreNeeded signal tells the renderer to + // latch model-restore-needed. It must NOT ride pty:data: an in-band empty + // chunk is indistinguishable from a chunk fully consumed by renderer-side + // OSC-9999 stripping, which spuriously restored visible panes. + function sendModelRestoreNeededMarker( + id: string, + reason: PtyModelRestoreReason, + markerSeq: number | undefined + ): void { + if (mainWindow.isDestroyed()) { + return + } + mainWindow.webContents.send('pty:modelRestoreNeeded', { + id, + reason, + ...(typeof markerSeq === 'number' ? { markerSeq } : {}) + }) + } + function getPendingPtyFlushEntries(): [string, PendingPtyData][] { const entries = Array.from(pendingData.entries()) const active: [string, PendingPtyData][] = [] @@ -1715,60 +2219,106 @@ export function registerPtyHandlers( return [...active, ...background] } - // Why: bound the unsent backlog to the most-recent PENDING_DATA_MAX_CHARS, - // advancing startSeq by the dropped-char count (same arithmetic flushPendingData - // uses when it slices) so the remaining tail's seq stays correct. Flags - // droppedBacklog so the next payload tells the renderer to rebuild the dropped - // span from the main headless snapshot. A no-op when under the cap (the common - // case: the renderer is ACKing and pendingData stays tiny). - function capPendingPtyData(pending: PendingPtyData): PendingPtyData { - if (pending.data.length <= PENDING_DATA_MAX_CHARS) { + const pendingDataDropWarnedPtys = new Set() + + // Why capped: the drop path guarantees O(1) memory per PTY; salvaged query + // bytes are tiny (a DSR probe is 4 chars) and anything past the cap means a + // pathological stream, where degrading to the plain sentinel is fine. + const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096 + + // Why: a bulk drop must not swallow reply-eliciting queries embedded in the + // flood (DSR 6n / CPR, DA1/DA2, DECRQM, OSC 10/11 probes). The program that + // wrote them blocks on the reply (the bench DSR timeout). Carve just the + // query bytes out and let them ride the droppedOutput sentinel — content is + // healed by the snapshot restore, so replies cannot double-fire. + function extractDroppedPtyQueryBytes(data: string): string { + if (!data.includes('\x1b')) { + return '' + } + const extracted = extractHiddenStartupRendererQueryData(data, '') + return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData + } + + function dropOversizedPendingPtyData(id: string, pending: PendingPtyData): PendingPtyData { + const capChars = pendingDataCapChars() + if (pending.droppedOutput === true || pending.data.length <= capChars) { return pending } - const trimmed = pending.data.slice(pending.data.length - PENDING_DATA_MAX_CHARS) - const droppedChars = pending.data.length - trimmed.length - const next: PendingPtyData = { data: trimmed, droppedBacklog: true } - if (typeof pending.startSeq === 'number') { - next.startSeq = pending.startSeq + droppedChars + if (!pendingDataDropWarnedPtys.has(id)) { + pendingDataDropWarnedPtys.add(id) + console.error( + `[pty] dropped ${pending.data.length} buffered chars for ${id}: renderer not receiving and per-PTY pending cap exceeded; pane will restore from the main-owned snapshot` + ) + // Why: field visibility for cap tuning — drop frequency and size decide + // whether the cap is too small (issue #2836 / #7017). No pty id: session + // ids can embed workspace paths. + recordCrashBreadcrumb('terminal_pending_output_dropped', { + droppedChars: pending.data.length, + capChars + }) } - if (pending.containsBackgroundOutput === true) { - next.containsBackgroundOutput = true + // Why: with the hidden-delivery gate rolled out, the model snapshot can + // recover the dropped middle — emit the out-of-band restore marker once + // per overflow episode alongside the droppedOutput sentinel so a fresh + // or reloaded view latches restore too. + if (isHiddenPtyDeliveryGateEnabled(getSettings?.()) && !pendingOverflowMarkedPtys.has(id)) { + pendingOverflowMarkedPtys.add(id) + sendModelRestoreNeededMarker(id, 'pending-cap', runtime?.getPtyOutputSequence(id)) + } + pendingDroppedChars += pending.data.length + // Why no trimmed content tail: a mid-stream gap would silently corrupt + // the pane. The droppedOutput sentinel routes the pane through + // hidden-output restore, which repaints from the authoritative main-owned + // buffer and realigns with the live stream by sequence. Only carved-out + // query bytes ride along so their replies survive the drop. + return { + data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), + droppedOutput: true } - return next } function appendPendingPtyData( + id: string, existing: PendingPtyData | undefined, data: string, startSeq: number | undefined, preservesSeq: boolean, containsBackgroundOutput: boolean ): PendingPtyData { + // Why: once over the cap, stay dropped at O(1) memory until the renderer + // can receive again — the restore sentinel supersedes any interim bytes. + // Queries arriving while latched still get carved out (bounded) so their + // replies survive the whole drop episode, not just the first burst. + if (existing?.droppedOutput === true) { + if (existing.data.length >= DROPPED_QUERY_SALVAGE_MAX_CHARS) { + return existing + } + const salvaged = extractDroppedPtyQueryBytes(data) + return salvaged ? { ...existing, data: existing.data + salvaged } : existing + } const nextContainsBackgroundOutput = existing?.containsBackgroundOutput === true || containsBackgroundOutput - // Carry a prior drop flag forward until it actually rides an outgoing payload. - const inheritedDropped = existing?.droppedBacklog === true - const base: PendingPtyData = { data: '' } if (!preservesSeq) { - base.data = (existing?.data ?? '') + data - } else if (!existing) { - base.data = data - if (typeof startSeq === 'number') { - base.startSeq = startSeq - } - } else { - base.data = existing.data + data - if (typeof existing.startSeq === 'number') { - base.startSeq = existing.startSeq - } + return dropOversizedPendingPtyData(id, { + data: (existing?.data ?? '') + data, + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + }) } - if (nextContainsBackgroundOutput) { - base.containsBackgroundOutput = true + if (!existing) { + return dropOversizedPendingPtyData(id, { + data, + ...(typeof startSeq === 'number' ? { startSeq } : {}), + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + }) } - if (inheritedDropped) { - base.droppedBacklog = true + const next: PendingPtyData = { + data: existing.data + data, + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) } - return capPendingPtyData(base) + if (typeof existing.startSeq === 'number') { + next.startSeq = existing.startSeq + } + return dropOversizedPendingPtyData(id, next) } function schedulePendingDataFlush(delayMs: number): void { @@ -1809,8 +2359,13 @@ export function registerPtyHandlers( function flushPendingData(): void { flushTimer = null if (mainWindow.isDestroyed()) { + // Why: the bookkeeping is being wiped, so no future drain can ever + // resume these producers — release them now or local shells wedge. + producerFlowControl.releaseAll() + clearDeliveryResyncProbe() pendingData.clear() - rendererInFlightCharsByPty.clear() + pendingOverflowMarkedPtys.clear() + rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 clearDispatcherReadyWatchdog() recordPtyRendererDeliveryPressure() @@ -1822,15 +2377,39 @@ export function registerPtyHandlers( if (!rendererPtyDispatcherReady) { return } + const settings = getSettings?.() let writes = 0 for (const [id, pending] of getPendingPtyFlushEntries()) { if (writes >= PTY_BATCH_FLUSH_MAX_WRITES) { break } + // Why: hidden-gated bytes are dropped, never re-queued — the model + // already ingested them; reveal restores from the snapshot+seq machinery. + if (shouldDropHiddenRendererPtyData(id, settings)) { + pendingData.delete(id) + pendingOverflowMarkedPtys.delete(id) + updateProducerFlowControl(id) + const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length) + warnIfDroppingHiddenBytesForVisiblePty(id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id)) + } + continue + } if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) { continue } pendingData.delete(id) + if (pending.droppedOutput === true) { + updateProducerFlowControl(id) + // Why: the buffered bytes were dropped at the pending cap; tell the + // renderer so the pane repaints from the main-owned buffer snapshot + // instead of continuing a stream with a silent gap. data carries only + // the carved-out query bytes (see extractDroppedPtyQueryBytes). + sendPtyDataToRenderer(id, { id, data: pending.data, droppedOutput: true }) + writes++ + continue + } const { data } = pending const chunk = data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) const remaining = data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) @@ -1843,19 +2422,13 @@ export function registerPtyHandlers( nextPending.containsBackgroundOutput = true } pendingData.set(id, nextPending) + } else { + pendingOverflowMarkedPtys.delete(id) } - // Why: the drop flag rides the first emitted chunk (renderer restore is - // idempotent) and is intentionally not copied onto `remaining` above, so a - // single backlog trim signals the renderer exactly once. + updateProducerFlowControl(id) sendPtyDataToRenderer( id, - makePtyDataPayload( - id, - chunk, - pending.startSeq, - pending.containsBackgroundOutput, - pending.droppedBacklog - ) + makePtyDataPayload(id, chunk, pending.startSeq, pending.containsBackgroundOutput) ) writes++ } @@ -1913,25 +2486,40 @@ export function registerPtyHandlers( // tears down the terminal on pty:exit before the batch timer fires. const remaining = pendingData.get(payload.id) if (remaining) { - sendPtyDataToRenderer( - payload.id, - makePtyDataPayload( + if (remaining.droppedOutput === true) { + // Sentinel entry: only salvaged query bytes remain; keep the flag so + // the renderer knows the span was dropped (same as the flush loop). + sendPtyDataToRenderer(payload.id, { + id: payload.id, + data: remaining.data, + droppedOutput: true + }) + } else { + sendPtyDataToRenderer( payload.id, - remaining.data, - remaining.startSeq, - remaining.containsBackgroundOutput, - remaining.droppedBacklog + makePtyDataPayload( + payload.id, + remaining.data, + remaining.startSeq, + remaining.containsBackgroundOutput + ) ) - ) + } pendingData.delete(payload.id) } + // Why: exit drops this PTY's bookkeeping; resume (no-op on a dead PTY) + // rather than leave a stale paused mark behind for a reused id. + producerFlowControl.release(payload.id) + pendingOverflowMarkedPtys.delete(payload.id) lastInputAtByPty.delete(payload.id) interactiveOutputCharsByPty.delete(payload.id) rendererInFlightTotalChars = Math.max( 0, - rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0) + rendererInFlightTotalChars - getRendererInFlightCharsForPty(payload.id) ) - rendererInFlightCharsByPty.delete(payload.id) + // Why: the renderer also drops its cumulative total on pty:exit, so a + // reused id restarts aligned at zero on both sides. + rendererDeliveryAccountingByPty.delete(payload.id) recordPtyRendererDeliveryPressure() mainWindow.webContents.send('pty:exit', payload) } @@ -1962,6 +2550,35 @@ export function registerPtyHandlers( const bindProviderListeners = (): void => { localDataUnsub?.() localExitUnsub?.() + localBackgroundStreamUnsub?.() + + // Keep-tail thinning facts from the daemon, in byte order with onData. + // The marker flips scan authority for the four transient-fact scanners; + // a gap resets main's cross-chunk parse state and forces the renderer to + // restore from the model snapshot (same seq-guard path as hidden drops) + // in case any view — eager buffer included — was receiving bytes. + localBackgroundStreamUnsub = + localProvider.onBackgroundStreamEvent?.((payload) => { + if (payload.kind === 'backgroundMarker') { + runtime?.setPtyTransientFactDelegation( + payload.id, + payload.background, + payload.scanSeedAnsi + ) + return + } + if (payload.kind === 'dataGap') { + providerSnapshotRequiredPtys.add(payload.id) + runtime?.notePtyDataGap(payload.id, payload.sequenceChars ?? payload.droppedChars) + sendModelRestoreNeededMarker( + payload.id, + 'hidden-drop', + runtime?.getPtyOutputSequence(payload.id) + ) + return + } + runtime?.emitDaemonPtyTransientFact(payload.id, payload.fact) + }) ?? null // Why: LocalPtyProvider routes data to the runtime via configure().onData, // but daemon-backed providers don't have configure(). Without this, daemon @@ -1974,9 +2591,16 @@ export function registerPtyHandlers( localDataUnsub = localProvider.onData((payload) => { const outputSeq = isLocalProvider ? runtime?.getPtyOutputSequence(payload.id) - : runtime?.onPtyData(payload.id, payload.data, Date.now()) + : runtime?.onPtyData( + payload.id, + payload.data, + Date.now(), + payload.sequenceChars ?? payload.data.length + ) const rendererData = answerStartupTerminalColorQueriesForPty(payload.id, payload.data) - const preservesSeq = rendererData === payload.data + const preservesSeq = + rendererData === payload.data && + (payload.sequenceChars === undefined || payload.sequenceChars === payload.data.length) const startSeq = preservesSeq ? getChunkStartSeq(outputSeq, payload.data) : undefined if (mainWindow.isDestroyed()) { // Why: clear the pending flush timer so it doesn't fire after the window @@ -1986,13 +2610,30 @@ export function registerPtyHandlers( clearTimeout(flushTimer) flushTimer = null } + producerFlowControl.releaseAll() + clearDeliveryResyncProbe() pendingData.clear() - rendererInFlightCharsByPty.clear() + pendingOverflowMarkedPtys.clear() + rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 clearDispatcherReadyWatchdog() recordPtyRendererDeliveryPressure() return } + const settings = getSettings?.() + // Why: hidden-delivery gate — runtime ingestion above already consumed + // the chunk; gated renderer delivery is DROPPED (never queued) and the + // reveal path restores from the model snapshot via the seq guard. The + // drop sits before the interactive bypass so gated PTYs take neither + // the immediate nor the batched renderer path. + if (shouldDropHiddenRendererPtyData(payload.id, settings)) { + const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length) + warnIfDroppingHiddenBytesForVisiblePty(payload.id, payload.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq) + } + return + } if (rendererData.length === 0) { return } @@ -2003,6 +2644,7 @@ export function registerPtyHandlers( } const existing = pendingData.get(payload.id) const pending = appendPendingPtyData( + payload.id, existing, rendererData, startSeq, @@ -2023,11 +2665,15 @@ export function registerPtyHandlers( // terminal output already handed to the renderer. The reserve is // bounded, and the per-PTY cap still prevents an active TUI runaway. if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) { + requestDeliveryResyncForGatedPty() pendingData.set(payload.id, pending) + updateProducerFlowControl(payload.id) recordPtyRendererDeliveryPressure() return } pendingData.delete(payload.id) + updateProducerFlowControl(payload.id) + pendingOverflowMarkedPtys.delete(payload.id) clearFlushTimerIfIdle() // Why: agent TUIs redraw small prompt regions after every keystroke. // Waiting for the throughput batch timer adds visible input latency. @@ -2038,12 +2684,20 @@ export function registerPtyHandlers( ? { seq: pending.startSeq + nextData.length, rawLength: nextData.length } : {}), ...(pending.containsBackgroundOutput === true ? { background: true } : {}), - ...(pending.droppedBacklog === true ? { droppedBacklog: true } : {}) + ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) }) return } pendingData.set(payload.id, pending) + updateProducerFlowControl(payload.id) recordPtyRendererDeliveryPressure() + // Why: probe on data arrival, not on flush skips — new output for a + // fully gated PTY is the moment stuck delivery becomes observable. + if ( + !canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) }) + ) { + requestDeliveryResyncForGatedPty() + } if (!flushTimer) { schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) } @@ -2151,6 +2805,28 @@ export function registerPtyHandlers( }) } + // Why: a reload (did-finish-load) or renderer crash replaces the process + // that owned every delivery-interest hold and hidden mark; surviving + // daemon/SSH PTYs would otherwise stay force-fed (leaked interest defeats + // the gate) or stay gated against a renderer that never marked them. Drop + // memory is preserved — each pane's first sync re-marks/unmarks and the + // unmark path re-emits the restore marker for unrestored drops. + clearRendererGateResetHandlers() + rendererGateResetLoadHandler = () => { + resetRendererScopedHiddenPtyDeliveryState() + // Why: the daemon pacer must not keep throttling ptys whose hidden marks + // died with the renderer; the fresh renderer's first visibility sync + // re-marks the ones that are still hidden. + resyncBackgroundedDeliveriesAfterGateReset() + } + rendererGateResetGoneHandler = () => { + resetRendererScopedHiddenPtyDeliveryState() + resyncBackgroundedDeliveriesAfterGateReset() + } + rendererGateResetWebContents = mainWindow.webContents + mainWindow.webContents.on('did-finish-load', rendererGateResetLoadHandler) + mainWindow.webContents.on('render-process-gone', rendererGateResetGoneHandler) + // Kill orphaned PTY processes from previous page loads when the renderer reloads. // Why: only applies to LocalPtyProvider where PTYs live in the Electron main // process and can become orphaned on page reload. Daemon-backed sessions @@ -2465,6 +3141,18 @@ export function registerPtyHandlers( } } ptyOwnership.set(result.id, args.connectionId ?? null) + // Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY + // determination from the spawn record before any byte reaches the + // runtime emulator, so its DA1 override exists from byte zero. + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: daemonShellOverride + }) + ) { + markNativeWindowsConptyPty(result.id) + } const relayResultId = getRelayPtyId(args.connectionId, result.id) const persistSshLease = (): void => { if (!store || !args.connectionId) { @@ -2534,6 +3222,10 @@ export function registerPtyHandlers( : undefined ) } + // Why: arms main's per-PTY Command Code output detector from the launch + // command (renderer startupCommand parity); banner detection covers + // PTYs spawned without one. + runtime?.noteTerminalSpawnCommand?.(result.id, args.command ?? null) if (isClaudeLaunch) { markClaudePtySpawned(result.id) } @@ -2782,8 +3474,10 @@ export function registerPtyHandlers( cwd?: string | null lastTitle?: string seq?: number + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string } | null> => { if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { @@ -2791,13 +3485,61 @@ export function registerPtyHandlers( } const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows) try { - return await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { scrollbackRows }) + const runtimeSeqBeforeSnapshot = runtime.getPtyOutputSequence(args.id) + const providerSnapshotRequired = providerSnapshotRequiredPtys.has(args.id) + const providerSnapshot = providerSnapshotRequired + ? await tryGetProviderForPty(args.id)?.getBufferSnapshot?.(args.id, { + scrollbackRows + }) + : null + // Why: after a data gap, main's model contains only the retained tail. + // Returning it as a full snapshot would silently erase older scrollback. + if (providerSnapshotRequired && !providerSnapshot) { + return null + } + const snapshot = + providerSnapshot ?? + (await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { + scrollbackRows + })) + if (!snapshot || typeof snapshot.seq !== 'number') { + return snapshot + } + // Why: sampled after serialize — every byte at or below snapshot.seq + // that can still reach the renderer sits in this pending queue. The + // renderer's post-restore dedupe bounds its duplicate window with it; + // without the bound a stale baseline silently swallows genuinely-new + // chunks whose seq domain sits below the snapshot counter. + const pending = pendingData.get(args.id) + if (pending && typeof pending.startSeq !== 'number') { + // Why: a seq-less backlog cannot be bounded — stay conservative. + return snapshot + } + return { + ...snapshot, + pendingDeliveryStartSeq: Math.min( + pending?.startSeq ?? (providerSnapshot ? runtimeSeqBeforeSnapshot : snapshot.seq), + snapshot.seq + ) + } } catch { return null } } ) + // Why: with main holding side-effect authority the renderer no longer + // derives titles from replayed bytes on (re)attach. This title-only replay + // snapshot restores title state — never historical bells/completions (the + // no-attention-replay rule, terminal-side-effect-authority.md). + ipcMain.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { + if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { + return null + } + return runtime.getTerminalSideEffectSnapshot(args.id) + }) + + installPowerSignalBreadcrumbs() ipcMain.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { return getPtyRendererDeliveryDebugSnapshot() }) @@ -2833,6 +3575,11 @@ export function registerPtyHandlers( foreground?: unknown background?: unknown } + // Why: hidden-at-spawn declaration (terminal-query-authority.md + // §races) — the renderer knows at spawn time that no visible view + // will consume this PTY's bytes, so main marks it hidden BEFORE the + // first byte and the gate + model responder own spawn-time queries. + initiallyHidden?: boolean // Why: closes the SIGKILL race documented in INVESTIGATION.md by // letting main patch + sync-flush the (worktreeId, tabId, leafId → // ptyId) binding before pty:spawn returns. Only the renderer's @@ -3222,6 +3969,20 @@ export function registerPtyHandlers( return await existingPaneSpawn.promise } const paneSpawnReservation = reservationPaneKey ? reservePaneSpawn(reservationPaneKey) : null + const initiallyHidden = args.initiallyHidden === true + // Why pre-spawn for daemon-host sessions (id minted up front): daemon + // PTYs can emit prompt bytes before spawn() resolves, and the hidden + // mark must beat the first byte so the gate + model responder own + // spawn-time queries (terminal-query-authority.md §races). Other + // providers cannot emit until spawn resolves; the post-spawn mark + // below is byte-zero-safe for them. + const preSpawnHiddenMarkId = + initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined + ? effectiveSessionAppId + : null + if (preSpawnHiddenMarkId !== null) { + markHiddenRendererPty(preSpawnHiddenMarkId) + } let result: PtySpawnResult try { try { @@ -3240,6 +4001,11 @@ export function registerPtyHandlers( result = await provider.spawn(spawnOptions) spawnTiming.mark('provider_spawn') } catch (err) { + // Why: a failed spawn must not leave a stale hidden mark on a session + // id a later visible attach may reuse. + if (preSpawnHiddenMarkId !== null) { + unmarkHiddenRendererPty(preSpawnHiddenMarkId) + } const rawMessage = err instanceof Error ? err.message : String(err) const spawnError = normalizeNodePtySpawnError(err) const isIdentityMismatch = @@ -3312,6 +4078,34 @@ export function registerPtyHandlers( reattach: result.isReattach ?? false }) ptyOwnership.set(result.id, args.connectionId ?? null) + if (initiallyHidden) { + // Why marked synchronously before any await below: local/SSH provider + // data events dispatch on later tasks, so this is still ahead of the + // first byte's delivery decision. Idempotent for daemon hosts already + // marked pre-spawn; the renderer's first visibility sync re-marks or + // unmarks (emitting the restore marker) through the Phase-4 path. + markHiddenRendererPty(result.id) + if (preSpawnHiddenMarkId !== null && preSpawnHiddenMarkId !== result.id) { + // Defense: never strand a mark on an id the provider renamed. + unmarkHiddenRendererPty(preSpawnHiddenMarkId) + } + // Why after ptyOwnership.set: the provider lookup routes by + // ownership, and a hidden-spawned agent should be paceable from its + // first flood, not from its first visibility transition. + syncPtyBackgroundedDelivery(result.id, 'spawn') + } + // Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY + // determination from the spawn record before the headless seed below, + // so the runtime emulator's DA1 override exists from byte zero. + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: effectiveShellOverride + }) + ) { + markNativeWindowsConptyPty(result.id) + } if (startupTerminalColorQueryReplyColors) { if (result.isReattach) { if (preSpawnStartupTerminalColorReplyPtyId) { @@ -3420,7 +4214,18 @@ export function registerPtyHandlers( ? { cols: result.snapshotCols, rows: result.snapshotRows } : undefined if (typeof result.snapshot === 'string' && result.snapshot.length > 0) { - runtime.seedHeadlessTerminal(result.id, result.snapshot, seedSize) + // Why kitty flags ride seed metadata: the snapshot string omits + // them by design (renderer kitty reset stays authoritative), but + // the re-seeded emulator must answer hidden `CSI ? u` with the + // flags the still-running app pushed (terminal-query-authority.md). + runtime.seedHeadlessTerminal( + result.id, + result.snapshot, + seedSize, + typeof result.snapshotKittyKeyboardFlags === 'number' + ? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags } + : {} + ) } else if ( result.coldRestore && typeof result.coldRestore.scrollback === 'string' && @@ -3453,6 +4258,13 @@ export function registerPtyHandlers( : undefined ) } + // Why: arms main's per-PTY Command Code output detector from the launch + // command (renderer startupCommand parity); banner detection covers + // PTYs spawned without one. + runtime?.noteTerminalSpawnCommand?.( + result.id, + typeof args.command === 'string' ? args.command : null + ) if (isClaudeLaunch) { markClaudePtySpawned(result.id) } @@ -3788,23 +4600,110 @@ export function registerPtyHandlers( // Why: renderer ACKs bound main→renderer terminal delivery without stopping // PTY ingestion. Agent/status consumers still see every chunk through the // provider/runtime path while background renderer writes wait their turn. - ipcMain.on('pty:ackData', (_event, args: { id: string; charCount: number }) => { - const charCount = Number.isFinite(args.charCount) ? Math.max(0, args.charCount) : 0 - const current = rendererInFlightCharsByPty.get(args.id) ?? 0 - const acknowledged = Math.min(current, charCount) - const next = Math.max(0, current - charCount) - rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged) - if (next === 0) { - rendererInFlightCharsByPty.delete(args.id) - } else { - rendererInFlightCharsByPty.set(args.id, next) + ipcMain.on( + 'pty:ackData', + (_event, args: { id: string; charCount?: number; processedChars?: number }) => { + lastAckReceivedAtMs = Date.now() + // Why: a live ACK channel means a future unanswered probe is a fresh + // diagnostic event, not a continuation of the last silent streak. + deliveryResyncUnansweredWarnLogged = false + let acknowledged = 0 + if (typeof args.processedChars === 'number' && Number.isFinite(args.processedChars)) { + acknowledged = applyCumulativeAck(args.id, Math.max(0, args.processedChars)) + } else { + // Why: tolerate legacy per-chunk delta payloads — dev hot-reload can + // pair an old renderer with a new main. Keyed by field presence. + const accounting = rendererDeliveryAccountingByPty.get(args.id) + const delta = Number.isFinite(args.charCount) ? Math.max(0, args.charCount ?? 0) : 0 + acknowledged = accounting ? applyCumulativeAck(args.id, accounting.ackedChars + delta) : 0 + } + tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) + recordPtyRendererDeliveryPressure() + if (pendingData.size > 0 && !flushTimer) { + schedulePendingDataFlush(0) + } } - tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) - recordPtyRendererDeliveryPressure() - if (pendingData.size > 0 && !flushTimer) { - schedulePendingDataFlush(0) + ) + + ipcMain.on( + 'pty:deliveryResyncResponse', + (_event, args: { requestId: number; processedCharsByPty: Record }) => { + if ( + deliveryResyncOutstandingRequestId === null || + args?.requestId !== deliveryResyncOutstandingRequestId + ) { + return + } + clearDeliveryResyncProbe() + deliveryResyncUnansweredWarnLogged = false + // Why: max-merge — the renderer's cumulative totals are authoritative + // for what it processed; reconciling them drains exactly the in-flight + // debt left by lost ACKs, nothing more. + for (const [id, processedChars] of Object.entries(args.processedCharsByPty ?? {})) { + if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { + continue + } + const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) + if (acknowledged > 0) { + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } + } + recordPtyRendererDeliveryPressure() + if (pendingData.size > 0 && !flushTimer) { + schedulePendingDataFlush(0) + } } - }) + ) + + // Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0 snapshot, + // 2026-07-06) kills every main→renderer push channel while invoke stays + // alive, so the solicited-resync probe above can never be answered there. + // This is the same reconcile, ridden over the direction proven to work, plus + // a write-off lane for bytes the renderer provably never received. + ipcMain.handle( + 'pty:reportRendererDeliveryState', + (_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => { + // Extra repair lane for the lost-ACK variant: identical max-merge to the + // resync response, so a heal is only reached when merging cannot drain. + for (const [id, processedChars] of Object.entries(args?.processedCharsByPty ?? {})) { + if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { + continue + } + const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) + if (acknowledged > 0) { + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } + } + let writtenOff: PtyDeliveryWriteOff[] = [] + // Why the main-side ACK-silence check: the renderer's two silent ticks + // already argue for a wedge; requiring main to have seen no ACK either + // keeps a buggy/foreign caller from writing off live delivery. + if ( + args?.heal === true && + rendererInFlightTotalChars > 0 && + (lastAckReceivedAtMs === null || + Date.now() - lastAckReceivedAtMs >= PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS) + ) { + writtenOff = writeOffLostRendererDelivery(args) + } + recordPtyRendererDeliveryPressure() + if (pendingData.size > 0 && !flushTimer) { + schedulePendingDataFlush(0) + } + let inFlightPtyCount = 0 + for (const accounting of rendererDeliveryAccountingByPty.values()) { + if (accounting.sentChars - accounting.ackedChars > 0) { + inFlightPtyCount++ + } + } + return { + inFlightTotalChars: rendererInFlightTotalChars, + inFlightPtyCount, + msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, + ...(writtenOff.length > 0 ? { writtenOff } : {}) + } + } + ) // Why: the renderer sends this once its pty:data listener is live (per page // load / reload). Until it arrives, sends are held so boot-window bytes can't @@ -3864,6 +4763,75 @@ export function registerPtyHandlers( } else { visibleRendererPtys.delete(args.id) } + syncPtyBackgroundedDelivery(args.id, 'visibility-report') + }) + + ipcMain.removeAllListeners('pty:setHiddenRendererPty') + ipcMain.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + mainDeliveryBreadcrumbs.record(args.hidden === true ? 'gate-mark' : 'gate-unmark', { + id: redactPtyIdForDiagnostics(args.id) + }) + if (args.hidden === true) { + markHiddenRendererPty(args.id) + // Why: bytes already queued for a newly hidden PTY are model-owned + // state; drop them now instead of holding them under ACK starvation. + // Reveal restores from the snapshot. + const pending = pendingData.get(args.id) + if (pending && shouldDropHiddenRendererPtyData(args.id, getSettings?.())) { + pendingData.delete(args.id) + updateProducerFlowControl(args.id) + pendingOverflowMarkedPtys.delete(args.id) + const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker( + args.id, + 'hidden-drop', + runtime?.getPtyOutputSequence(args.id) + ) + } + recordPtyRendererDeliveryPressure() + } + syncPtyBackgroundedDelivery(args.id, 'gate-mark') + return + } + const { droppedWhileHidden } = unmarkHiddenRendererPty(args.id) + syncPtyBackgroundedDelivery(args.id, 'gate-unmark') + // Why: a renderer reload or remount can replace the view that latched + // restore-needed from the first-drop marker. Re-emit on unhide so the + // (possibly fresh) visible view still pulls the model snapshot covering + // the dropped bytes. If the original view is still alive this can trigger + // a redundant second restore — accepted: a snapshot replay is cheap and + // idempotent, while a missed restore leaves a corrupt pane. + if (droppedWhileHidden) { + sendModelRestoreNeededMarker(args.id, 'unhide', runtime?.getPtyOutputSequence(args.id)) + } + }) + + ipcMain.removeAllListeners('pty:terminalViewAttributes') + ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => { + // Why validate-or-drop: the responder must never store a malformed + // palette — a wrong color reply breaks TUI theme detection worse than + // the documented silent-until-first-push behavior. + const attributes = validateTerminalViewAttributes(args) + if (attributes) { + setTerminalViewAttributes(attributes) + } + }) + + ipcMain.removeAllListeners('pty:setPtyDeliveryInterest') + ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + // Why: explicit delivery-interest signal from renderer byte sidecars — + // any interest suppresses the hidden-delivery gate so + // raw-byte consumers keep receiving while the view is hidden or parked. + // Deliberately NOT synced to the daemon backlog pacer: interest consumers + // tolerate paced data, and interest churn must not un-pace a flood. + setRendererPtyDeliveryInterest(args.id, args.interested === true) }) ipcMain.removeAllListeners('pty:signal') diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 01697bcc6fe..7af303c72a3 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -5,6 +5,7 @@ const { applyElectronProxySettingsMock, browserWindowGetAllWindowsMock, handleMock, + onMock, previewGhosttyImportMock, previewWarpThemeImportMock, prepareLocalWorktreeRootsForReposMock, @@ -14,6 +15,7 @@ const { applyElectronProxySettingsMock: vi.fn(), browserWindowGetAllWindowsMock: vi.fn(), handleMock: vi.fn(), + onMock: vi.fn(), previewGhosttyImportMock: vi.fn(), previewWarpThemeImportMock: vi.fn(), prepareLocalWorktreeRootsForReposMock: vi.fn(), @@ -22,7 +24,7 @@ const { vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: browserWindowGetAllWindowsMock }, - ipcMain: { handle: handleMock }, + ipcMain: { handle: handleMock, on: onMock }, nativeTheme: { themeSource: 'system' } })) @@ -70,6 +72,7 @@ const store = { describe('registerSettingsHandlers', () => { beforeEach(() => { handleMock.mockClear() + onMock.mockClear() applyAppIconMock.mockClear() applyElectronProxySettingsMock.mockClear() applyElectronProxySettingsMock.mockResolvedValue({ source: 'settings' }) @@ -89,6 +92,22 @@ describe('registerSettingsHandlers', () => { expect(channels).toContain('settings:previewGhosttyImport') }) + it('answers the synchronous settings read with the persisted settings', () => { + // Why: panes can bind PTYs before async hydration; the side-effect + // authority kill switch needs the persisted value synchronously. + store.getSettings.mockReturnValue({ terminalMainSideEffectAuthority: false }) + registerSettingsHandlers(store as never) + + const listener = onMock.mock.calls.find( + (call) => call[0] === 'settings:get-sync' + )?.[1] as (event: { returnValue: unknown }) => void + expect(listener).toBeTypeOf('function') + + const event = { returnValue: undefined as unknown } + listener(event) + expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false }) + }) + it('registers settings:previewWarpThemeImport handler', () => { registerSettingsHandlers(store as never) const channels = handleMock.mock.calls.map((call) => call[0]) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 4f3a4698c76..b59bf6add2e 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -67,6 +67,15 @@ export function registerSettingsHandlers( return store.getSettings() }) + // Why: terminal panes can bind PTYs before async settings hydration + // completes. The side-effect authority kill switch is consulted once at + // transport creation, so the renderer needs the persisted value + // synchronously or pre-hydration bindings would always pick main authority + // (terminal-side-effect-authority.md, migration switch). + ipcMain.on('settings:get-sync', (event) => { + event.returnValue = store.getSettings() + }) + ipcMain.handle('settings:set', async (event, args: Partial) => { const sanitizedArgs = sanitizeRendererSettingsUpdate(args) // Why: Floating Workspace grants are trusted only when written by the diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 3fc96c897bf..1448f59d946 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -92,6 +92,8 @@ describe('LocalPtyProvider', () => { onExit: ReturnType write: ReturnType resize: ReturnType + pause: ReturnType + resume: ReturnType kill: ReturnType process: string pid: number @@ -138,6 +140,8 @@ describe('LocalPtyProvider', () => { }), write: vi.fn(), resize: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), kill: vi.fn(() => { exitCb?.({ exitCode: -1 }) }), @@ -896,6 +900,39 @@ describe('LocalPtyProvider', () => { }) }) + describe('producer flow control', () => { + it('pauses and resumes the node-pty process directly', async () => { + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + provider.pauseProducer(id) + expect(mockProc.pause).toHaveBeenCalledTimes(1) + provider.resumeProducer(id) + expect(mockProc.resume).toHaveBeenCalledTimes(1) + }) + + it('is a no-op for unknown PTY ids', () => { + expect(() => { + provider.pauseProducer('nonexistent') + provider.resumeProducer('nonexistent') + }).not.toThrow() + expect(mockProc.pause).not.toHaveBeenCalled() + expect(mockProc.resume).not.toHaveBeenCalled() + }) + + it('swallows node-pty throws from a torn-down PTY', async () => { + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + mockProc.pause.mockImplementation(() => { + throw new Error('read EIO') + }) + mockProc.resume.mockImplementation(() => { + throw new Error('read EIO') + }) + expect(() => { + provider.pauseProducer(id) + provider.resumeProducer(id) + }).not.toThrow() + }) + }) + describe('shutdown', () => { it('kills the PTY process', async () => { // Why: capture the spy reference before shutdown triggers onExit → diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 661b877abbf..941fe062997 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -842,6 +842,26 @@ export class LocalPtyProvider implements IPtyProvider { ptyProcesses.get(id)?.resize(cols, rows) } + // Why: node-pty pause() stops reading the pty master fd, so the kernel + // buffer fills and a flooding child blocks on write — true producer + // backpressure. Best-effort: a PTY torn down mid-call must never throw + // into the flow-control path. + pauseProducer(id: string): void { + try { + ptyProcesses.get(id)?.pause() + } catch { + /* PTY already destroyed */ + } + } + + resumeProducer(id: string): void { + try { + ptyProcesses.get(id)?.resume() + } catch { + /* PTY already destroyed */ + } + } + // Why: node-pty caches the last winsize it applied on the IPty handle, so its // cols/rows are the authoritative applied size (node-pty clamps invalid dims // and a resize on a dead handle is a no-op, neither of which the requested diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 367179338c2..be45aa04ff3 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -17,6 +17,9 @@ vi.mock('electron', () => ({ on: onMock, removeHandler: removeHandlerMock, removeAllListeners: removeAllListenersMock + }, + powerMonitor: { + on: vi.fn() } })) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index c8e2680366b..24bd305bfbc 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -21,9 +21,38 @@ import type { CommitMessageDraftContext } from '../../shared/commit-message-gene import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' // ─── PTY Provider ─────────────────────────────────────────────────── +/** Notification-bearing fact a thinning transport detected while it held + * scan authority for a backgrounded PTY (see onBackgroundStreamEvent). */ +export type PtyTransientFact = + | { kind: 'bell' } + | { kind: 'command-finished'; exitCode: number | null } + | { kind: 'pr-link'; link: TerminalGitHubPRLink } + | { kind: '2031-subscribe' } + +export type PtyBackgroundStreamEvent = + | { id: string; kind: 'backgroundMarker'; background: boolean; scanSeedAnsi?: string } + | { id: string; kind: 'dataGap'; droppedChars: number; sequenceChars?: number } + | { id: string; kind: 'transientFact'; fact: PtyTransientFact } + +export type PtyProviderBufferSnapshot = { + data: string + /** Authoritative normal buffer captured beside an alternate-screen frame. */ + scrollbackAnsi?: string + cols: number + rows: number + cwd?: string | null + lastTitle?: string + seq: number + source: 'headless' + oscLinks?: TerminalOscLinkRange[] + alternateScreen?: boolean + pendingEscapeTailAnsi?: string +} + export type PtySpawnOptions = { cols: number rows: number @@ -83,6 +112,11 @@ export type PtySpawnResult = { * writing the snapshot so ANSI cursor positions land correctly. */ snapshotCols?: number snapshotRows?: number + /** Kitty keyboard flags persisted in the daemon snapshot, threaded so the + * re-seeded runtime emulator answers hidden `CSI ? u` with the real flags + * (terminal-query-authority.md §kitty). Never replayed into a renderer + * xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */ + snapshotKittyKeyboardFlags?: number /** True when the spawn reattached to an existing daemon session. */ isReattach?: boolean /** True when the reattached session uses the alternate screen buffer @@ -119,6 +153,36 @@ export type IPtyProvider = { hasPty?: (id: string) => boolean write(id: string, data: string): void resize(id: string, cols: number, rows: number): void + /** + * Producer-side flow control: stop/restart reading the underlying PTY so a + * flooding child blocks on write (kernel backpressure) instead of growing + * main-process buffers. Best-effort and optional — providers that cannot + * pause (SSH relay, legacy daemon protocols) omit these or no-op silently, + * and callers must keep functioning without them (the pending-output cap + * still bounds memory when pause is unavailable). + */ + pauseProducer?: (id: string) => void + resumeProducer?: (id: string) => void + /** + * Hidden-delivery hint: the renderer has no visible view for this PTY, so + * the provider's transport may keep-tail thin this PTY's monitoring stream + * under backlog (bytes nobody is watching must not bury a visible pane's + * echo). Best-effort and optional, like pauseProducer. + */ + setPtyBackgrounded?: (id: string, background: boolean) => void + /** + * Facts a thinning transport interleaves with onData, in byte order: + * scan-authority handoff markers, keep-tail gaps, and the transient facts + * (bell/command-finished/pr-link/2031) it detected in bytes it was allowed + * to drop. Only transports that thin implement it. + */ + onBackgroundStreamEvent?: (callback: (payload: PtyBackgroundStreamEvent) => void) => () => void + /** Authoritative provider-owned model snapshot. Daemon providers expose this + * after their monitoring stream gaps; other providers may omit it. */ + getBufferSnapshot?: ( + id: string, + opts?: { scrollbackRows?: number } + ) => Promise /** * The size the PTY has ACTUALLY applied, not the last size requested. * resize() is fire-and-forget for remote providers (daemon/SSH `notify`), @@ -145,7 +209,9 @@ export type IPtyProvider = { listProcesses(): Promise getDefaultShell(): Promise getProfiles(): Promise<{ name: string; path: string }[]> - onData(callback: (payload: { id: string; data: string }) => void): () => void + onData( + callback: (payload: { id: string; data: string; sequenceChars?: number }) => void + ): () => void onReplay(callback: (payload: { id: string; data: string }) => void): () => void onExit(callback: (payload: { id: string; code: number }) => void): () => void } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 73330a14fa5..a9f03794db0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -56,6 +56,7 @@ import { } from './orca-runtime' import { HeadlessEmulator } from '../daemon/headless-emulator' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, TERMINAL_INPUT_MAX_BYTES, @@ -6475,6 +6476,654 @@ describe('OrcaRuntimeService', () => { }) }) + it('resolves tui-idle when a completion title is coalesced with the next working title', async () => { + // Why: node-pty + the main batch window can coalesce "task done" and the + // next task's working title into one chunk. A last-title reader never + // sees the intermediate idle and the waiter hangs (issue #1083 class). + const runtime = createRuntime() + syncSinglePty(runtime) + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + const [terminal] = (await runtime.listTerminals()).terminals + const wait = runtime.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 1_000 + }) + + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07\x1b]0;Codex working\x07', 101) + + await expect(wait).resolves.toMatchObject({ + handle: terminal.handle, + condition: 'tui-idle', + status: 'running' + }) + }) + + it('ignores the bare cursor-agent native title so synthesized spinner state survives', async () => { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100) + // cursor-agent re-emits its bare native title on internal redraws while + // still working; it must not stomp the synthesized working title. + runtime.onPtyData(ptyId, '\x1b]0;Cursor Agent\x07', 101) + + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: '⠋ Cursor Agent' + }) + }) + + it('clears a stale working title after 3s of title-less output', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyId, 'output without a title\r\n', 101) + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: 'Codex working' + }) + + await vi.advanceTimersByTimeAsync(3_000) + + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: 'Codex' + }) + } finally { + vi.useRealTimers() + } + }) + + it('cancels the stale-title timer when the PTY exits', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyId, 'output without a title\r\n', 101) + runtime.onPtyExit(ptyId, 0) + + await vi.advanceTimersByTimeAsync(4_000) + + // The dead session keeps its factual last title — the disposed tracker's + // stale-title rewrite must not fire into the retained record. + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: 'Codex working' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps stale-title timers isolated per PTY', async () => { + vi.useFakeTimers() + try { + const ptyA = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-a` + const ptyB = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-b` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { id: ptyA, cwd: '/tmp/worktree-a', title: 'shell' }, + { id: ptyB, cwd: '/tmp/worktree-a', title: 'shell' } + ] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyA, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyB, '\x1b]0;Aider working\x07', 100) + // Only A receives title-less output, so only A's stale timer arms. + runtime.onPtyData(ptyA, 'output without a title\r\n', 101) + + await vi.advanceTimersByTimeAsync(3_000) + + const { terminals } = await runtime.listTerminals() + expect(terminals.find((t) => t.tabId === `pty:${ptyA}`)).toMatchObject({ title: 'Codex' }) + expect(terminals.find((t) => t.tabId === `pty:${ptyB}`)).toMatchObject({ + title: 'Aider working' + }) + } finally { + vi.useRealTimers() + } + }) + + // ─── pty:sideEffect channel (terminal-side-effect-authority.md, slice 2) ── + describe('terminal side-effect fact channel', () => { + function createSideEffectRuntime(): { + runtime: OrcaRuntimeService + batches: TerminalSideEffectBatch[] + } { + const batches: TerminalSideEffectBatch[] = [] + const runtime = new OrcaRuntimeService(store, undefined, { + onTerminalSideEffects: (batch) => batches.push(batch) + }) + return { runtime, batches } + } + + it('emits one batched event per chunk with facts in byte order and attribution', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + const chunk = '\x1b]0;Codex working\x07response\x1b]0;Codex done\x07\x07' + runtime.onPtyData('pty-1', chunk, 100) + + expect(batches).toHaveLength(1) + expect(batches[0]).toMatchObject({ + ptyId: 'pty-1', + seq: chunk.length, + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + paneKey: 'tab-1:1' + }) + expect(batches[0].replay).toBeUndefined() + expect(batches[0].facts).toEqual([ + { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }, + { kind: 'agent-working' }, + { kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' }, + { kind: 'agent-idle', title: 'Codex done' }, + { kind: 'bell' } + ]) + }) + + it('keeps per-PTY ordering across chunks and accumulates seq', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + + expect(batches.map((batch) => batch.facts[0]?.kind)).toEqual(['title', 'title']) + expect(batches[0].seq).toBeLessThan(batches[1].seq) + }) + + it('emits nothing for chunks without derived facts', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + // Plain output, a BEL-terminated non-title OSC split across chunks, and + // an Orca status payload: none of these is a title/bell/agent fact. + runtime.onPtyData('pty-1', 'plain output\r\n', 100) + runtime.onPtyData('pty-1', '\x1b]7;file://host', 101) + runtime.onPtyData('pty-1', '/tmp\x07', 102) + runtime.onPtyData('pty-1', '\x1b]9999;{"state":"working","agentType":"codex"}\x07', 103) + + expect(batches).toEqual([]) + }) + + it('emits the stale-working-title rewrite as between-chunk fact batches', async () => { + vi.useFakeTimers() + try { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', 'output without a title\r\n', 101) + batches.length = 0 + + await vi.advanceTimersByTimeAsync(3_000) + + // Timer facts fire outside a chunk, so each emits immediately — + // still strictly ordered per PTY. They carry staleWorkingTitleClear: + // the renderer must clear state without scheduling a task-complete + // notification main's unthrottled timer did not earn. + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ]) + } finally { + vi.useRealTimers() + } + }) + + it('ingests synthetic title frames without touching the byte pipeline', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07') + + expect(batches).toHaveLength(1) + expect(batches[0].facts).toEqual([ + { kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' }, + // The synthesized spinner classifies as working — agent facts derive + // from synthetic frames the same as from real bytes. + { kind: 'agent-working' } + ]) + // Synthetic frames are fabricated by main: they must not advance the + // metered output sequence the renderer ACK budget is based on. + expect(runtime.getPtyOutputSequence('pty-1')).toBe(0) + }) + + it('carries the synthetic permission BEL as a bell fact', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor needs your input\x07\x07') + + expect(batches[0].facts.at(0)).toMatchObject({ kind: 'title' }) + expect(batches[0].facts.at(-1)).toEqual({ kind: 'bell' }) + }) + + it('emits command-finished facts with best-effort exit codes across chunk splits', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'output\x1b]133;D;13', 100) + expect(batches).toEqual([]) + runtime.onPtyData('pty-1', '0\x07prompt $ ', 101) + runtime.onPtyData('pty-1', '\x1b]133;D\x07', 102) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { kind: 'command-finished', exitCode: 130 }, + { kind: 'command-finished', exitCode: null } + ]) + }) + + it('emits pr-link facts once per URL with batch attribution', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'PR https://github.com/acme/orca/pull/4', 100) + runtime.onPtyData('pty-1', '2\r\nand https://github.com/acme/orca/pull/43 done\r\n', 101) + // Repeated URL: deduped per PTY, like the renderer byte detector. + runtime.onPtyData('pty-1', 'again https://github.com/acme/orca/pull/42\r\n', 102) + + expect(batches).toHaveLength(1) + expect(batches[0]).toMatchObject({ + ptyId: 'pty-1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1' + }) + expect(batches[0].facts).toEqual([ + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + }, + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/43', + slug: { owner: 'acme', repo: 'orca' }, + number: 43 + } + } + ]) + }) + + it('emits 2031-subscribe facts across chunk splits', () => { + // Why: hidden-delivery-gated views never receive the bytes — this fact + // is their only signal to send the DECSET 2031 color-scheme reply. + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b[?20', 100) + expect(batches).toEqual([]) + runtime.onPtyData('pty-1', '31h', 101) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }]) + }) + + it('prefers the tracked title over the renderer snapshot lastTitle', async () => { + const { runtime } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'visible content', + cols: 80, + rows: 24, + // The renderer xterm never saw the synthetic frame (it no longer + // rides pty:data), so its serializer reports a stale title. + lastTitle: 'stale shell title' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07') + + const snapshot = await runtime.serializeTerminalBuffer('pty-1', { scrollbackRows: 10 }) + expect(snapshot?.source).toBe('renderer') + expect(snapshot?.lastTitle).toBe('⠋ Cursor Agent') + }) + + it('prefers the tracked title over the headless emulator lastTitle', async () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07real output\r\n', 100) + // The hook-driven idle frame lands only in main's tracker — the + // emulator never sees fabricated bytes (invariant 5). + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 }) + expect(snapshot?.source).toBe('headless') + expect(snapshot?.lastTitle).toBe('Codex ready') + }) + + it('returns a title-only replay snapshot and never historical attention', () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07\x07', 100) + + expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({ + ptyId: 'pty-1', + replay: true, + facts: [{ kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }] + }) + expect(runtime.getTerminalSideEffectSnapshot('pty-unknown')).toBeNull() + }) + + it('drops the cursor-agent literal from record-fallback snapshots', () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'plain output\n', 100) + // Simulate a record title restored by a path that bypassed the tracker + // (the tracker itself refuses to store the bare native title). + const records = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById + records.get('pty-1')!.lastOscTitle = 'Cursor Agent' + + expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toBeNull() + }) + + it('emits the chunk agentStatus events before its side-effect batch', () => { + // Cross-channel contract order per chunk: status → titles → bell. + const order: string[] = [] + const runtime = new OrcaRuntimeService(store, undefined, { + onTerminalAgentStatus: () => order.push('agentStatus:set'), + onTerminalSideEffects: () => order.push('pty:sideEffect') + }) + syncSinglePty(runtime) + + runtime.onPtyData( + 'pty-1', + '\x1b]9999;{"state":"working","agentType":"codex"}\x07\x1b]0;Codex working\x07\x07', + 100 + ) + + expect(order).toEqual(['agentStatus:set', 'pty:sideEffect']) + }) + + it('still emits a throwing chunk’s facts under its own seq, not the next chunk’s', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + vi.spyOn( + runtime as unknown as { applyTrackedPtyTitle: (ptyId: string, title: string) => boolean }, + 'applyTrackedPtyTitle' + ).mockImplementationOnce(() => { + throw new Error('tracker boom') + }) + + const first = '\x1b]0;Codex working\x07' + expect(() => runtime.onPtyData('pty-1', first, 100)).toThrow('tracker boom') + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + + expect(batches).toHaveLength(2) + expect(batches[0].seq).toBe(first.length) + expect(batches[0].facts).toEqual([ + { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' } + ]) + // The next chunk's batch carries only its own facts (the throw aborted + // the first chunk's agent-tracker pass, so no working state was kept). + expect(batches[1].seq).toBeGreaterThan(batches[0].seq) + expect(batches[1].facts).toEqual([ + { kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' } + ]) + }) + + it('parses synthetic frames statelessly so ticks cannot corrupt the bell detector', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;split ti', 100) + // An 80ms spinner tick lands between the two halves of the real OSC. + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07') + // Continuation: this BEL terminates the real OSC — it is NOT a bell. + runtime.onPtyData('pty-1', 'tle\x07', 101) + // A later standalone BEL is a real bell and must not be swallowed. + runtime.onPtyData('pty-1', 'ready\x07', 102) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' }, + { kind: 'agent-working' }, + { kind: 'title', normalizedTitle: 'split title', rawTitle: 'split title' }, + { kind: 'bell' } + ]) + }) + + it('touches mobile snapshots once for decorative spinner ticks, again on idle', () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + const touchSpy = vi.spyOn( + runtime as unknown as { touchMobileSessionSnapshotsForPty: (ptyId: string) => void }, + 'touchMobileSessionSnapshotsForPty' + ) + + for (const frame of ['⠋', '⠙', '⠹', '⠸', '⠼']) { + runtime.ingestSyntheticTitleFrame('pty-1', `\x1b]0;${frame} Cursor Agent\x07`) + } + // Five ticks with the same de-spinnered title: one snapshot fan-out. + expect(touchSpy).toHaveBeenCalledTimes(1) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor ready\x07') + expect(touchSpy).toHaveBeenCalledTimes(2) + // Raw record titles still track every frame for worktree ps/mobile tabs. + expect( + ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1')?.lastOscTitle + ).toBe('Cursor ready') + }) + + it('seeds the lazily created tracker from the daemon-snapshot title', async () => { + const { runtime, batches } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'restored scrollback\n', + cols: 80, + rows: 24, + lastTitle: 'Codex working' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + // First live chunk creates the tracker cold and kicks off hydration; + // the snapshot seed must land in the already-created tracker. + runtime.onPtyData('pty-1', 'plain output without a title\n', 100) + await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 }) + batches.length = 0 + + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + + // Without the seed the tracker never saw 'working', so this idle title + // could not produce a completion fact. + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'agent-idle', + title: 'Codex done' + }) + }) + + it('arms the stale-title timer for a seeded working title', async () => { + vi.useFakeTimers() + try { + const { runtime, batches } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'restored scrollback\n', + cols: 80, + rows: 24, + lastTitle: 'Codex working' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'plain output\n', 100) + // Settle the async daemon-snapshot hydration that seeds the tracker. + await vi.advanceTimersByTimeAsync(0) + runtime.onPtyData('pty-1', 'still no title\n', 101) + batches.length = 0 + + await vi.advanceTimersByTimeAsync(3_000) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ]) + } finally { + vi.useRealTimers() + } + }) + + it('emits command-code-working facts only after the banner arms the scrape', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + // Generic status words without the Command Code banner must not arm. + runtime.onPtyData('pty-1', '❯ Fix the spinner\r\nThinking...', 100) + expect(batches.flatMap((batch) => batch.facts)).toEqual([]) + + runtime.onPtyData('pty-1', '# Command Code v0.27.3\r\n', 101) + runtime.onPtyData('pty-1', '❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m', 102) + + expect(batches.at(-1)).toMatchObject({ + ptyId: 'pty-1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1' + }) + expect(batches.at(-1)?.facts).toEqual([ + { kind: 'command-code-working', prompt: 'Fix the spinner' } + ]) + }) + + it('emits a command-code-done fact when the idle composer returns', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '# Command Code v0.27.3\r\n', 100) + runtime.onPtyData('pty-1', '❯ say hi\r\n✻ Thinking...', 101) + runtime.onPtyData( + 'pty-1', + '\r\n✻ Thought for 1 second\r\n:: Hi!\r\n❯ Ask your question...', + 102 + ) + + expect(batches.at(-1)?.facts).toEqual([{ kind: 'command-code-done', prompt: 'say hi' }]) + }) + + it('arms the Command Code scrape from the noted spawn command', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + // Mirrors the renderer detector's startupCommand fast-arm: no banner + // needed when main saw the launch command at spawn time. + runtime.noteTerminalSpawnCommand('pty-1', 'command-code --trust') + runtime.onPtyData('pty-1', '❯ Fix the spinner\r\n✻ Thinking...', 100) + + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'command-code-working', + prompt: 'Fix the spinner' + }) + }) + + it('prefers the tracked title over a stale renderer lastTitle in the hydration seed', async () => { + const { runtime } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'renderer scrollback\n', + cols: 80, + rows: 24, + // The renderer xterm never saw the synthetic hook frame (it no longer + // rides pty:data), so its serializer reports the pre-agent title. + lastTitle: 'stale shell title' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude working\x07') + // First live chunk kicks off renderer hydration; awaiting the snapshot + // below settles the seed write chain. + runtime.onPtyData('pty-1', 'plain output\n', 100) + await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 }) + + const leaves = ( + runtime as unknown as { leaves: Map } + ).leaves + // The seed must not stomp the leaf record (worktree ps status source) + // back to the renderer's stale title. + expect([...leaves.values()][0]?.lastOscTitle).toBe('⠋ Claude working') + }) + }) + it('returns OSC titles from headless main terminal snapshots', async () => { const runtime = createRuntime() syncSinglePty(runtime, 'pty-1') @@ -6733,6 +7382,16 @@ describe('OrcaRuntimeService', () => { expect(serializeBuffer).not.toHaveBeenCalled() }) + it('advances the absolute output sequence across a daemon stream gap', () => { + const runtime = createRuntime() + runtime.onPtyData('pty-gap', 'before', Date.now()) + + runtime.notePtyDataGap('pty-gap', 4096) + runtime.onPtyData('pty-gap', 'after', Date.now()) + + expect(runtime.getPtyOutputSequence('pty-gap')).toBe('before'.length + 4096 + 'after'.length) + }) + it('emits explicit OSC 9999 agent status from runtime PTY data', () => { const statuses: RuntimeTerminalAgentStatusEvent[] = [] const runtime = new OrcaRuntimeService(store, undefined, { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b59afc24a32..be8dc19a9ee 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2,9 +2,9 @@ /* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ /* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ import { - extractLastOscTitle, detectAgentStatusFromTitle, isClaudeManagementTitle, + isCursorNativeAgentTitle, isShellProcess, normalizeTerminalTitle } from '../../shared/agent-detection' @@ -13,6 +13,17 @@ import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extra import { parseFileUriPathParts } from '../daemon/osc7-file-uri' import type { AgentStatus } from '../../shared/agent-detection' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import { + createTerminalTitleTracker, + stripBrailleSpinnerGlyphs, + type TerminalTitleTracker +} from '../../shared/terminal-output-side-effects' +import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status' +import type { + TerminalSideEffectBatch, + TerminalSideEffectFact +} from '../../shared/terminal-side-effect-facts' +import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusIpcPayload, @@ -688,14 +699,28 @@ import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { closeLocalWatcherForWorktreePath } from '../ipc/filesystem-watcher' -import { HeadlessEmulator, type HeadlessEmulatorOptions } from '../daemon/headless-emulator' +import { HeadlessEmulator } from '../daemon/headless-emulator' +import { + isNativeWindowsConptyPty, + registerConptyDa1OverrideInstaller, + shouldModelAnswerHiddenPtyQueries +} from './terminal-model-query-authority' +import { + getTerminalViewAttributes, + registerTerminalViewAttributesApplier +} from './terminal-view-attribute-store' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import { createMobileSessionTabsNotifyCoalescer, type MobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer' -import type { IFilesystemProvider, IPtyProvider, PtyProcessInfo } from '../providers/types' +import type { + IFilesystemProvider, + IPtyProvider, + PtyProcessInfo, + PtyTransientFact +} from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { assertFolderWorkspacePathUsable, @@ -831,6 +856,11 @@ type RuntimeStore = { mobileEmulatorDefaultDeviceUdid?: string | null voice?: VoiceSettings claudeAgentTeamsMode?: GlobalSettings['claudeAgentTeamsMode'] + // Why: Phase-5 query responder kill switches — read per chunk in + // onPtyData to capture reply ownership at ingestion. + terminalMainSideEffectAuthority?: GlobalSettings['terminalMainSideEffectAuthority'] + terminalHiddenDeliveryGate?: GlobalSettings['terminalHiddenDeliveryGate'] + terminalModelQueryAuthority?: GlobalSettings['terminalModelQueryAuthority'] } // Why: narrow to `unknown` return so test mocks can return void without // a cast. The runtime never reads the return value — the persisted value @@ -1095,6 +1125,28 @@ export type RuntimeTerminalAgentStatusEvent = { payload: ParsedAgentStatusPayload } +type RuntimePtyTitleTrackerEntry = { + tracker: TerminalTitleTracker + // Why: onPtyData batches the mobile session-tab touch to once per chunk; + // the stale-working-title timer fires between chunks and must touch + // immediately. These flags route the tracker callback to the right mode. + applyingChunk: boolean + // Why: synthetic spinner ticks arrive ~12.5x/sec per working pane; the + // synthetic path gates mobile snapshot fan-out on a non-decorative title + // change (spinner glyph + status comparison key kept below). + applyingSyntheticFrame: boolean + lastMobileTitleGateKey: string | null + chunkTouchedSessionTabs: boolean + // Why: facts observed while applying a chunk are batched into one + // pty:sideEffect emission per chunk, preserving byte order (titles in + // sequence, then bell). Timer-fired facts emit immediately between chunks. + pendingFacts: TerminalSideEffectFact[] + // Why: Command Code lacks hooks, so its working/done state is scraped from + // TUI output. Null when no side-effect consumer exists (headless serve) — + // the scrape produces facts only. + commandCodeDetector: { observe: (data: string) => boolean } | null +} + // Why: the full OSC 9999 payload flows through emitTerminalAgentStatusEvents and // is then forwarded to the renderer and dropped. Mobile is served by the main // process and has no renderer store, so we retain the latest payload per pane @@ -1121,6 +1173,10 @@ type RuntimeHeadlessTerminal = { type HeadlessSeedMetadata = { cwd?: string | null oscLinks?: TerminalOscLinkRange[] + /** Persisted kitty flags from the daemon snapshot, re-applied to the fresh + * emulator so hidden `CSI ? u` answers the real flags instead of ?0u + * (terminal-query-authority.md §kitty). */ + kittyKeyboardFlags?: number } type RuntimePtyController = { @@ -2152,12 +2208,39 @@ export class OrcaRuntimeService { // Why: OSC 9999 status can span PTY chunks. Keeping parser state in the // runtime lets hidden/model-owned terminals observe agent state without a // mounted xterm view. + // Why a throttle: the blocked-reason check builds and scans two full wait + // texts (<=256KB each, lowercased) — measured at ~85% of onPtyData's cost + // under a TUI flood (findings log 2026-07-03). PTY chunk boundaries are + // arbitrary, so running the identical computation over coalesced chunks at + // a bounded cadence (plus a trailing-edge timer so burst-final state is + // always evaluated) preserves semantics while removing it from the hot path. + private waitBlockedCheckStateByPtyId = new Map< + string, + { + lastAt: number + lastWaitState: TerminalTailWaitState | null + appended: string + keywordCarry: string + timer: ReturnType | null + } + >() + private agentStatusOscProcessorsByPtyId = new Map< string, ReturnType >() + // Why: per-PTY shared title trackers (all-titles ordering + stale-working + // timer) replace last-title-per-chunk scanning so main observes the same + // intra-chunk working→idle transitions the renderer does (issue #1083). + // Lazily created like agentStatusOscProcessorsByPtyId; disposed on PTY exit. + private ptyTitleTrackersByPtyId = new Map() + // Why: the Command Code output detector arms early from the launch command + // when known (banner detection covers user-typed launches), mirroring the + // renderer detector's startupCommand seed. + private terminalSpawnCommandsByPtyId = new Map() // Why: ordinary OSC 0/1/2 titles can split across PTY chunks, especially over - // SSH/relay buffering. Keep a small raw scan tail so status titles are not lost. + // SSH/relay buffering. Keep a small raw scan tail and feed reconstructed + // chunks into the title tracker instead of falling back to last-title scans. private oscTitleScanTailByPtyId = new Map() // Why: mobile file taps resolve relative paths on the host. OSC 7 is the // terminal-owned cwd signal, and it can arrive in live output between snapshots. @@ -2224,6 +2307,14 @@ export class OrcaRuntimeService { > >() + // Why: Phase-5 query-responder suppression — a terminal-RPC subscribe + // stream feeds a remote xterm view (mobile/web/remote desktop) that answers + // queries with view authority, so main must yield while one is attached + // (terminal-query-authority.md). Ref-counted per PTY because multiple + // streams can attach concurrently; mobileSubscribers is consulted too so + // grace-window mobile records keep suppressing. + private remoteTerminalViewSubscriberCounts = new Map() + // Why: per-PTY driver state. The "driver" is whoever currently owns the // input/resize floor. While `kind === 'mobile'` the desktop renderer drops // xterm.onData/onResize and shows the lock banner; `terminal.send` / @@ -2371,6 +2462,7 @@ export class OrcaRuntimeService { private readonly getLocalProviderFn: (() => IPtyProvider) | null private readonly onPtyStopped: ((ptyId: string) => void) | null private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null + private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null private readonly buildAgentHookPtyEnv: (() => Record) | null private accountServices: RuntimeAccountServices | null = null @@ -2395,6 +2487,7 @@ export class OrcaRuntimeService { getLocalProvider?: () => IPtyProvider onPtyStopped?: (ptyId: string) => void onTerminalAgentStatus?: (event: RuntimeTerminalAgentStatusEvent) => void + onTerminalSideEffects?: (batch: TerminalSideEffectBatch) => void // Why: agent status mostly arrives via hooks (agent-hooks/server), not OSC // terminal output. worktree.ps reads this at query time so mobile shows the // same inline agent rows the desktop sidebar does — same source, 1:1. @@ -2431,6 +2524,20 @@ export class OrcaRuntimeService { this.onPtyStopped = deps?.onPtyStopped ?? null this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null + this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null + // Why: the ConPTY spawn mark can land after daemon stream data already + // created this PTY's emulator; the mark retrofits the DA1 override here + // (terminal-query-authority.md §ConPTY DA1). + registerConptyDa1OverrideInstaller((ptyId) => this.ensureNativeWindowsConptyDa1Override(ptyId)) + // Why: a renderer attribute push must reach already-live emulators too — + // cursor options for DECRQSS/DECRQM parity plus the per-PTY OSC color + // override reset a theme apply implies (terminal-query-authority.md + // §View-attribute bridge). + registerTerminalViewAttributesApplier((attributes) => { + for (const state of this.headlessTerminals.values()) { + state.emulator.applyPushedViewAttributes(attributes) + } + }) } getLocalProvider(): IPtyProvider | null { @@ -5390,12 +5497,22 @@ export class OrcaRuntimeService { } } + /** Record the spawn launch command so the per-PTY Command Code detector can + * arm from it (renderer startupCommand parity). Best-effort: a chunk that + * beats this call falls back to the detector's banner arming. */ + noteTerminalSpawnCommand(ptyId: string, command: string | null | undefined): void { + const trimmed = typeof command === 'string' ? command.trim() : '' + if (trimmed.length > 0) { + this.terminalSpawnCommandsByPtyId.set(ptyId, trimmed) + } + } + /** * Handles incoming data from a PTY process, running agent detection, * updating terminal tail buffers, and triggering foreground agent refreshes. */ - onPtyData(ptyId: string, data: string, at: number): number { - const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + data.length + onPtyData(ptyId: string, data: string, at: number, sequenceChars = data.length): number { + const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars this.ptyOutputSequenceById.set(ptyId, outputSequence) const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data) const cwd = osc7Metadata.cwd @@ -5410,6 +5527,12 @@ export class OrcaRuntimeService { // panel can surface them in place of the kernel bind address. advertisedUrlWatcher.ingest(ptyId, data, at) serveSimStateWatcher.ingestPtyOutput(ptyId, data) + // Why: reply ownership is captured per chunk, here at ingestion — the + // same module state and tick as the hidden-gate drop sites — and rides + // the writeChain link. A mark/setting/subscriber flip before the queued + // emulator write runs must not change who answers (terminal-query- + // authority.md invariant 1). + const forwardQueryReplies = this.shouldAnswerQueriesForLiveChunk(ptyId) // Ordering invariant (DO NOT REORDER): maybeHydrateHeadlessFromRenderer // MUST run before trackHeadlessTerminalData so the eager-state pattern // (set headlessTerminals + writeChain head = seedPromise) is in place @@ -5418,24 +5541,14 @@ export class OrcaRuntimeService { // that the later seed-resolve would overwrite, dropping the live byte. // See docs/mobile-prefer-renderer-scrollback.md. this.maybeHydrateHeadlessFromRenderer(ptyId) - this.trackHeadlessTerminalData(ptyId, data, outputSequence) - - // Why: extract OSC title from raw PTY data before tail-buffer processing - // strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.) - // announce status via OSC 0/1/2 title sequences — this is the same - // detection path the renderer uses for notifications and sidebar badges. - const rawOscTitle = this.extractLastOscTitleForPty(ptyId, data) - // Why: collapse high-churn agent titles (Grok/Pi spinner frames, Gemini - // per-keystroke updates) once at the observation boundary so lastOscTitle — - // and the mobile session-tab titles/snapshots derived from it — stays - // stable instead of changing every animation frame. Status is detected - // from the raw title (mirroring the renderer's tracker) so working/idle - // transitions are unaffected by normalization. - const oscTitle = rawOscTitle === null ? null : normalizeTerminalTitle(rawOscTitle) - const agentStatus = rawOscTitle ? detectAgentStatusFromTitle(rawOscTitle) : null + // Our structure wins: OSC title/agent-status extraction runs through the + // shared per-PTY title tracker below (getOrCreatePtyTitleTrackerEntry → + // applyTrackedPtyTitle) in byte order, superseding main's inline + // extractLastOscTitleForPty block (#7880/#7852 title/status semantics are + // preserved via the tracker + detectAgentStatusFromTitle path). + this.trackHeadlessTerminalData(ptyId, data, outputSequence, forwardQueryReplies) const pty = this.getOrCreatePtyWorktreeRecord(ptyId) - let shouldTouchPtyBackedSessionTabs = false const ptyTailBefore = pty ? { lines: pty.tailBuffer, @@ -5453,28 +5566,12 @@ export class OrcaRuntimeService { pty.lastOutputAt = at const normalized = normalizeTerminalChunk(data, pty.tailPendingAnsi) pty.tailPendingAnsi = normalized.pendingAnsi - // Why: the prior chunk's post-append tail is this chunk's pre-append tail, - // so its cached wait scan is exact — reuse it instead of rebuilding and - // rescanning the full tail. Only a tail-derived state is safe to reuse. - const previousWaitState = - pty.tailWaitState?.fromTail === true - ? pty.tailWaitState - : computeTerminalTailWaitState(pty.tailBuffer, pty.tailPartialLine, pty.preview) const nextTail = appendNormalizedToTailBuffer( pty.tailBuffer, pty.tailPartialLine, normalized.text, pty.tailRedrawCursor ) - const nextWaitState = computeTerminalTailWaitState( - nextTail.lines, - nextTail.partialLine, - pty.preview - ) - if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, normalized.text)) { - pty.waitBlockedAt = at - } - pty.tailWaitState = nextWaitState ptyTailAfter = nextTail pty.tailBuffer = nextTail.lines pty.tailPartialLine = nextTail.partialLine @@ -5482,40 +5579,7 @@ export class OrcaRuntimeService { pty.tailTruncated = pty.tailTruncated || nextTail.truncated pty.tailLinesTotal += nextTail.newCompleteLines pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine) - if (oscTitle !== null) { - const prevStatus = pty.lastAgentStatus - const prevTitle = pty.lastOscTitle - const observedAt = this.nextTitleObservationSequence() - pty.lastOscTitle = oscTitle - pty.lastOscTitleAt = observedAt - pty.lastAgentStatus = agentStatus - this.setPtyManagementTitleFromObservedTitle(pty, oscTitle, observedAt) - shouldTouchPtyBackedSessionTabs = - prevTitle !== oscTitle || prevStatus !== pty.lastAgentStatus - if (agentStatus === 'idle' && prevStatus !== 'idle') { - this.resolvePtyTuiIdleWaiters(pty, ptyId) - } - const shouldDelayMobileSnapshot = - shouldTouchPtyBackedSessionTabs && - this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, oscTitle) - let foregroundRefresh: Promise | undefined - // Why: gate on an actual status transition — braille spinner frames - // mutate the title every tick, so probing per-title-change would stream - // a foreground query per frame during active work. - if (prevStatus !== pty.lastAgentStatus) { - foregroundRefresh = this.refreshPtyForegroundAgentFromController(ptyId, { - afterTitleObservation: observedAt - }) - } else if (shouldDelayMobileSnapshot) { - // Why: same-status compatible title changes can arrive before the - // foreground owner probe settles; publishing them would flicker. - foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt) - } - if (foregroundRefresh && shouldDelayMobileSnapshot) { - shouldTouchPtyBackedSessionTabs = false - this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) - } - } + this.scheduleWaitBlockedCheck(ptyId, normalized.text, at) } for (const leaf of this.getLeavesForPty(ptyId)) { @@ -5553,7 +5617,10 @@ export class OrcaRuntimeService { leaf.tailLinesTotal = pty.tailLinesTotal leaf.preview = pty.preview leaf.waitBlockedAt = pty.waitBlockedAt - // Why: the leaf mirrors the PTY tail here, so share the cached wait scan. + // Why undefined on this branch: the PTY record's wait scan is throttled + // (scheduleWaitBlockedCheck), so pty.tailWaitState is never populated; + // copying it here intentionally invalidates the leaf cache and the + // mismatch branch below recomputes an exact state on its next chunk. leaf.tailWaitState = pty.tailWaitState } else { const normalized = normalizeTerminalChunk(data, leaf.tailPendingAnsi) @@ -5584,42 +5651,54 @@ export class OrcaRuntimeService { leaf.tailLinesTotal += nextTail.newCompleteLines leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) } - - if (oscTitle !== null) { - // Why: keep the latest OSC title on the leaf so worktree.ps can - // recompute status from the live title each call. Without this, - // daemon-hosted terminals (no renderer pushing pane titles) had no - // way to clear a stale 'working' status after the agent exited and - // the shell took over the title — the stuck-spinner bug in #1437. - leaf.lastOscTitle = oscTitle - leaf.lastOscTitleAt = this.nextTitleObservationSequence() - const prevStatus = leaf.lastAgentStatus - // Why: when a new OSC title doesn't classify as an agent state (e.g. - // bare shell title after the agent exits), clear lastAgentStatus so - // it is no longer sticky. Tui-idle waiters that needed the previous - // 'idle' transition were already resolved at the moment of the - // transition below; only fresh waiters registered after the agent - // exits would observe the cleared value, and they correctly fall - // back to title-based detection / polling. - leaf.lastAgentStatus = agentStatus - // Why: resolve tui-idle on any transition TO idle (not just working→idle). - // Claude Code may skip "working" entirely on fast tasks, going null→idle, - // and the coordinator's tui-idle waiter would hang forever waiting for a - // working→idle transition that never comes. Permission→idle is excluded: - // it means the agent was blocked on user approval and the user said no, - // which isn't a task-completion signal. - if (agentStatus === 'idle' && prevStatus !== 'idle') { - this.resolveTuiIdleWaiters(leaf) - this.deliverPendingMessages(leaf) - } - } } - const retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + // Why: feed the chunk's OSC titles through the shared per-PTY tracker in + // byte order — the same ordering the renderer transport uses — so + // coalesced working→idle transitions reach tui-idle waiters and + // pending-message delivery instead of being masked by the chunk's last + // title (issue #1083). Uses the OSC 9999-stripped cleanData like the + // renderer, so pure status chunks don't perturb the stale-title probe. + const titleTrackerEntry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + const previousTitleScanTail = this.oscTitleScanTailByPtyId.get(ptyId) + const titleInput = previousTitleScanTail + ? `${previousTitleScanTail}${agentStatusChunk.cleanData}` + : agentStatusChunk.cleanData + const nextTitleScanTail = extractOscTitleScanTail(titleInput) + if (nextTitleScanTail.length > 0) { + this.oscTitleScanTailByPtyId.set(ptyId, nextTitleScanTail) + } else { + this.oscTitleScanTailByPtyId.delete(ptyId) + } + titleTrackerEntry.applyingChunk = true + titleTrackerEntry.chunkTouchedSessionTabs = false + let retainedAgentStatusChanged = false + try { + titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData, { + titleScanData: titleInput + }) + // Why: the Command Code scrape rides the same per-chunk batch (its facts + // trail the tracker's). cleanData keeps OSC 9999 payloads out of the + // detector's bounded recent-text window; the detector strips remaining + // control sequences itself, exactly like the renderer byte path. + titleTrackerEntry.commandCodeDetector?.observe(agentStatusChunk.cleanData) + } finally { + titleTrackerEntry.applyingChunk = false + try { + // Why: per-chunk cross-channel contract order is status → titles → + // bell — the chunk's agentStatus:set events must reach the renderer + // before its pty:sideEffect batch. + retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + } finally { + // Why: flushed in the finally so a throwing tracker callback cannot + // strand this chunk's facts to be emitted under the next chunk's seq. + this.flushPendingTerminalSideEffectFacts(ptyId, titleTrackerEntry) + } + } // Why: hook (OSC 9999) transitions often arrive without a title change, so // headless-serve snapshots would never republish and paired remote clients // kept the stale agent state until the next title change (#7970). - if (shouldTouchPtyBackedSessionTabs || retainedAgentStatusChanged) { + if (titleTrackerEntry.chunkTouchedSessionTabs || retainedAgentStatusChanged) { this.touchMobileSessionSnapshotsForPty(ptyId) } @@ -5637,6 +5716,80 @@ export class OrcaRuntimeService { return outputSequence } + private scheduleWaitBlockedCheck(ptyId: string, appendedText: string, at: number): void { + let state = this.waitBlockedCheckStateByPtyId.get(ptyId) + if (!state) { + state = { lastAt: 0, lastWaitState: null, appended: '', keywordCarry: '', timer: null } + this.waitBlockedCheckStateByPtyId.set(ptyId, state) + } + const appendedLower = appendedText.toLowerCase() + const keywordHit = WAIT_BLOCKED_KEYWORD_PATTERN.test(`${state.keywordCarry}${appendedLower}`) + state.keywordCarry = appendedLower.slice(-WAIT_BLOCKED_KEYWORD_CARRY_CHARS) + // Why the cap keeps the tail: the accumulated text only anchors boundary- + // spanning prompt detection; anything past the tail cap has scrolled out + // of the retained tail the check reads anyway. + state.appended = + state.appended.length + appendedText.length > MAX_TAIL_CHARS + ? `${state.appended}${appendedText}`.slice(-MAX_TAIL_CHARS) + : `${state.appended}${appendedText}` + const elapsed = at - state.lastAt + if (keywordHit || elapsed >= WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS || elapsed < 0) { + this.runWaitBlockedCheck(ptyId, state, at) + return + } + if (!state.timer) { + // Why trailing edge: the final chunks of a burst must still be + // evaluated or a prompt arriving right after a flood would go + // unstamped until the next output. + state.timer = setTimeout(() => { + state.timer = null + this.runWaitBlockedCheck(ptyId, state, Date.now()) + }, WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS - elapsed) + } + } + + private runWaitBlockedCheck( + ptyId: string, + state: { + lastAt: number + lastWaitState: TerminalTailWaitState | null + appended: string + keywordCarry: string + timer: ReturnType | null + }, + at: number + ): void { + const pty = this.ptysById.get(ptyId) + if (!pty) { + state.appended = '' + return + } + const nextWaitState = computeTerminalTailWaitState( + pty.tailBuffer, + pty.tailPartialLine, + pty.preview + ) + const previousWaitState = state.lastWaitState ?? { + waitText: '', + signal: null, + fromTail: false + } + if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, state.appended)) { + pty.waitBlockedAt = at + } + state.lastAt = at + state.lastWaitState = nextWaitState + state.appended = '' + } + + private clearWaitBlockedCheckState(ptyId: string): void { + const state = this.waitBlockedCheckStateByPtyId.get(ptyId) + if (state?.timer) { + clearTimeout(state.timer) + } + this.waitBlockedCheckStateByPtyId.delete(ptyId) + } + private processAgentStatusOscForPty(ptyId: string, data: string): ProcessedAgentStatusChunk { let processor = this.agentStatusOscProcessorsByPtyId.get(ptyId) if (!processor) { @@ -5646,19 +5799,439 @@ export class OrcaRuntimeService { return processor(data) } - private extractLastOscTitleForPty(ptyId: string, data: string): string | null { - const previousTail = this.oscTitleScanTailByPtyId.get(ptyId) - if (!previousTail && !data.includes('\x1b')) { + /** Emit the facts batched while applying one chunk/frame as a single + * pty:sideEffect batch, preserving byte order. */ + private flushPendingTerminalSideEffectFacts( + ptyId: string, + entry: RuntimePtyTitleTrackerEntry + ): void { + if (entry.pendingFacts.length === 0) { + return + } + const facts = entry.pendingFacts + entry.pendingFacts = [] + this.emitTerminalSideEffectBatch(ptyId, facts) + } + + /** Feed a main-fabricated OSC title/BEL frame (agent hook spinners) through + * the per-PTY tracker — NOT onPtyData, so emulator state, tails, + * transcripts, and stats never see synthetic bytes. Parsed via the + * tracker's stateless synthetic path: the shared chunk bell detector must + * never observe fabricated bytes, or a tick interleaved with a split real + * OSC corrupts its escape state (phantom/swallowed bells). While the + * side-effect kill switch is off the legacy pty:data copy still drives + * renderer parsers; this ingest keeps main's facts and records + * authoritative. */ + ingestSyntheticTitleFrame(ptyId: string, data: string): void { + const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + entry.applyingChunk = true + entry.applyingSyntheticFrame = true + entry.chunkTouchedSessionTabs = false + try { + entry.tracker.applySyntheticTitleFrame(data) + } finally { + entry.applyingChunk = false + entry.applyingSyntheticFrame = false + this.flushPendingTerminalSideEffectFacts(ptyId, entry) + } + if (entry.chunkTouchedSessionTabs) { + this.touchMobileSessionSnapshotsForPty(ptyId) + } + } + + /** Scan-authority handoff for a backgrounded PTY (daemon keep-tail + * thinning): while delegated, the daemon relays bell/133/pr-link/2031 + * facts itself and the delivered bytes may be gapped — feeding them to + * main's transient scanners would mint phantom or duplicate facts. Title + * processing stays main-side either way. */ + setPtyTransientFactDelegation(ptyId: string, delegated: boolean, scanSeedAnsi?: string): void { + const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + entry.tracker.setTransientFactScanningSuppressed(delegated) + if (!delegated && scanSeedAnsi) { + // Prime the freshly reset scanner carry with the emulator's dangling + // incomplete escape at the handoff position — a sequence split across + // the un-background toggle must not mint a phantom bell or lose its + // fact. titleScanData:'' keeps titles out (they were never suppressed). + entry.tracker.handleChunk(scanSeedAnsi, { titleScanData: '' }) + } + } + + /** A transient fact the daemon detected while it held scan authority — + * emitted through the same fact channel as byte-scanned facts. Arrives + * between chunks, so recordTerminalSideEffectFact emits it immediately. */ + emitDaemonPtyTransientFact(ptyId: string, fact: PtyTransientFact): void { + switch (fact.kind) { + case 'bell': + this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) + return + case 'command-finished': + this.recordTerminalSideEffectFact(ptyId, { + kind: 'command-finished', + exitCode: fact.exitCode + }) + return + case 'pr-link': + this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link: fact.link }) + return + case '2031-subscribe': + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + } + } + + /** The daemon keep-tail dropped this PTY's oldest undelivered output; the + * next delivered chunk is discontinuous. Reset every cross-chunk parse + * carry so a half-open escape from before the gap cannot corrupt what + * follows, and drop the mobile headless mirror — it rebuilds from the + * delivered tail / snapshot seeds instead of parsing a gapped stream. */ + notePtyDataGap(ptyId: string, droppedChars = 0): void { + if (droppedChars > 0) { + // Why: the daemon snapshot's seq counts bytes its monitoring stream + // dropped. Advancing without parsing preserves that absolute domain so + // post-snapshot live chunks can be reconciled instead of duplicated. + const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + droppedChars + this.ptyOutputSequenceById.set(ptyId, outputSequence) + } + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + if (pty) { + pty.tailPendingAnsi = '' + } + for (const leaf of this.getLeavesForPty(ptyId)) { + leaf.tailPendingAnsi = '' + } + this.oscTitleScanTailByPtyId.delete(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.disposeHeadlessTerminal(ptyId) + } + + /** Record one derived side-effect fact: batched per chunk while applying + * bytes, emitted immediately for between-chunk facts (stale-title timer). */ + private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void { + if (!this.onTerminalSideEffects) { + return + } + const entry = this.ptyTitleTrackersByPtyId.get(ptyId) + if (entry?.applyingChunk) { + entry.pendingFacts.push(fact) + return + } + this.emitTerminalSideEffectBatch(ptyId, [fact]) + } + + private emitTerminalSideEffectBatch( + ptyId: string, + facts: TerminalSideEffectFact[], + options: { replay?: boolean } = {} + ): void { + if (!this.onTerminalSideEffects || facts.length === 0) { + return + } + const batch: TerminalSideEffectBatch = { + ptyId, + seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, + facts, + ...(options.replay ? { replay: true } : {}), + ...this.resolveTerminalSideEffectAttribution(ptyId) + } + try { + this.onTerminalSideEffects(batch) + } catch (err) { + console.error('[runtime] terminal side-effect listener threw', { ptyId, err }) + } + } + + /** Same attribution resolution as emitTerminalAgentStatusEvents: prefer the + * first mounted leaf, fall back to the spawn-time PTY record binding. */ + private resolveTerminalSideEffectAttribution(ptyId: string): { + worktreeId?: string + tabId?: string + paneKey?: string + connectionId?: string | null + } { + const pty = this.ptysById.get(ptyId) + const connectionId = pty?.connectionId ?? null + for (const leaf of this.getLeavesForPty(ptyId)) { + return { + worktreeId: leaf.worktreeId, + tabId: leaf.tabId, + paneKey: this.makeRuntimePaneKey(leaf), + connectionId + } + } + if (pty?.paneKey) { + return { + worktreeId: pty.worktreeId, + ...(pty.tabId ? { tabId: pty.tabId } : {}), + paneKey: pty.paneKey, + connectionId + } + } + return {} + } + + /** Title-only replay batch for renderer (re)attach — the no-attention-replay + * rule: snapshots restore title state, never historical bells/completions. */ + getTerminalSideEffectSnapshot(ptyId: string): TerminalSideEffectBatch | null { + const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker + const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle + // Why: the cursor-agent literal drop applies to every title surface; a + // record-fallback snapshot must not replay the bare native title the + // tracker would have refused to emit live. + const rawTitle = recordTitle && !isCursorNativeAgentTitle(recordTitle) ? recordTitle : null + const normalizedTitle = tracker?.getLastNormalizedTitle() ?? null + if (normalizedTitle === null && !rawTitle) { return null } - const input = `${previousTail ?? ''}${data}` - const scanTail = extractOscTitleScanTail(input) - if (scanTail.length > 0) { - this.oscTitleScanTailByPtyId.set(ptyId, scanTail) - } else { - this.oscTitleScanTailByPtyId.delete(ptyId) + return { + ptyId, + seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, + replay: true, + facts: [ + { + kind: 'title', + normalizedTitle: normalizedTitle ?? normalizeTerminalTitle(rawTitle!), + rawTitle: rawTitle ?? normalizedTitle! + } + ], + ...this.resolveTerminalSideEffectAttribution(ptyId) } - return extractLastOscTitle(input) + } + + /** Raw last title from main's tracked PTY/leaf records — the title surface + * the tracker (live bytes + synthetic frames) keeps current. */ + private getTrackedRawTitleForPty(ptyId: string): string | null { + const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle + if (recordTitle) { + return recordTitle + } + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastOscTitle) { + return leaf.lastOscTitle + } + } + return null + } + + /** Why: synthetic agent title frames no longer ride pty:data, so neither + * renderer xterm nor the headless emulator observes them. Mobile-parity + * snapshot titles must prefer main's tracker over snapshot lastTitle, or + * hook-driven spinner/idle titles vanish from mobile tabs. */ + private preferTrackedLastTitle(ptyId: string, snapshot: T): T { + const tracked = this.getTrackedRawTitleForPty(ptyId) + if (!tracked) { + return snapshot + } + return { ...snapshot, lastTitle: tracked } + } + + /** Decorative comparison key: spinner frame glyphs stripped, derived agent + * status kept so a working→idle flip with an otherwise-equal label still + * counts as a change. */ + private makeMobileTitleGateKey(rawTitle: string, normalizedTitle: string): string { + return `${detectAgentStatusFromTitle(rawTitle) ?? ''}\u0000${stripBrailleSpinnerGlyphs( + normalizedTitle + )}` + } + + private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry { + const existing = this.ptyTitleTrackersByPtyId.get(ptyId) + if (existing) { + return existing + } + // Why: trackers are created lazily on the first observed chunk. After an + // app relaunch the PTY/leaf records can already hold a persisted title; a + // cold tracker would miss the parked working→idle completion and never + // arm the stale-title timer for a persisted 'working' title. + let initialTitle = this.ptysById.get(ptyId)?.lastOscTitle ?? null + if (initialTitle === null) { + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastOscTitle) { + initialTitle = leaf.lastOscTitle + break + } + } + } + const tracker = createTerminalTitleTracker( + { + onTitle: (normalizedTitle, rawTitle, meta) => { + this.recordTerminalSideEffectFact(ptyId, { + kind: 'title', + normalizedTitle, + rawTitle, + ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) + const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle) + if (!changed) { + return + } + const live = this.ptyTitleTrackersByPtyId.get(ptyId) + const gateKey = this.makeMobileTitleGateKey(rawTitle, normalizedTitle) + const decorativeOnly = live?.lastMobileTitleGateKey === gateKey + if (live) { + live.lastMobileTitleGateKey = gateKey + } + if (live?.applyingChunk) { + // Why: synthetic spinner ticks change only the braille glyph + // ~12.5x/sec; fanning out full mobile session snapshots per frame + // is pure churn. Raw lastOscTitle updates above stay cheap. + if (!(live.applyingSyntheticFrame && decorativeOnly)) { + live.chunkTouchedSessionTabs = true + } + } else { + // Stale-working-title timer path — fires between chunks, so the + // per-chunk batching in onPtyData cannot pick it up. + this.touchMobileSessionSnapshotsForPty(ptyId) + } + }, + // Why: agent transitions and bells become pty:sideEffect facts — + // main is the single byte parser for local/SSH PTYs; the renderer + // store handler decides what the facts mean (notification policy). + onAgentBecameWorking: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-working' }) + }, + onAgentBecameIdle: (title, meta) => { + this.recordTerminalSideEffectFact(ptyId, { + kind: 'agent-idle', + title, + ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) + }, + onAgentExited: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + }, + // Why: bell/command-finished/pr-link/2031 facts exist only for the + // pty:sideEffect channel. Headless serve has no consumer, so skip the + // per-chunk bell walk and 133/URL/2031 scans entirely. + ...(this.onTerminalSideEffects + ? { + onBell: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) + }, + onCommandFinished: (exitCode: number | null) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) + }, + onPrLink: (link: TerminalGitHubPRLink) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) + }, + // Why: hidden-delivery-gated views never see the bytes, so main + // surfaces DECSET 2031 subscribes as facts; the theme reply is + // still sent by the renderer (query authority stays with the view). + onMode2031Subscribe: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + } + } + : {}) + }, + initialTitle !== null ? { initialTitle } : {} + ) + const entry: RuntimePtyTitleTrackerEntry = { + tracker, + applyingChunk: false, + applyingSyntheticFrame: false, + lastMobileTitleGateKey: null, + chunkTouchedSessionTabs: false, + pendingFacts: [], + // Why: command-code facts exist only for the pty:sideEffect channel — + // headless serve skips the per-chunk scrape entirely. The detector + // self-arms on the Command Code banner; the spawn command (when main + // saw one) mirrors the renderer detector's startupCommand fast-arm. + commandCodeDetector: this.onTerminalSideEffects + ? createCommandCodeOutputStatusDetector({ + startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, + onWorking: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt }) + }, + onDone: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt }) + } + }) + : null + } + this.ptyTitleTrackersByPtyId.set(ptyId, entry) + return entry + } + + /** Apply one observed OSC title (raw form) to the PTY and leaf records. + * Returns true when the PTY record's title or status changed. */ + private applyTrackedPtyTitle(ptyId: string, rawTitle: string, normalizedTitle: string): boolean { + // Why: status is detected from the RAW title (mirrors the renderer tracker), + // so working/idle transitions are unaffected by normalization; the records + // store the NORMALIZED title so rotating Grok/Pi/Gemini frames collapse to + // one stable stored label (#7880) instead of churning `ps`/mobile tabs. + const agentStatus = detectAgentStatusFromTitle(rawTitle) + let ptyRecordChanged = false + const pty = this.ptysById.get(ptyId) + if (pty) { + const prevStatus = pty.lastAgentStatus + const prevTitle = pty.lastOscTitle + const observedAt = this.nextTitleObservationSequence() + pty.lastOscTitle = normalizedTitle + pty.lastOscTitleAt = observedAt + pty.lastAgentStatus = agentStatus + this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt) + ptyRecordChanged = prevTitle !== normalizedTitle || prevStatus !== agentStatus + if (agentStatus === 'idle' && prevStatus !== 'idle') { + this.resolvePtyTuiIdleWaiters(pty, ptyId) + } + const shouldDelayMobileSnapshot = + ptyRecordChanged && + this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, normalizedTitle) + let foregroundRefresh: Promise | undefined + // Why: gate on an actual status transition — braille spinner frames + // mutate the title every tick, so probing per-title-change would stream + // a foreground query per frame during active work. + if (prevStatus !== agentStatus) { + foregroundRefresh = this.refreshPtyForegroundAgentFromController(ptyId, { + afterTitleObservation: observedAt + }) + } else if (shouldDelayMobileSnapshot) { + // Why: same-status compatible title changes can arrive before the + // foreground owner probe settles; publishing them would flicker. + foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt) + } + if (foregroundRefresh && shouldDelayMobileSnapshot) { + // Why: report "unchanged" so the per-chunk batch skips the mobile + // snapshot fan-out; the delayed publish fires when the probe settles. + ptyRecordChanged = false + this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) + } + } + for (const leaf of this.getLeavesForPty(ptyId)) { + // Why: keep the latest OSC title on the leaf so worktree.ps can + // recompute status from the live title each call. Without this, + // daemon-hosted terminals (no renderer pushing pane titles) had no + // way to clear a stale 'working' status after the agent exited and + // the shell took over the title — the stuck-spinner bug in #1437. + leaf.lastOscTitle = normalizedTitle + leaf.lastOscTitleAt = this.nextTitleObservationSequence() + const prevStatus = leaf.lastAgentStatus + // Why: when a new OSC title doesn't classify as an agent state (e.g. + // bare shell title after the agent exits), clear lastAgentStatus so + // it is no longer sticky. Tui-idle waiters that needed the previous + // 'idle' transition were already resolved at the moment of the + // transition below; only fresh waiters registered after the agent + // exits would observe the cleared value, and they correctly fall + // back to title-based detection / polling. + leaf.lastAgentStatus = agentStatus + // Why: resolve tui-idle on any transition TO idle (not just working→idle). + // Claude Code may skip "working" entirely on fast tasks, going null→idle, + // and the coordinator's tui-idle waiter would hang forever waiting for a + // working→idle transition that never comes. Permission→idle is excluded: + // it means the agent was blocked on user approval and the user said no, + // which isn't a task-completion signal. + if (agentStatus === 'idle' && prevStatus !== 'idle') { + this.resolveTuiIdleWaiters(leaf) + this.deliverPendingMessages(leaf) + } + } + return ptyRecordChanged + } + + /** Cancel the per-PTY title tracker (stale-title timer included) on PTY + * teardown so it cannot fire into pruned records. */ + private disposePtyTitleTracker(ptyId: string): void { + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose() + this.ptyTitleTrackersByPtyId.delete(ptyId) } private extractLastOsc7CwdForPty( @@ -5845,6 +6418,53 @@ export class OrcaRuntimeService { return addListenerToMap(this.dataListeners, ptyId, listener) } + /** Set by pty IPC: fires when a PTY gains/loses remote view subscribers so + * the daemon background mark (keep-tail stream thinning) can resync — a + * live mobile/web view consumes raw bytes and must never be thinned, even + * while the desktop pane is hidden. */ + onRemoteTerminalViewPresenceChanged: ((ptyId: string) => void) | null = null + + private notifyRemoteTerminalViewPresenceChanged(ptyId: string): void { + try { + this.onRemoteTerminalViewPresenceChanged?.(ptyId) + } catch (err) { + console.error('[runtime] remote view presence listener threw', { ptyId, err }) + } + } + + /** Registered by terminal-RPC subscribe/multiplex streams: while a remote + * view subscriber is attached its xterm answers queries with view + * authority and the model responder must stay silent. Returns an + * idempotent release. */ + registerRemoteTerminalViewSubscriber(ptyId: string): () => void { + this.remoteTerminalViewSubscriberCounts.set( + ptyId, + (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) + 1 + ) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + let released = false + return () => { + if (released) { + return + } + released = true + const next = (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 1) - 1 + if (next <= 0) { + this.remoteTerminalViewSubscriberCounts.delete(ptyId) + } else { + this.remoteTerminalViewSubscriberCounts.set(ptyId, next) + } + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + } + } + + hasRemoteTerminalViewSubscriber(ptyId: string): boolean { + if ((this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0) { + return true + } + return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0 + } + subscribeToFitOverrideChanges( ptyId: string, listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void @@ -5884,6 +6504,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string } | null> { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) @@ -5902,6 +6523,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + scrollbackAnsi?: string } | null> { return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) } @@ -5919,6 +6541,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, { @@ -5986,17 +6609,22 @@ export class OrcaRuntimeService { return } const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state: RuntimeHeadlessTerminal = { - emulator: this.createHeadlessEmulator(ptyId, { cols: dims.cols, rows: dims.rows }), - outputSequence: 0, - writeChain: Promise.resolve() - } + const state = this.createPtyHeadlessTerminalState(ptyId, dims) this.headlessTerminals.set(ptyId, state) this.recordOsc7MetadataForPty(ptyId, data) this.recordRecentPtyOutputForPathProvenance(ptyId, data) state.writeChain = state.writeChain .then(async () => { + // Why: seed writes never set forwardQueryReplies — the main-side + // replay guard. A snapshot containing old queries must answer no one. await state.emulator.write(data) + // Why AFTER the seed write: the snapshot payload cannot carry kitty + // pushes (rehydrateSequences deliberately omits them), but ordering + // behind it keeps the parse deterministic. Unflagged like the seed — + // re-applying flags must answer no one. + if (typeof metadata.kittyKeyboardFlags === 'number') { + await state.emulator.applyKittyKeyboardFlags(metadata.kittyKeyboardFlags) + } if (metadata.cwd !== undefined) { state.emulator.setCwd(metadata.cwd) } @@ -6037,11 +6665,9 @@ export class OrcaRuntimeService { this.headlessHydrationState.set(ptyId, 'pending') const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state: RuntimeHeadlessTerminal = { - emulator: this.createHeadlessEmulator(ptyId, { cols: dims.cols, rows: dims.rows }), - outputSequence: 0, - writeChain: Promise.resolve() - } + // Why: hydration writes below never set forwardQueryReplies (main-side + // replay guard) — renderer-buffer snapshots can embed stale queries. + const state = this.createPtyHeadlessTerminalState(ptyId, dims) this.headlessTerminals.set(ptyId, state) // Why: append the seed work to writeChain so live writes queued by @@ -6071,9 +6697,14 @@ export class OrcaRuntimeService { if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) { state.emulator.resize(ptyDims.cols, ptyDims.rows) } - if (rendered.lastTitle) { - state.emulator.setLastTitle(rendered.lastTitle) - this.applySeededAgentStatus(ptyId, rendered.lastTitle) + // Why: the renderer xterm no longer sees synthetic hook title frames + // (they feed main's tracker only), so its serializer lastTitle can be + // stale here. Prefer main's tracked title; the renderer's is only the + // seed when main has observed none (fresh relaunch, cold tracker). + const seedTitle = this.getTrackedRawTitleForPty(ptyId) ?? rendered.lastTitle + if (seedTitle) { + state.emulator.setLastTitle(seedTitle) + this.applySeededAgentStatus(ptyId, seedTitle) } } catch { // Hydration is best-effort. Live writes continue via the same @@ -6094,6 +6725,11 @@ export class OrcaRuntimeService { if (!title) { return } + // Why: a relaunched main starts its per-PTY title tracker cold — without + // this seed it misses the parked working→idle completion and never arms + // the stale-title timer for a persisted 'working' title. Seeding no-ops + // once a live title was observed, so live state always wins. + this.getOrCreatePtyTitleTrackerEntry(ptyId).tracker.seedInitialTitle(title) const status = detectAgentStatusFromTitle(title) // Why: live observations store normalized titles, so seeds must match — // otherwise the first live frame after hydration compares unequal and @@ -6118,11 +6754,28 @@ export class OrcaRuntimeService { } } - private trackHeadlessTerminalData(ptyId: string, data: string, outputSequence: number): void { + /** Per-chunk reply-ownership capture (Phase 5). Evaluated synchronously at + * ingestion only — never re-read at reply time. */ + private shouldAnswerQueriesForLiveChunk(ptyId: string): boolean { + return shouldModelAnswerHiddenPtyQueries({ + ptyId, + settings: this.store?.getSettings(), + hasRemoteViewSubscriber: this.hasRemoteTerminalViewSubscriber(ptyId) + }) + } + + private trackHeadlessTerminalData( + ptyId: string, + data: string, + outputSequence: number, + forwardQueryReplies = false + ): void { const state = this.getOrCreateHeadlessTerminal(ptyId) state.writeChain = state.writeChain .then(async () => { - await state.emulator.write(data) + // Why: the ingestion-time ownership decision is closed over this + // chain link; async scheduling cannot retroactively change it. + await state.emulator.write(data, { forwardQueryReplies }) state.outputSequence = outputSequence }) .catch(() => { @@ -6131,17 +6784,69 @@ export class OrcaRuntimeService { }) } + /** Shared factory for the per-PTY runtime emulators (seed, hydration, and + * lazy live-byte creation): wires the Phase-5 query-reply sink and the + * ConPTY DA1 override. The daemon emulator never goes through here. */ + private createPtyHeadlessTerminalState( + ptyId: string, + dims: { cols: number; rows: number } + ): RuntimeHeadlessTerminal { + let state: RuntimeHeadlessTerminal | null = null + const pathFlavor = this.pathFlavorForPty(this.ptysById.get(ptyId)) + const emulator = new HeadlessEmulator({ + cols: dims.cols, + rows: dims.rows, + pathFlavor, + remotePosixFileUriAuthority: + !!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32', + // Why: replies take the provider input path (same entry as pty:write — + // daemon shell-ready gating and the SSH relay write apply unchanged), + // NOT writePtyInput, so renderer interactive-output metering never + // counts responder traffic as user-input echo. + onQueryReply: (reply) => { + // Why the identity check: queued writeChain links can parse after + // disposeHeadlessTerminal, and daemon respawns reuse session ids — a + // stale link's reply must never reach a successor PTY under this id. + if (state !== null && this.headlessTerminals.get(ptyId) === state) { + // Why this write is safe pre-shell-ready: daemon Session.write + // QUEUES (never drops) input while the POSIX shell-ready gate is + // pending and flushes at the ready marker or the 15s + // SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query + // reply is delayed at most that bound, not lost. + this.ptyController?.write(ptyId, reply) + } + } + }) + if (isNativeWindowsConptyPty(ptyId)) { + emulator.installConptyPrimaryDeviceAttributesOverride() + } + // Why the lazy getter: replies must use the freshest renderer push at + // parse time, and stay silent (never default) before the first push. + emulator.installViewAttributeResponder(() => getTerminalViewAttributes()) + const viewAttributes = getTerminalViewAttributes() + if (viewAttributes) { + emulator.applyPushedViewAttributes(viewAttributes) + } + state = { emulator, outputSequence: 0, writeChain: Promise.resolve() } + return state + } + + /** Phase-5 ConPTY DA1 retrofit (terminal-query-authority.md): invoked via + * markNativeWindowsConptyPty when the spawn mark lands after daemon stream + * data already created this PTY's emulator. Idempotent emulator-side. */ + private ensureNativeWindowsConptyDa1Override(ptyId: string): void { + if (isNativeWindowsConptyPty(ptyId)) { + this.headlessTerminals.get(ptyId)?.emulator.installConptyPrimaryDeviceAttributesOverride() + } + } + private getOrCreateHeadlessTerminal(ptyId: string): RuntimeHeadlessTerminal { const existing = this.headlessTerminals.get(ptyId) if (existing) { return existing } const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state: RuntimeHeadlessTerminal = { - emulator: this.createHeadlessEmulator(ptyId, { cols: size.cols, rows: size.rows }), - outputSequence: 0, - writeChain: Promise.resolve() - } + const state = this.createPtyHeadlessTerminalState(ptyId, size) this.headlessTerminals.set(ptyId, state) return state } @@ -6164,19 +6869,6 @@ export class OrcaRuntimeService { }) } - private createHeadlessEmulator( - ptyId: string, - options: Omit - ): HeadlessEmulator { - const pathFlavor = this.pathFlavorForPty(this.ptysById.get(ptyId)) - return new HeadlessEmulator({ - ...options, - pathFlavor, - remotePosixFileUriAuthority: - !!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32' - }) - } - // Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile // mirror or a resubscribing mobile client resurrects the cleared scrollback. async clearHeadlessTerminalBuffer(ptyId: string): Promise { @@ -6248,11 +6940,11 @@ export class OrcaRuntimeService { // their existing null fallback paths. } return rendererSnapshot - ? { + ? this.preferTrackedLastTitle(ptyId, { ...rendererSnapshot, cwd: rendererSnapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), - source: 'renderer' - } + source: 'renderer' as const + }) : null } @@ -6322,9 +7014,10 @@ export class OrcaRuntimeService { source?: 'headless' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + scrollbackAnsi?: string // Why: dangling mid-escape tail the restorer must write LAST, after any // reset, so the next live chunk completes it instead of rendering it - // literally (#7329). + // literally (Bug E / #7329). pendingEscapeTailAnsi?: string } | null> { const state = this.headlessTerminals.get(ptyId) @@ -6332,36 +7025,35 @@ export class OrcaRuntimeService { return null } await state.writeChain - // Why: when an alternate-screen TUI (Claude Code, vim, etc.) is currently - // active, the visible content is the alt-screen snapshot — replaying any - // normal-buffer scrollback before it can duplicate shell prompts and - // flatten SGR attributes when the mobile xterm replays the data. Force - // scrollbackRows=0 in that case. When the buffer is in normal mode the - // caller can request scrollback so the user can scroll up to see prior - // agent output. - const requested = opts.scrollbackRows ?? 0 + // Why: normal history is separated from an active alternate frame, so the + // caller's scrollback policy can be honored without painting it into alt. const isAlternateScreen = state.emulator.isAlternateScreen - const scrollbackRows = isAlternateScreen ? 0 : requested + const scrollbackRows = opts.scrollbackRows ?? 0 const snapshot = state.emulator.getSnapshot({ scrollbackRows }) const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi return data.length > 0 || opts.includeEmpty === true - ? { + ? this.preferTrackedLastTitle(ptyId, { data, cols: snapshot.cols, rows: snapshot.rows, cwd: snapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), lastTitle: snapshot.lastTitle, seq: state.outputSequence, - source: 'headless', + source: 'headless' as const, oscLinks: snapshot.oscLinks, + scrollbackAnsi: snapshot.scrollbackAnsi, ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } : {}), // Why: lets the renderer skip the destructive scrollback clear when // restoring an alt-screen snapshot — clearing wipes xterm's own // history that the TUI relies on for scroll-up after a tab return. - alternateScreen: isAlternateScreen - } + alternateScreen: isAlternateScreen, + // Why NOT folded into data: the renderer writes its post-replay + // reset after data, and any ESC after a dangling partial aborts it. + // The restorer writes this last (Bug E fix). + pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi + }) : null } @@ -6372,6 +7064,10 @@ export class OrcaRuntimeService { return } this.headlessTerminals.delete(ptyId) + // Why: queued chain links still parse below before the emulator disposes; + // sever the reply sink now so they cannot write to a respawned PTY that + // reused this id (belt to the sink's state-identity check). + state.emulator.disableQueryReplyForwarding() state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose()) } @@ -7172,6 +7868,7 @@ export class OrcaRuntimeService { ? { cols: subscriber.previousCols, rows: subscriber.previousRows } : null inner.delete(clientId) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) if (inner.size > 0) { ptysWithSurvivingPeers.push(ptyId) } else { @@ -7256,13 +7953,17 @@ export class OrcaRuntimeService { serveSimStateWatcher.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) + this.remoteTerminalViewSubscriberCounts.delete(ptyId) this.mobileDisplayModes.delete(ptyId) this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) this.recentPtyOutputById.delete(ptyId) + this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.terminalSpawnCommandsByPtyId.delete(ptyId) + this.disposePtyTitleTracker(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) @@ -7985,6 +8686,20 @@ export class OrcaRuntimeService { ptyId: string, clientId: string, viewport?: { cols: number; rows: number } + ): Promise { + try { + return await this.handleMobileSubscribeInternal(ptyId, clientId, viewport) + } finally { + // Every subscribe path mutates mobileSubscribers — resync the daemon + // background mark once, whatever branch returned. + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + } + } + + private async handleMobileSubscribeInternal( + ptyId: string, + clientId: string, + viewport?: { cols: number; rows: number } ): Promise { const mode = this.getMobileDisplayMode(ptyId) @@ -8157,6 +8872,7 @@ export class OrcaRuntimeService { const wasResizedToPhone = subscriber.wasResizedToPhone inner.delete(clientId) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) if (inner.size > 0) { // Why: if the leaving client was the only one with a non-null restore @@ -18861,9 +19577,12 @@ export class OrcaRuntimeService { serveSimStateWatcher.unbindPty(ptyId) this.ptysById.delete(ptyId) this.recentPtyOutputById.delete(ptyId) + this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.terminalSpawnCommandsByPtyId.delete(ptyId) + this.disposePtyTitleTracker(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) @@ -23355,6 +24074,14 @@ export class OrcaRuntimeService { } } +const WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS = 50 +// Why: chunks that can complete an actionable prompt bypass the throttle so +// blocked stamps stay per-chunk-immediate; the pattern heads mirror +// findTerminalWaitBlockedSignal. Scanned over the new chunk plus a short +// carry only — never the accumulated window. +const WAIT_BLOCKED_KEYWORD_PATTERN = + /press enter|press t to trust|do you trust|trust this|trusted workspace|update available|choose working directory|codex just got an upgrade|hooks need review/ +const WAIT_BLOCKED_KEYWORD_CARRY_CHARS = 31 const MAX_TAIL_LINES = 2000 const MAX_TAIL_CHARS = 256 * 1024 const MAX_TAIL_PARTIAL_CHARS = 4000 @@ -23918,6 +24645,30 @@ function trimTerminalLineRight(line: string): string { return end === line.length ? line : line.slice(0, end) } +// Why a window: the unwindowed implementation below materializes a row object +// per retained tail line and finalize re-allocates + regex-trims every row — +// O(tail) per chunk (~0.9ms at the 2,000-line cap), measured at ~93% of the +// main-process event loop under an agent-TUI flood (findings log 2026-07-03). +// A redraw can only touch rows the cursor can reach, so run the algorithm on +// a suffix window sized by the chunk's maximum upward cursor excursion and +// share the untouched prefix by reference. Equality with the unwindowed +// implementation is fuzz-verified in +// retained-tail-redraw-window.equivalence.test.ts. +const REDRAW_WINDOW_SAFETY_ROWS = 8 + +function maxUpwardCursorReach( + normalizedChunk: string, + previousRedrawCursor: RetainedTailRedrawCursor | null +): number { + let reach = previousRedrawCursor ? previousRedrawCursor.rowFromEnd : 0 + const cursorUpPattern = /\x1b\[(\d*)(?:;[\d;]*)?A/g + let match: RegExpExecArray | null + while ((match = cursorUpPattern.exec(normalizedChunk)) !== null) { + reach += match[1] ? Number.parseInt(match[1], 10) : 1 + } + return reach +} + function appendNormalizedToMultilineTailBuffer( previousLines: string[], boundedPreviousPartialLine: string, @@ -23930,6 +24681,79 @@ function appendNormalizedToMultilineTailBuffer( redrawCursor: RetainedTailRedrawCursor | null truncated: boolean newCompleteLines: number +} { + const windowRows = + maxUpwardCursorReach(normalizedChunk, previousRedrawCursor) + REDRAW_WINDOW_SAFETY_ROWS + if (windowRows >= previousLines.length) { + return appendNormalizedToMultilineTailBufferUnwindowed( + previousLines, + boundedPreviousPartialLine, + normalizedChunk, + previousPartialWasCapped, + previousRedrawCursor + ) + } + const prefixLength = previousLines.length - windowRows + const suffix = previousLines.slice(prefixLength) + const windowed = appendNormalizedToMultilineTailBufferUnwindowed( + suffix, + boundedPreviousPartialLine, + normalizedChunk, + previousPartialWasCapped, + previousRedrawCursor + ) + let lines = previousLines.slice(0, prefixLength) + // Why: the unwindowed finalize trims trailing spaces/tabs on every row; the + // shared prefix must match without paying a regex per untouched row. + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]! + const lastChar = line.charCodeAt(line.length - 1) + if (lastChar === 32 || lastChar === 9) { + lines[index] = line.replace(/[ \t]+$/g, '') + } + } + for (const line of windowed.lines) { + lines.push(line) + } + let truncated = windowed.truncated + if (lines.length > MAX_TAIL_LINES) { + lines = lines.slice(lines.length - MAX_TAIL_LINES) + truncated = true + } + let totalChars = windowed.partialLine.length + for (const line of lines) { + totalChars += line.length + } + let dropCount = 0 + while (dropCount < lines.length && totalChars > MAX_TAIL_CHARS) { + totalChars -= lines[dropCount]!.length + dropCount += 1 + } + if (dropCount > 0) { + lines = lines.slice(dropCount) + truncated = true + } + return { + lines, + partialLine: windowed.partialLine, + redrawCursor: windowed.redrawCursor, + truncated, + newCompleteLines: windowed.newCompleteLines + } +} + +export function appendNormalizedToMultilineTailBufferUnwindowed( + previousLines: string[], + boundedPreviousPartialLine: string, + normalizedChunk: string, + previousPartialWasCapped: boolean, + previousRedrawCursor: RetainedTailRedrawCursor | null +): { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + newCompleteLines: number } { const rows: RetainedTerminalRow[] = [ ...previousLines.map((line) => ({ text: line, completed: true })), diff --git a/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts new file mode 100644 index 00000000000..32685f93b53 --- /dev/null +++ b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { + appendNormalizedToTailBuffer, + appendNormalizedToMultilineTailBufferUnwindowed +} from './orca-runtime' + +// Differential guard for the windowed redraw tail path: the public +// appendNormalizedToTailBuffer routes vertical-control chunks through a +// suffix-windowed wrapper (findings log 2026-07-03 — the unwindowed path was +// O(tail) per chunk and dominated main's event loop under agent-TUI floods). +// This fuzz asserts the windowed result is byte-identical to the reference +// implementation across randomized tails and redraw chunks. + +function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function randomTail(rng: () => number, maxLines: number): string[] { + const count = Math.floor(rng() * maxLines) + return Array.from({ length: count }, (_, i) => { + const base = `line ${i} ${'x'.repeat(Math.floor(rng() * 40))}` + // Trailing whitespace included deliberately: the reference implementation + // trims every row on each call, so the windowed prefix must match. + return rng() < 0.3 ? `${base} ` : base + }) +} + +function randomRedrawChunk(rng: () => number): string { + const parts: string[] = [] + const ops = 1 + Math.floor(rng() * 12) + for (let i = 0; i < ops; i++) { + const roll = rng() + if (roll < 0.2) { + parts.push(`\x1b[${1 + Math.floor(rng() * 12)}A`) + } else if (roll < 0.3) { + parts.push(`\x1b[${Math.floor(rng() * 3)}J`) + } else if (roll < 0.4) { + parts.push(`\x1b[${Math.floor(rng() * 3)}K`) + } else if (roll < 0.5) { + parts.push('\r') + } else if (roll < 0.6) { + parts.push(`\x1b[${1 + Math.floor(rng() * 30)}G`) + } else if (roll < 0.7) { + parts.push('\n') + } else if (roll < 0.75) { + parts.push('') + } else { + parts.push(`text${Math.floor(rng() * 100)} ${'y'.repeat(Math.floor(rng() * 20))}`) + } + } + return parts.join('') +} + +describe('windowed redraw tail equivalence', () => { + it('matches the unwindowed reference across 500 randomized cases', () => { + const rng = mulberry32(42) + for (let round = 0; round < 500; round++) { + const tail = randomTail(rng, round % 5 === 0 ? 2100 : 300) + const partial = rng() < 0.5 ? `partial ${'z'.repeat(Math.floor(rng() * 30))}` : '' + const redrawCursor = + rng() < 0.3 ? { rowFromEnd: Math.floor(rng() * 20), column: Math.floor(rng() * 40) } : null + // Why the guaranteed cursor-up: the public function routes to the + // multiline (windowed) path only for vertical-control chunks; chunks + // without one take the single-line fast path, which is out of scope. + const chunk = `\x1b[${1 + Math.floor(rng() * 4)}A${randomRedrawChunk(rng)}` + + const actual = appendNormalizedToTailBuffer(tail, partial, chunk, redrawCursor) + // Reference path over the full tail. + const expected = appendNormalizedToMultilineTailBufferUnwindowed( + tail, + partial.slice(-4000), + chunk, + partial.length > 4000, + redrawCursor + ) + + expect(actual.lines, `round ${round} lines`).toEqual(expected.lines) + expect(actual.partialLine, `round ${round} partial`).toBe(expected.partialLine) + expect(actual.redrawCursor, `round ${round} cursor`).toEqual(expected.redrawCursor) + expect(actual.truncated, `round ${round} truncated`).toBe(expected.truncated) + expect(actual.newCompleteLines, `round ${round} newLines`).toBe(expected.newCompleteLines) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index ff553f812a7..73c77701c97 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -36,6 +36,9 @@ const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024 const TERMINAL_OUTPUT_FLUSH_MS = 5 // Why: output batches become binary stream payloads; byte size is the transport cost. const TERMINAL_OUTPUT_BATCH_MAX_BYTES = 64 * 1024 +// Why: remote clients can apply output pressure without pausing runtime PTY ingestion. +const TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES = 512 * 1024 +const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024 // Why: pending output is held for later binary frames, so cap the encoded // payload bytes rather than UTF-16 code units. const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024 @@ -60,6 +63,7 @@ type SnapshotFrameOptions = { type SerializedSnapshot = { data: string + scrollbackAnsi?: string cols: number rows: number seq?: number @@ -82,7 +86,13 @@ type TerminalMultiplexStream = { ptyId: string client: TerminalViewportClient | undefined isMobile: boolean + ackOutput: boolean + ackInFlightBytes: number buffering: boolean + ackPendingOutput: TerminalOutputFrameChunk[] + ackPendingOutputBytes: number + ackPendingOutputOverflowed: boolean + ackRecoverySnapshotInFlight: boolean pendingOutput: TerminalOutputChunk[] pendingOutputBytes: number pendingOutputOverflowed: boolean @@ -362,6 +372,26 @@ function getOutputAfterSnapshotSeq( return chunk.data.slice(snapshotSeq - chunkStartSeq) } +function appendAckPendingOutput( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk +): void { + stream.ackPendingOutput.push(chunk) + stream.ackPendingOutputBytes += chunk.bytes.byteLength + let omittedChunkCount = 0 + while ( + stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && + omittedChunkCount < stream.ackPendingOutput.length + ) { + stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength + omittedChunkCount += 1 + } + if (omittedChunkCount > 0) { + stream.ackPendingOutput.splice(0, omittedChunkCount) + stream.ackPendingOutputOverflowed = true + } +} + function trimPendingOutputToBudget( pendingOutput: TerminalOutputChunk[], pendingOutputBytes: number @@ -388,6 +418,43 @@ function measureTerminalStreamByteLength( return measureClipboardTextByteLength(data, options) } +function trimPendingOutputCoveredBySnapshot( + pendingOutput: TerminalOutputChunk[], + snapshotSeq: number | undefined +): { chunks: TerminalOutputChunk[]; bytes: number } { + if (typeof snapshotSeq !== 'number') { + return { + chunks: pendingOutput, + bytes: pendingOutput.reduce((sum, chunk) => sum + chunk.bytes, 0) + } + } + const chunks: TerminalOutputChunk[] = [] + let bytes = 0 + for (const chunk of pendingOutput) { + const chunkSeq = chunk.meta?.seq + const rawLength = chunk.meta?.rawLength ?? chunk.data.length + if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) { + chunks.push(chunk) + bytes += chunk.bytes + continue + } + const startSeq = chunkSeq - rawLength + if (snapshotSeq >= chunkSeq) { + continue + } + if (snapshotSeq <= startSeq) { + chunks.push(chunk) + bytes += chunk.bytes + continue + } + const data = chunk.data.slice(snapshotSeq - startSeq) + const slicedBytes = terminalStreamByteLength(data) + chunks.push({ data, bytes: slicedBytes, meta: undefined }) + bytes += slicedBytes + } + return { chunks, bytes } +} + function terminalStreamByteLength(data: string): number { return measureTerminalStreamByteLength(data).byteLength } @@ -436,13 +503,12 @@ async function serializeBudgetedRequestedSnapshot( if (!serialized) { return null } - const overByteBudget = terminalStreamByteLengthExceeds( - serialized.data, - REQUESTED_SNAPSHOT_BYTE_BUDGET - ) + const data = (serialized.scrollbackAnsi ?? '') + serialized.data + const overByteBudget = terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET) if (!overByteBudget || rows === 0) { return { ...serialized, + data, scrollbackRows: rows, truncatedByByteBudget: rows < requestedRows || overByteBudget } @@ -491,7 +557,14 @@ async function serializeBudgetedMobileSnapshot( ): Promise { if (!isMobile) { const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 }) - return serialized ? { ...serialized, scrollbackRows: 0, truncatedByByteBudget: false } : null + return serialized + ? { + ...serialized, + data: (serialized.scrollbackAnsi ?? '') + serialized.data, + scrollbackRows: 0, + truncatedByByteBudget: false + } + : null } const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] for (const rows of candidates) { @@ -499,13 +572,12 @@ async function serializeBudgetedMobileSnapshot( if (!serialized) { return null } - const overByteBudget = terminalStreamByteLengthExceeds( - serialized.data, - MOBILE_SNAPSHOT_BYTE_BUDGET - ) + const data = (serialized.scrollbackAnsi ?? '') + serialized.data + const overByteBudget = terminalStreamByteLengthExceeds(data, MOBILE_SNAPSHOT_BYTE_BUDGET) if (!overByteBudget || rows === 0) { return { ...serialized, + data, scrollbackRows: rows, truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget } @@ -749,7 +821,16 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ type: z.enum(['mobile', 'desktop']).default('desktop') }) .optional(), - viewport: TerminalViewport.optional() + viewport: TerminalViewport.optional(), + capabilities: z + .object({ + ackOutput: z.literal(1).optional() + }) + .optional() +}) + +const TerminalMultiplexAckFrame = z.object({ + bytes: z.number().int().nonnegative() }) const TerminalMultiplexSnapshotRequestFrame = z.object({ @@ -1224,6 +1305,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let closed = false let cursor = 0 const streams = new Map() + let ackTotalInFlightBytes = 0 let resolveMultiplex = (): void => {} const multiplexClosed = new Promise((resolve) => { resolveMultiplex = resolve @@ -1269,6 +1351,136 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) ) } + const canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => { + if (!stream.ackOutput) { + return true + } + return ( + stream.ackInFlightBytes + bytes <= TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES && + ackTotalInFlightBytes + bytes <= TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES + ) + } + const sendAckGatedOutput = ( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk + ): void => { + sendFrame(stream.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) + if (stream.ackOutput) { + stream.ackInFlightBytes += chunk.bytes.byteLength + ackTotalInFlightBytes += chunk.bytes.byteLength + } + } + const queueOrSendOutput = ( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk + ): void => { + if (closed || streams.get(stream.streamId) !== stream) { + return + } + if ( + stream.ackPendingOutputOverflowed || + stream.ackPendingOutput.length > 0 || + !canSendAckGatedOutput(stream, chunk.bytes.byteLength) + ) { + appendAckPendingOutput(stream, chunk) + return + } + sendAckGatedOutput(stream, chunk) + } + const sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise => { + if ( + closed || + streams.get(stream.streamId) !== stream || + stream.ackRecoverySnapshotInFlight + ) { + return + } + stream.ackRecoverySnapshotInFlight = true + try { + const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) + if (closed || streams.get(stream.streamId) !== stream) { + return + } + const size = runtime.getTerminalSize(stream.ptyId) + const displayMode = runtime.getMobileDisplayMode(stream.ptyId) + // Why: dropped ACK-pending output means live frames are no longer a + // complete replay. Send a fresh model snapshot before resuming output. + // Why: truncated marks an unusable snapshot, and clients discard + // those. The recovery snapshot must be applied to cover dropped + // output, so it is only truncated when serialization failed. + sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { + kind: 'scrollback', + cols: serialized?.cols ?? size?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? 24, + displayMode, + reason: 'ack-pending-overflow', + seq: serialized?.seq, + source: serialized?.source, + truncated: !serialized, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + data: serialized?.data ?? '' + }) + if (serialized && typeof serialized.seq === 'number') { + // Why: retained chunks queued before the snapshot serialized are + // already contained in it; replaying them would duplicate output. + const snapshotSeq = serialized.seq + const retained = stream.ackPendingOutput.filter( + (chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq) + ) + stream.ackPendingOutput = retained + stream.ackPendingOutputBytes = retained.reduce( + (total, chunk) => total + chunk.bytes.byteLength, + 0 + ) + } + stream.ackPendingOutputOverflowed = false + } catch (error) { + sendStreamError( + stream.streamId, + error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.' + ) + } finally { + if (streams.get(stream.streamId) === stream) { + stream.ackRecoverySnapshotInFlight = false + flushAckPendingOutput(stream) + } + } + } + const flushAckPendingOutput = (stream: TerminalMultiplexStream): void => { + if (stream.ackPendingOutputOverflowed) { + void sendAckRecoverySnapshot(stream) + return + } + let flushed = 0 + while ( + flushed < stream.ackPendingOutput.length && + canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength) + ) { + sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!) + flushed += 1 + } + if (flushed > 0) { + stream.ackPendingOutput.splice(0, flushed) + stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce( + (total, pending) => total + pending.bytes.byteLength, + 0 + ) + } + } + const flushAllAckPendingOutput = (): void => { + for (const stream of streams.values()) { + flushAckPendingOutput(stream) + } + } + const acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => { + if (!stream.ackOutput || bytes <= 0) { + return + } + const acknowledged = Math.min(stream.ackInFlightBytes, bytes) + stream.ackInFlightBytes -= acknowledged + ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged) + flushAllAckPendingOutput() + } const detachStream = (streamId: number, emitEnd: boolean): void => { const stream = streams.get(streamId) if (!stream) { @@ -1276,12 +1488,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } stream.outputBatcher.flush() stream.outputBatcher.dispose() + ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - stream.ackInFlightBytes) + stream.ackInFlightBytes = 0 + stream.ackPendingOutput = [] + stream.ackPendingOutputBytes = 0 + stream.ackPendingOutputOverflowed = false + stream.ackRecoverySnapshotInFlight = false stream.unsubscribeData() stream.unsubscribeResize() stream.unsubscribeFit() stream.unsubscribeDriver() stream.unregisterBinaryHandler() streams.delete(streamId) + flushAllAckPendingOutput() // Why: release the runtime exit-waiter for this slot (see the field's // note). The .catch below no-ops because the stream is already deleted. stream.exitWaiterAbort.abort() @@ -1314,6 +1533,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ detachStream(stream.streamId, false) return } + if (frame.opcode === TerminalStreamOpcode.Ack) { + const parsed = TerminalMultiplexAckFrame.safeParse( + decodeTerminalStreamJson(frame.payload) ?? {} + ) + if (parsed.success) { + acknowledgeOutput(stream, parsed.data.bytes) + } + return + } if (frame.opcode === TerminalStreamOpcode.Input) { const text = decodeTerminalStreamText(frame.payload) if (!text) { @@ -1482,6 +1710,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ emit({ type: 'end', streamId: request.streamId }) return } + if (closed) { + return + } + // Why: a competing subscribe for the same streamId can fully register + // while this one awaited the PTY id above. Overwriting it in + // `streams` would orphan its data/view-subscriber registrations — a + // leaked view subscriber permanently silences the model query + // responder (terminal-query-authority.md). Detach it so every + // registration stays release-balanced. + detachStream(request.streamId, false) const ptyId = leaf.ptyId const stream: TerminalMultiplexStream = { @@ -1490,7 +1728,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ptyId, client: request.client, isMobile, + ackOutput: request.capabilities?.ackOutput === 1, + ackInFlightBytes: 0, buffering: true, + ackPendingOutput: [], + ackPendingOutputBytes: 0, + ackPendingOutputOverflowed: false, + ackRecoverySnapshotInFlight: false, pendingOutput: [], pendingOutputBytes: 0, pendingOutputOverflowed: false, @@ -1506,7 +1750,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) } for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { - sendFrame(request.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) + queueOrSendOutput(stream, chunk) } }), unsubscribeData: () => {}, @@ -1522,7 +1766,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) try { - stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { if (closed || streams.get(request.streamId) !== stream) { return } @@ -1532,6 +1776,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } stream.outputBatcher.push(data, meta) }) + // Why: a multiplexed stream feeds a remote xterm view that answers + // terminal queries with view authority; the main model responder + // yields while it is attached (terminal-query-authority.md). + // Wrapped into unsubscribeData so every detach path releases it. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + stream.unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } if (isMobile && request.client?.id) { await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) @@ -1718,6 +1971,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } }) } catch (error) { + // Why the ownership check: a newer subscribe may own this streamId + // now (it detached and released this stream on arrival). Detaching + // or erroring the slot here would tear down the successor's live + // registrations instead of this stream's. + if (streams.get(request.streamId) !== stream) { + return + } detachStream(request.streamId, false) sendStreamError(request.streamId, error instanceof Error ? error.message : String(error)) emit({ type: 'end', streamId: request.streamId }) @@ -1819,9 +2079,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const outputBatcher = createTerminalOutputBatcher((chunk) => { emit({ type: 'data', chunk }) }) - const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { outputBatcher.push(data) }) + // Why: this legacy JSON stream can feed a live xterm view too + // (older web/desktop subscribers), so it conservatively registers + // as a remote view subscriber. For read-only watchers the cost is + // a withheld model reply — the pre-Phase-5 status quo — which is + // strictly safer than a double reply under a view consumer. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + const unsubscribeData = (): void => { + releaseViewSubscriber() + unsubscribeStreamData() + } const unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { outputBatcher.flush() emit({ @@ -1861,7 +2131,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // resize re-stream so it only fires on an actual width change. let lastResizeCols: number | undefined let resizeGeneration = 0 - const pendingOutput: TerminalOutputChunk[] = [] + let pendingOutput: TerminalOutputChunk[] = [] let pendingOutputBytes = 0 let pendingOutputOverflowed = false let unsubscribeData = (): void => {} @@ -1975,7 +2245,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return } - unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { if (closed) { return } @@ -1996,6 +2266,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } outputBatcher?.push(data, meta) }) + // Why: binary subscribe streams feed remote xterm views (mobile and + // binary-capable desktop clients) that answer queries with view + // authority; the main model responder yields while attached. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } let read = await runtime.readTerminal(params.terminal) let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) @@ -2066,6 +2344,55 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // Why: baseline for resize re-stream gating; the client already // rewrapped to these cols via the initial snapshot replay. lastResizeCols = serialized?.cols ?? size?.cols + let recoveryAttempts = 0 + // Why: if the bounded pre-subscribe tail overflowed, only a fresh + // model snapshot can cover the dropped middle without replay gaps. + while (pendingOutputOverflowed && recoveryAttempts < 2) { + pendingOutputOverflowed = false + recoveryAttempts += 1 + const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (closed) { + return + } + if (!recovery) { + break + } + // Why: without an output seq (renderer-source fallback) covered + // chunks cannot be trimmed exactly, and the renderer view may lag + // the queued chunks under backpressure. Keep the bounded replay + // instead of applying an unverifiable snapshot. + if (typeof recovery.seq !== 'number') { + break + } + // Why: shipped mobile clients drop a second scrollback snapshot for + // an initialized handle but apply a resized snapshot inline by + // re-initializing xterm with fresh scrollback. Omit seq on the wire + // so the client's layout-seq staleness filter is not polluted with + // output-byte sequences. + const recoveryStats = sendSnapshotFrames(sendFrame, { + kind: 'resized', + cols: recovery.cols, + rows: recovery.rows, + displayMode, + reason: 'pending-output-overflow', + source: recovery.source, + truncated: false, + truncatedByByteBudget: recovery.truncatedByByteBudget, + data: recovery.data + }) + console.log('[mobile-terminal-stream] recovery snapshot', { + terminal: params.terminal, + streamId, + reason: 'pending-output-overflow', + bytes: recoveryStats.bytes, + chunks: recoveryStats.chunks, + scrollbackRows: recovery.scrollbackRows, + truncatedByByteBudget: recovery.truncatedByByteBudget === true + }) + const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq) + pendingOutput = trimmed.chunks + pendingOutputBytes = trimmed.bytes + } buffering = false const bufferedOutput = pendingOutput.splice(0) if (!initialOutputOverflowed) { diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index abc9ab20372..9cdc88a021b 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -9,6 +9,9 @@ import type { RuntimeTerminalWait } from '../../../shared/runtime-types' function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: subscribe streams register as remote view subscribers for Phase-5 + // query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index 24367102745..88125c840b3 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -53,6 +53,7 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), getLayout: vi.fn().mockReturnValue({ seq: 1 }), + registerRemoteTerminalViewSubscriber: vi.fn(() => () => {}), subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index edb5afc030a..62dcfddc5ab 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -18,6 +18,14 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: every multiplex stream registers as a remote view subscriber for + // Phase-5 query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, + // Why: the multiplex subscribe path resolves handles via + // resolveLiveLeafForHandle (#7718). Default to a live pty so tests that + // only stub the legacy resolveLeafForHandle still bind; tests that need a + // null/stale leaf override this explicitly. + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), ...overrides } as OrcaRuntimeService } @@ -498,6 +506,708 @@ describe('terminal multiplex RPC', () => { } }) + it('holds ACK-capable multiplex output over budget until the client acknowledges bytes', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot', + cols: 120, + rows: 40 + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + _: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListenerRef.current = listener + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-gated', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 16, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + binaryFrames.splice(0) + + const output = 'x'.repeat(700 * 1024) + dataListenerRef.current?.(output, { seq: output.length, rawLength: output.length }) + + const initialOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + const initialBytes = initialOutputFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(initialBytes).toBeLessThanOrEqual(512 * 1024) + expect(initialOutputFrames.length).toBeGreaterThan(0) + const initialOutput = initialOutputFrames + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(initialOutput.length).toBeLessThan(output.length) + + handlers.get(16)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 16, + seq: 2, + payload: encodeTerminalStreamText('still interactive\r') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'still interactive\r', + enter: false, + interrupt: false + }) + ) + + handlers.get(16)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 16, + seq: 3, + payload: encodeTerminalStreamJson({ bytes: initialBytes }) + }) + )! + ) + + const flushedOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + expect(flushedOutputFrames.length).toBeGreaterThan(initialOutputFrames.length) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-gated') + await dispatchPromise + }) + + it('releases shared ACK budget to other stalled multiplex streams', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListeners = new Map< + string, + (data: string, meta?: { seq?: number; rawLength?: number }) => void + >() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn((terminal: string) => ({ + ptyId: terminal.replace('terminal-', 'pty-') + })), + resolveLiveLeafForHandle: vi.fn((terminal: string) => ({ + ptyId: terminal.replace('terminal-', 'pty-') + })), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async (ptyId: string) => ({ + data: `snapshot-${ptyId}`, + cols: 120, + rows: 40 + })), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + ptyId: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListeners.set(ptyId, listener) + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-shared-budget', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + + const streamIds = [21, 22, 23, 24, 25, 26] + for (const streamId of streamIds) { + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: streamId, + payload: encodeTerminalStreamJson({ + streamId, + terminal: `terminal-${streamId - 20}`, + client: { id: `desktop-${streamId}`, type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + } + + await vi.waitFor(() => + expect( + messages + .map((msg) => JSON.parse(msg).result) + .filter((result) => result?.type === 'subscribed') + ).toHaveLength(streamIds.length) + ) + await vi.waitFor(() => expect(dataListeners.size).toBe(streamIds.length)) + binaryFrames.splice(0) + + const fillerOutput = 'f'.repeat(480 * 1024) + for (let index = 1; index <= 4; index += 1) { + dataListeners.get(`pty-${index}`)?.(fillerOutput, { + seq: fillerOutput.length, + rawLength: fillerOutput.length + }) + } + const stalledOutput = 's'.repeat(700 * 1024) + dataListeners.get('pty-5')?.(stalledOutput, { + seq: stalledOutput.length, + rawLength: stalledOutput.length + }) + dataListeners.get('pty-6')?.(stalledOutput, { + seq: stalledOutput.length, + rawLength: stalledOutput.length + }) + + const initialOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + const initialBytesByStream = new Map() + for (const frame of initialOutputFrames) { + if (!frame) { + continue + } + initialBytesByStream.set( + frame.streamId, + (initialBytesByStream.get(frame.streamId) ?? 0) + frame.payload.byteLength + ) + } + const initialBytes = initialOutputFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(initialBytes).toBeLessThanOrEqual(2 * 1024 * 1024) + expect(initialBytesByStream.get(21)).toBe(480 * 1024) + expect(initialBytesByStream.get(22)).toBe(480 * 1024) + expect(initialBytesByStream.get(23)).toBe(480 * 1024) + expect(initialBytesByStream.get(24)).toBe(480 * 1024) + expect(initialBytesByStream.get(25)).toBeGreaterThan(0) + expect(initialBytesByStream.get(26) ?? 0).toBe(0) + + handlers.get(26)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 26, + seq: 200, + payload: encodeTerminalStreamText('remote-still-interactive\r') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-6', { + text: 'remote-still-interactive\r', + enter: false, + interrupt: false + }) + ) + + const frameCountBeforeAck = binaryFrames.length + handlers.get(21)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 21, + seq: 201, + payload: encodeTerminalStreamJson({ bytes: initialBytesByStream.get(21) ?? 0 }) + }) + )! + ) + + await vi.waitFor(() => + expect( + binaryFrames + .slice(frameCountBeforeAck) + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => { + if (frame?.streamId !== 25 || frame.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + ).toBe(true) + ) + const framesAfterAck = binaryFrames + .slice(frameCountBeforeAck) + .map((frame) => decodeTerminalStreamFrame(frame)) + const snapshotStartIndex = framesAfterAck.findIndex((frame) => { + if (frame?.streamId !== 25 || frame.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + const outputFramesAfterAck = framesAfterAck.filter( + (frame) => frame?.opcode === TerminalStreamOpcode.Output + ) + const bytesAfterAckByStream = new Map() + for (const frame of outputFramesAfterAck) { + if (!frame) { + continue + } + bytesAfterAckByStream.set( + frame.streamId, + (bytesAfterAckByStream.get(frame.streamId) ?? 0) + frame.payload.byteLength + ) + } + expect(snapshotStartIndex).toBeGreaterThanOrEqual(0) + expect( + framesAfterAck + .filter((frame) => frame?.streamId === 25 && frame.opcode === TerminalStreamOpcode.Output) + .every((frame) => framesAfterAck.indexOf(frame) > snapshotStartIndex) + ).toBe(true) + expect(bytesAfterAckByStream.get(25) ?? 0).toBeGreaterThan(0) + expect(bytesAfterAckByStream.get(21) ?? 0).toBe(0) + expect( + outputFramesAfterAck.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + ).toBeLessThanOrEqual(initialBytesByStream.get(21) ?? 0) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-shared-budget') + await dispatchPromise + }) + + it('caps stalled ACK output and snapshots before resuming retained tail frames', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 }) + .mockResolvedValue({ data: 'recovered snapshot', cols: 120, rows: 40, seq: 99 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + _: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListenerRef.current = listener + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-overflow', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 17, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + binaryFrames.splice(0) + + const output = 'x'.repeat(3 * 1024 * 1024) + dataListenerRef.current?.(output, { seq: output.length, rawLength: output.length }) + + const initialOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + const initialBytes = initialOutputFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(initialBytes).toBeLessThanOrEqual(512 * 1024) + + handlers.get(17)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 17, + seq: 2, + payload: encodeTerminalStreamText('still interactive\r') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'still interactive\r', + enter: false, + interrupt: false + }) + ) + + binaryFrames.splice(0) + handlers.get(17)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 17, + seq: 3, + payload: encodeTerminalStreamJson({ bytes: initialBytes }) + }) + )! + ) + + await vi.waitFor(() => + expect( + binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + ).toBe(true) + ) + const drainFrames = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame)) + const recoveryStartIndex = drainFrames.findIndex((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + const firstOutputAfterAckIndex = drainFrames.findIndex( + (frame) => frame?.opcode === TerminalStreamOpcode.Output + ) + expect(recoveryStartIndex).toBeGreaterThanOrEqual(0) + // Why: clients discard truncated snapshots; a usable recovery snapshot + // must not be marked truncated or the dropped output gap is permanent. + expect( + decodeTerminalStreamJson<{ truncated?: boolean }>(drainFrames[recoveryStartIndex]!.payload) + ?.truncated + ).toBe(false) + expect(firstOutputAfterAckIndex).toBeGreaterThan(recoveryStartIndex) + expect( + drainFrames + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + ).toBe('recovered snapshot') + + const outputBytesAfterRecovery = drainFrames + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + expect(outputBytesAfterRecovery).toBeLessThanOrEqual(256 * 1024) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-overflow') + await dispatchPromise + }) + + it('trims recovery-covered ACK pending output instead of replaying it', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const floodedChars = 3 * 1024 * 1024 + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 }) + // Why: the recovery snapshot seq covers the entire flood, so every + // retained pending chunk is already contained in the snapshot. + .mockResolvedValue({ data: 'recovered snapshot', cols: 120, rows: 40, seq: floodedChars }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + _: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListenerRef.current = listener + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-trim', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 31, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + binaryFrames.splice(0) + + const output = 'x'.repeat(floodedChars) + dataListenerRef.current?.(output, { seq: floodedChars, rawLength: floodedChars }) + const initialBytes = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + expect(initialBytes).toBeLessThanOrEqual(512 * 1024) + + binaryFrames.splice(0) + handlers.get(31)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 31, + seq: 2, + payload: encodeTerminalStreamJson({ bytes: initialBytes }) + }) + )! + ) + await vi.waitFor(() => + expect( + binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotEnd) + ).toBe(true) + ) + + const framesAfterRecovery = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame)) + expect( + framesAfterRecovery + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + ).toBe('recovered snapshot') + // Why: every retained chunk is covered by the recovery snapshot seq; + // replaying any of them would duplicate snapshot content. + expect( + framesAfterRecovery.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + ).toEqual([]) + + binaryFrames.splice(0) + const fresh = 'fresh-after-recovery\r\n' + dataListenerRef.current?.(fresh, { + seq: floodedChars + fresh.length, + rawLength: fresh.length + }) + await vi.waitFor(() => { + const freshOutput = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(freshOutput).toBe(fresh) + }) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-trim') + await dispatchPromise + }) + it('marks multiplex fallback snapshots truncated when the uncursored read is limited', async () => { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] @@ -1472,6 +2182,238 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) + it('keeps view-subscriber releases balanced when a same-streamId subscribe overwrites a blocked one', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + // Why: a leaked registration permanently suppresses the model query + // responder (terminal-query-authority.md) — the count must return to 0. + let viewSubscriberCount = 0 + let leafResolved = false + let resolveFirstWait: (ptyId: string) => void = () => {} + // Why: the multiplex subscribe path resolves via resolveLiveLeafForHandle + // (#7718); null makes subscribe A block in waitForLeafPtyId until B resolves. + const resolveLeaf = (): { ptyId: string | null } => + leafResolved ? { ptyId: 'pty-1' } : { ptyId: null } + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn(resolveLeaf), + resolveLiveLeafForHandle: vi.fn(resolveLeaf), + waitForLeafPtyId: vi.fn( + () => + new Promise((resolve) => { + resolveFirstWait = resolve + }) + ), + registerRemoteTerminalViewSubscriber: vi.fn(() => { + viewSubscriberCount += 1 + let released = false + return () => { + if (!released) { + released = true + viewSubscriberCount -= 1 + } + } + }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snap', cols: 80, rows: 24 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + handleMobileSubscribe: vi.fn().mockResolvedValue(undefined), + handleMobileUnsubscribe: vi.fn(), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-overwrite', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + const sendSubscribe = (): void => { + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 7, + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' } + }) + }) + )! + ) + } + + // Subscribe A blocks in waitForLeafPtyId; subscribe B (same streamId) + // then resolves the leaf directly and fully registers. + sendSubscribe() + await vi.waitFor(() => expect(runtime.waitForLeafPtyId).toHaveBeenCalled()) + leafResolved = true + sendSubscribe() + await vi.waitFor(() => + expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength( + 1 + ) + ) + + // A resumes and takes the slot; B's registration must be released, not + // orphaned by the overwrite. + resolveFirstWait('pty-1') + await vi.waitFor(() => + expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength( + 2 + ) + ) + + handlers.get(7)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Unsubscribe, + streamId: 7, + seq: 2, + payload: new Uint8Array() + }) + )! + ) + expect(viewSubscriberCount).toBe(0) + + cleanups.get('terminal-multiplex:conn-overwrite')?.() + await dispatchPromise + }) + + it('keeps an evicted subscribe error from detaching the successor stream', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + let viewSubscriberCount = 0 + const mobileSubscribeWaiters: { + resolve: () => void + reject: (error: Error) => void + }[] = [] + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + // Why: the multiplex subscribe path resolves the leaf via + // resolveLiveLeafForHandle (#7718), so it must return a live pty here. + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + registerRemoteTerminalViewSubscriber: vi.fn(() => { + viewSubscriberCount += 1 + let released = false + return () => { + if (!released) { + released = true + viewSubscriberCount -= 1 + } + } + }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snap', cols: 80, rows: 24 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + handleMobileSubscribe: vi.fn( + () => + new Promise((resolve, reject) => { + mobileSubscribeWaiters.push({ resolve: () => resolve(true), reject }) + }) + ), + handleMobileUnsubscribe: vi.fn(), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-evicted-error', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + const sendSubscribe = (): void => { + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 9, + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' } + }) + }) + )! + ) + } + + // A registers, then blocks in handleMobileSubscribe. B (same streamId) + // evicts A on arrival and completes its own registration. + sendSubscribe() + await vi.waitFor(() => expect(mobileSubscribeWaiters).toHaveLength(1)) + sendSubscribe() + await vi.waitFor(() => expect(mobileSubscribeWaiters).toHaveLength(2)) + mobileSubscribeWaiters[1]!.resolve() + await vi.waitFor(() => + expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength( + 1 + ) + ) + expect(viewSubscriberCount).toBe(1) + + // A's pending await now rejects. The evicted stream must not detach the + // successor that owns the slot. + mobileSubscribeWaiters[0]!.reject(new Error('mobile_subscribe_failed')) + await Promise.resolve() + await Promise.resolve() + expect(viewSubscriberCount).toBe(1) + + cleanups.get('terminal-multiplex:conn-evicted-error')?.() + await dispatchPromise + expect(viewSubscriberCount).toBe(0) + }) + it('rejects a stale terminal handle with terminal_handle_stale instead of binding the wrong PTY', async () => { // Why: after a reconnect a client can resubscribe with a handle whose // pane now hosts a different PTY. Binding the stream anyway would mirror diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index df81e05e057..d10456c8fa6 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -15,6 +15,9 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: subscribe streams register as remote view subscribers for Phase-5 + // query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index f4a70cbeebd..ba921c6b14e 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -14,6 +14,9 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: subscribe streams register as remote view subscribers for Phase-5 + // query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, ...overrides } as OrcaRuntimeService } @@ -346,13 +349,6 @@ describe('terminal subscribe buffering', () => { await vi.waitFor(() => expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) ) - const subscribed = messages - .map((msg) => JSON.parse(msg).result) - .find((result) => result?.type === 'subscribed') - expect(subscribed).toMatchObject({ - type: 'subscribed', - truncated: false - }) const snapshotStart = binaryFrames .map((frame) => decodeTerminalStreamFrame(frame)) .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart) @@ -367,21 +363,35 @@ describe('terminal subscribe buffering', () => { await dispatchPromise }) - it('bounds legacy binary output queued while the initial snapshot is serializing', async () => { + it('recovers binary output overflow queued while the initial snapshot is serializing', async () => { vi.useFakeTimers() try { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] const cleanups = new Map void>() - const dataListenerRef: { current?: (data: string) => void } = {} - const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const snapshotResolvers: ((value: { + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }) => void)[] = [] const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn( () => - new Promise<{ data: string; cols: number; rows: number }>((resolve) => { - snapshotResolves.push(resolve) + new Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }>((resolve) => { + snapshotResolvers.push(resolve) }) ), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), @@ -427,34 +437,54 @@ describe('terminal subscribe buffering', () => { await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) const shiftSpy = vi.spyOn(Array.prototype, 'shift') + let seq = 0 for (let index = 0; index < 400; index += 1) { - dataListenerRef.current?.(`${String(index).padStart(3, '0')}${'x'.repeat(1021)}`) + const data = `${String(index).padStart(3, '0')}${'x'.repeat(1021)}` + seq += data.length + dataListenerRef.current?.(data, { seq, rawLength: data.length }) } const shiftCallCount = shiftSpy.mock.calls.length shiftSpy.mockRestore() await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) - snapshotResolves.shift()?.({ data: '', cols: 120, rows: 40 }) + snapshotResolvers[0]?.({ data: '', cols: 120, rows: 40, seq: 0, source: 'headless' }) await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2)) - snapshotResolves.shift()?.({ data: '399', cols: 120, rows: 40 }) + snapshotResolvers[1]?.({ + data: 'recovered after overflow\r\n', + cols: 120, + rows: 40, + seq, + source: 'headless' + }) await vi.waitFor(() => expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) ) await vi.runOnlyPendingTimersAsync() - const output = binaryFrames + const decodedFrames = binaryFrames .map((frame) => decodeTerminalStreamFrame(frame)) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) - .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .filter((frame): frame is NonNullable => frame !== null) + const snapshotStarts = decodedFrames.filter( + (frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart + ) + const decodedStarts = snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload)) + // Why one snapshot: overflow during the initial serialize is recovered + // INLINE (drop pending, re-read, re-serialize) before anything is sent, + // so the client's first scrollback snapshot is already current. The + // 'resized'/pending-output-overflow follow-up path remains only for + // overflow that begins after the initial snapshot went out. + expect(decodedStarts).toEqual([expect.objectContaining({ kind: 'scrollback', seq })]) + const snapshotText = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame.payload)) + .join('') + const output = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame.payload)) .join('') expect(output.length).toBeLessThanOrEqual(256 * 1024) - expect(output).toBe('') - const snapshotPayload = binaryFrames - .map((frame) => decodeTerminalStreamFrame(frame)) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) - .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) - .join('') - expect(snapshotPayload).toBe('399') expect(output).not.toContain('000') + expect(output).not.toContain('399') + expect(snapshotText).toContain('recovered after overflow') expect(shiftCallCount).toBe(0) runtime.cleanupSubscription('terminal-1:desktop-1') @@ -612,4 +642,129 @@ describe('terminal subscribe buffering', () => { runtime.cleanupSubscription('terminal-1:phone-1') await dispatchPromise }) + + it('applies inline overflow recovery when the snapshot has no output seq', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const snapshotResolvers: ((value: { + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }) => void)[] = [] + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }>((resolve) => { + snapshotResolvers.push(resolve) + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateMobileViewport: vi.fn().mockResolvedValue(false) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + capabilities: { terminalBinaryStream: 1 } + }), + (msg) => messages.push(msg), + { + connectionId: 'conn-buffered-no-seq', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } + } + ) + + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + let seq = 0 + for (let index = 0; index < 400; index += 1) { + const data = `${String(index).padStart(3, '0')}${'x'.repeat(1021)}` + seq += data.length + dataListenerRef.current?.(data, { seq, rawLength: data.length }) + } + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) + snapshotResolvers[0]?.({ data: '', cols: 120, rows: 40, seq: 0, source: 'headless' }) + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2)) + // Why applying is safe without a seq here: inline recovery serialized + // AFTER dropping the overflowed pending queue, so the snapshot covers + // those chunks by construction — no seq-based trimming is needed. + snapshotResolvers[1]?.({ + data: 'renderer fallback snapshot\r\n', + cols: 120, + rows: 40, + source: 'renderer' + }) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + await vi.runOnlyPendingTimersAsync() + + const decodedFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame): frame is NonNullable => frame !== null) + const snapshotStarts = decodedFrames.filter( + (frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart + ) + // Why seq 1 (layout seq): a no-output-seq snapshot falls back to the + // layout seq on the wire; the recovered data still ships as the first + // and only scrollback snapshot. + expect(snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))).toEqual([ + expect.objectContaining({ kind: 'scrollback', seq: 1 }) + ]) + const snapshotText = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame.payload)) + .join('') + expect(snapshotText).toContain('renderer fallback snapshot') + const output = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame.payload)) + .join('') + // Why empty: the overflowed pending queue was dropped before the + // covering snapshot was serialized; nothing needs replay. + expect(output).toBe('') + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 5f50c32845e..2d7579cce48 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -13,6 +13,15 @@ import * as runtimeMetadataModule from './runtime-metadata' import { readRuntimeMetadata } from './runtime-metadata' import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc' import { parsePairingCode } from '../../shared/pairing' +import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../shared/terminal-stream-protocol' import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' import { DeviceRegistry } from './device-registry' @@ -2760,6 +2769,206 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('keeps active runtime multiplex streams responsive while a background stream is ACK-limited over WebSocket', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const writes: { terminal: string; text: string }[] = [] + const runtime = new OrcaRuntimeService(makeStore() as never) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'multiplex-background-pty' }) + .mockResolvedValueOnce({ id: 'multiplex-active-pty' }) + runtime.setPtyController({ + spawn, + write: (ptyId, data) => { + writes.push({ terminal: ptyId, text: data }) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + const phoneOffer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'phone', + scope: 'mobile' + }) + expect(phoneOffer.available).toBe(true) + if (!phoneOffer.available) { + throw new Error('WebSocket pairing unavailable') + } + const pairing = parsePairingCode(phoneOffer.pairingUrl) + expect(pairing).toBeTruthy() + if (!pairing) { + throw new Error('Pairing URL did not parse') + } + + const metadata = readRuntimeMetadata(userDataPath) + const laptopEndpoint = metadata!.transports[0]!.endpoint + const laptopAuthToken = metadata!.authToken + const worktree = 'id:repo-1::/tmp/worktree-a' + const backgroundLeafId = '11111111-1111-4111-8111-111111111111' + const activeLeafId = '22222222-2222-4222-8222-222222222222' + const backgroundCreateResponse = await sendRequest(laptopEndpoint, { + id: 'laptop_create_background', + authToken: laptopAuthToken, + method: 'terminal.create', + params: { + worktree, + command: 'background', + tabId: 'multiplex-background-tab', + leafId: backgroundLeafId + } + }) + const activeCreateResponse = await sendRequest(laptopEndpoint, { + id: 'laptop_create_active', + authToken: laptopAuthToken, + method: 'terminal.create', + params: { + worktree, + command: 'active', + tabId: 'multiplex-active-tab', + leafId: activeLeafId, + activate: true + } + }) + const backgroundTerminal = (backgroundCreateResponse.result as { terminal: { handle: string } }) + .terminal + const activeTerminal = (activeCreateResponse.result as { terminal: { handle: string } }) + .terminal + + const responses: Record[] = [] + const binaryFrames: Uint8Array[] = [] + const onError = vi.fn() + const subscription = await subscribeRemoteRuntimeRequest( + pairing, + 'terminal.multiplex', + {}, + 15_000, + { + onResponse: (response) => responses.push(response as Record), + onBinary: (bytes) => binaryFrames.push(bytes), + onError + } + ) + + try { + await vi.waitFor(() => + expect( + responses.some( + (response) => (response.result as { type?: string } | undefined)?.type === 'ready' + ) + ).toBe(true) + ) + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 1, + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + payload: encodeTerminalStreamJson({ + streamId: 21, + terminal: backgroundTerminal.handle, + client: { id: 'desktop-background', type: 'desktop' }, + capabilities: { ackOutput: 1 } + }) + }) + ) + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 2, + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + payload: encodeTerminalStreamJson({ + streamId: 22, + terminal: activeTerminal.handle, + client: { id: 'desktop-active', type: 'desktop' }, + capabilities: { ackOutput: 1 } + }) + }) + ) + await vi.waitFor(() => { + const subscribedStreamIds = responses + .map((response) => response.result as { type?: string; streamId?: number } | undefined) + .filter((result) => result?.type === 'subscribed') + .map((result) => result?.streamId) + expect(subscribedStreamIds).toEqual(expect.arrayContaining([21, 22])) + }) + binaryFrames.splice(0) + + const backgroundOutput = 'B'.repeat(700 * 1024) + runtime.onPtyData('multiplex-background-pty', backgroundOutput, 1) + await vi.waitFor(() => { + const backgroundFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21) + const backgroundBytes = backgroundFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(backgroundBytes).toBeGreaterThan(0) + expect(backgroundBytes).toBeLessThan(backgroundOutput.length) + }) + + const frameCountBeforeActive = binaryFrames.length + runtime.onPtyData('multiplex-active-pty', 'ACTIVE_MULTIPLEX_READY\r\n', 2) + await vi.waitFor(() => { + const activeOutput = binaryFrames + .slice(frameCountBeforeActive) + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 22) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(activeOutput).toContain('ACTIVE_MULTIPLEX_READY') + }) + + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 3, + opcode: TerminalStreamOpcode.Input, + streamId: 22, + payload: encodeTerminalStreamText('still interactive\r') + }) + ) + await vi.waitFor(() => + expect(writes).toContainEqual({ + terminal: 'multiplex-active-pty', + text: 'still interactive\r' + }) + ) + + const backgroundBytesBeforeAck = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 4, + opcode: TerminalStreamOpcode.Ack, + streamId: 21, + payload: encodeTerminalStreamJson({ bytes: backgroundBytesBeforeAck }) + }) + ) + await vi.waitFor(() => { + const backgroundBytesAfterAck = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + expect(backgroundBytesAfterAck).toBeGreaterThan(backgroundBytesBeforeAck) + }) + expect(onError).not.toHaveBeenCalled() + } finally { + subscription.close() + await server.stop() + } + }) + it('serves worktree.ps from the runtime summary builder', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never) diff --git a/src/main/runtime/terminal-model-query-authority.test.ts b/src/main/runtime/terminal-model-query-authority.test.ts new file mode 100644 index 00000000000..3217a98022e --- /dev/null +++ b/src/main/runtime/terminal-model-query-authority.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + _resetTerminalModelQueryAuthorityForTest, + clearNativeWindowsConptyPty, + isNativeWindowsConptyPty, + isNativeWindowsLocalPtySpawn, + isTerminalModelQueryAuthorityEnabled, + markNativeWindowsConptyPty, + shouldModelAnswerHiddenPtyQueries +} from './terminal-model-query-authority' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + markHiddenRendererPty, + setRendererPtyDeliveryInterest +} from '../ipc/pty-hidden-delivery-gate' + +const ALL_ON = { + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true +} + +afterEach(() => { + _resetTerminalModelQueryAuthorityForTest() + _resetHiddenRendererPtyDeliveryGateForTest() +}) + +describe('isTerminalModelQueryAuthorityEnabled', () => { + it('defaults on, including for absent settings', () => { + expect(isTerminalModelQueryAuthorityEnabled(ALL_ON)).toBe(true) + expect(isTerminalModelQueryAuthorityEnabled({})).toBe(true) + expect(isTerminalModelQueryAuthorityEnabled(null)).toBe(true) + expect(isTerminalModelQueryAuthorityEnabled(undefined)).toBe(true) + }) + + it('is an independent off switch for the responder alone', () => { + expect( + isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalModelQueryAuthority: false }) + ).toBe(false) + }) + + it('requires both Phase-4 gate switches — no marks exist without them', () => { + expect( + isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalHiddenDeliveryGate: false }) + ).toBe(false) + expect( + isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalMainSideEffectAuthority: false }) + ).toBe(false) + }) +}) + +describe('shouldModelAnswerHiddenPtyQueries', () => { + const answer = (ptyId: string, overrides: Record = {}): boolean => + shouldModelAnswerHiddenPtyQueries({ + ptyId, + settings: { ...ALL_ON, ...overrides }, + hasRemoteViewSubscriber: false + }) + + it('answers only for hidden-marked PTYs (the delivery decision is the reply decision)', () => { + expect(answer('pty-1')).toBe(false) + markHiddenRendererPty('pty-1') + expect(answer('pty-1')).toBe(true) + expect(answer('pty-other')).toBe(false) + }) + + it('yields to registered renderer delivery interest (chunk is delivered to a sidecar)', () => { + markHiddenRendererPty('pty-1') + setRendererPtyDeliveryInterest('pty-1', true) + expect(answer('pty-1')).toBe(false) + setRendererPtyDeliveryInterest('pty-1', false) + expect(answer('pty-1')).toBe(true) + }) + + it('yields while a remote view subscriber is attached', () => { + markHiddenRendererPty('pty-1') + expect( + shouldModelAnswerHiddenPtyQueries({ + ptyId: 'pty-1', + settings: ALL_ON, + hasRemoteViewSubscriber: true + }) + ).toBe(false) + }) + + it('stays silent under any kill switch', () => { + markHiddenRendererPty('pty-1') + expect(answer('pty-1', { terminalModelQueryAuthority: false })).toBe(false) + expect(answer('pty-1', { terminalHiddenDeliveryGate: false })).toBe(false) + expect(answer('pty-1', { terminalMainSideEffectAuthority: false })).toBe(false) + }) +}) + +describe('isNativeWindowsLocalPtySpawn (main-side mirror of isLocalNativeWindowsPty)', () => { + const base = { + connectionId: null, + cwd: 'C:\\repo', + shellOverride: undefined, + platform: 'win32' as NodeJS.Platform + } + + it('matches local native Windows spawns', () => { + expect(isNativeWindowsLocalPtySpawn(base)).toBe(true) + expect(isNativeWindowsLocalPtySpawn({ ...base, connectionId: undefined })).toBe(true) + expect( + isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'C:\\Tools\\powershell.exe' }) + ).toBe(true) + }) + + it('rejects non-Windows hosts', () => { + expect(isNativeWindowsLocalPtySpawn({ ...base, platform: 'darwin' })).toBe(false) + expect(isNativeWindowsLocalPtySpawn({ ...base, platform: 'linux' })).toBe(false) + }) + + it('rejects SSH-backed spawns', () => { + expect(isNativeWindowsLocalPtySpawn({ ...base, connectionId: 'ssh-1' })).toBe(false) + }) + + it('rejects WSL cwds and WSL shell overrides', () => { + expect( + isNativeWindowsLocalPtySpawn({ ...base, cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me' }) + ).toBe(false) + expect(isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'wsl.exe' })).toBe(false) + expect( + isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'C:\\Windows\\System32\\wsl.exe' }) + ).toBe(false) + expect(isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'wsl' })).toBe(false) + }) +}) + +describe('native-Windows ConPTY spawn record', () => { + it('marks, reads, and clears per PTY', () => { + expect(isNativeWindowsConptyPty('pty-1')).toBe(false) + markNativeWindowsConptyPty('pty-1') + expect(isNativeWindowsConptyPty('pty-1')).toBe(true) + expect(isNativeWindowsConptyPty('pty-2')).toBe(false) + clearNativeWindowsConptyPty('pty-1') + expect(isNativeWindowsConptyPty('pty-1')).toBe(false) + }) +}) diff --git a/src/main/runtime/terminal-model-query-authority.ts b/src/main/runtime/terminal-model-query-authority.ts new file mode 100644 index 00000000000..e1e0f9c28ef --- /dev/null +++ b/src/main/runtime/terminal-model-query-authority.ts @@ -0,0 +1,111 @@ +/** + * Phase 5 of the terminal model/view architecture: main-side terminal query + * authority (docs/reference/terminal-query-authority.md). + * + * The delivery decision is the reply decision: main answers a query iff the + * hidden-delivery gate dropped the chunk that carried it. This module owns + * the responder kill-switch predicate and the main-side mirror of the + * renderer's native-Windows-ConPTY determination, recorded per PTY at spawn + * so the runtime emulator can register the DA1 override before byte zero. + */ +import type { GlobalSettings } from '../../shared/types' +import { isWslUncPath } from '../../shared/wsl-paths' +import { + isHiddenPtyDeliveryGateEnabled, + shouldDropHiddenRendererPtyData +} from '../ipc/pty-hidden-delivery-gate' + +export type TerminalModelQueryAuthoritySettings = Pick< + GlobalSettings, + 'terminalMainSideEffectAuthority' | 'terminalHiddenDeliveryGate' | 'terminalModelQueryAuthority' +> + +/** Responder kill switch: requires BOTH Phase-4 gate switches (no marks/drops + * exist without them) plus the Phase-5-specific independent off switch. */ +export function isTerminalModelQueryAuthorityEnabled( + settings: TerminalModelQueryAuthoritySettings | null | undefined +): boolean { + return isHiddenPtyDeliveryGateEnabled(settings) && settings?.terminalModelQueryAuthority !== false +} + +/** Per-chunk reply-ownership predicate, evaluated once at ingestion in + * OrcaRuntimeService.onPtyData — the same module state and tick as the + * hidden-gate drop sites, so "chunk dropped" and "main answers" cannot + * diverge for live chunks. Remote view subscribers (mobile/web/remote + * desktop xterms on the multiplexed stream) keep view authority, so main + * yields while one is attached. */ +export function shouldModelAnswerHiddenPtyQueries(opts: { + ptyId: string + settings: TerminalModelQueryAuthoritySettings | null | undefined + hasRemoteViewSubscriber: boolean +}): boolean { + return ( + isTerminalModelQueryAuthorityEnabled(opts.settings) && + !opts.hasRemoteViewSubscriber && + shouldDropHiddenRendererPtyData(opts.ptyId, opts.settings) + ) +} + +/** Main-side mirror of the renderer's isLocalNativeWindowsPty + * (windows-pty-compatibility.ts), computed from spawn-time facts: local or + * daemon provider (no SSH connection), win32 host, and not a WSL shell. */ +export function isNativeWindowsLocalPtySpawn(opts: { + connectionId: string | null | undefined + cwd: string | null | undefined + shellOverride: string | null | undefined + platform?: NodeJS.Platform +}): boolean { + if ((opts.platform ?? process.platform) !== 'win32') { + return false + } + if (opts.connectionId) { + return false + } + if (isWslUncPath(opts.cwd ?? '')) { + return false + } + if (/(?:^|[/\\])wsl(?:\.exe)?$/i.test(opts.shellOverride ?? '')) { + return false + } + return true +} + +// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts records +// the determination at spawn, the runtime consults it at emulator creation. +// Daemon-adopted PTYs from a previous app run carry no mark — acceptable: +// ConPTY's blocking DA1 only fires at spawn, which happened in a prior life. +const nativeWindowsConptyPtys = new Set() + +// Why installers: the mark lands after the awaited spawn response, but daemon +// stream data (warm-reattach flush) can lazy-create the runtime emulator +// first. The runtime registers an installer so marking retrofits the DA1 +// override onto an existing emulator; installation is idempotent emulator-side. +type ConptyDa1OverrideInstaller = (ptyId: string) => void +const conptyDa1OverrideInstallers = new Set() + +export function registerConptyDa1OverrideInstaller(installer: ConptyDa1OverrideInstaller): void { + conptyDa1OverrideInstallers.add(installer) +} + +export function markNativeWindowsConptyPty(id: string): void { + nativeWindowsConptyPtys.add(id) + for (const installer of conptyDa1OverrideInstallers) { + installer(id) + } +} + +export function isNativeWindowsConptyPty(id: string): boolean { + return nativeWindowsConptyPtys.has(id) +} + +/** Wired into clearProviderPtyState so every PTY teardown path releases the + * spawn record. */ +export function clearNativeWindowsConptyPty(id: string): void { + nativeWindowsConptyPtys.delete(id) +} + +/** Test seam: reset module state between tests. */ +export function _resetTerminalModelQueryAuthorityForTest(): void { + nativeWindowsConptyPtys.clear() + conptyDa1OverrideInstallers.clear() +} diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts new file mode 100644 index 00000000000..b6988e06731 --- /dev/null +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -0,0 +1,827 @@ +/** + * Phase 5 model query responder (docs/reference/terminal-query-authority.md): + * reply parity through the runtime emulator, the per-chunk ownership matrix, + * the main-side replay guard, and the ingestion-time capture race. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { HeadlessEmulator } from '../daemon/headless-emulator' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + markHiddenRendererPty, + setRendererPtyDeliveryInterest, + unmarkHiddenRendererPty +} from '../ipc/pty-hidden-delivery-gate' +import { + _resetTerminalModelQueryAuthorityForTest, + markNativeWindowsConptyPty +} from './terminal-model-query-authority' +import { + _resetTerminalViewAttributesForTest, + setTerminalViewAttributes +} from './terminal-view-attribute-store' +import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes' + +const settingsState = { + terminalMainSideEffectAuthority: true as boolean, + terminalHiddenDeliveryGate: true as boolean, + terminalModelQueryAuthority: true as boolean +} + +const store = { + getRepo: () => undefined, + getRepos: () => [], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getGitHubCache: () => ({ pr: {}, issue: {} }) as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + terminalMainSideEffectAuthority: settingsState.terminalMainSideEffectAuthority, + terminalHiddenDeliveryGate: settingsState.terminalHiddenDeliveryGate, + terminalModelQueryAuthority: settingsState.terminalModelQueryAuthority + }) +} + +type RendererBufferStub = { data: string; cols: number; rows: number } + +function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = {}) { + const runtime = new OrcaRuntimeService(store) + const replies: { ptyId: string; data: string }[] = [] + runtime.setPtyController({ + write: (ptyId, data) => { + replies.push({ ptyId, data }) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => ({ cols: 80, rows: 24 }), + resize: () => true, + ...(opts.rendererBuffer + ? { + hasRendererSerializer: () => true, + serializeBuffer: async () => opts.rendererBuffer ?? null + } + : {}) + }) + return { runtime, replies } +} + +/** Awaits the per-PTY emulator writeChain so queued chunk links (and the + * replies they forward) have settled. */ +async function settle(runtime: OrcaRuntimeService, ptyId: string): Promise { + await runtime.serializeMainTerminalBuffer(ptyId) +} + +/** Renderer-pushed attribute snapshot with distinct, pinned slot values so + * reply fixtures cannot pass by coincidence. */ +function viewAttributes(overrides: Partial = {}): TerminalViewAttributes { + const ansi = Array.from( + { length: 256 }, + (_, i) => [i, (i * 2) % 256, (i * 3) % 256] as TerminalViewRgb + ) + ansi[1] = [0xcc, 0x00, 0x00] + return { + foreground: [0xd0, 0xd0, 0xd0], + background: [0x1e, 0x1e, 0x2e], + cursor: [0xff, 0x99, 0x00], + ansi, + colorSchemeMode: 'dark', + cursorStyle: 'bar', + cursorBlink: true, + ...overrides + } +} + +afterEach(() => { + _resetHiddenRendererPtyDeliveryGateForTest() + _resetTerminalModelQueryAuthorityForTest() + _resetTerminalViewAttributesForTest() + settingsState.terminalMainSideEffectAuthority = true + settingsState.terminalHiddenDeliveryGate = true + settingsState.terminalModelQueryAuthority = true +}) + +describe('reply parity for hidden-dropped chunks', () => { + // Expected replies pinned from the design doc and verified against the + // bundled @xterm/headless build — the same core the renderer runs, so + // parity is structural for static and model-state classes. + it.each([ + ['DA1 CSI c', '\x1b[c', ['\x1b[?1;2c']], + ['DA1 CSI 0 c variant', '\x1b[0c', ['\x1b[?1;2c']], + ['DA2', '\x1b[>c', ['\x1b[>0;276;0c']], + ['DSR 5n operating status', '\x1b[5n', ['\x1b[0n']], + ['CPR 6n at origin', '\x1b[6n', ['\x1b[1;1R']], + ['CPR 6n reports the model cursor position', 'hello\r\nworld\x1b[6n', ['\x1b[2;6R']], + ['DECXCPR ?6n', '\x1b[?6n', ['\x1b[?1;1R']], + ['DECRPM ?1 DECCKM default', '\x1b[?1$p', ['\x1b[?1;2$y']], + ['DECRPM ?6 DECOM default', '\x1b[?6$p', ['\x1b[?6;2$y']], + ['DECRPM ?7 DECAWM default', '\x1b[?7$p', ['\x1b[?7;1$y']], + ['DECRPM ?25 DECTCEM default', '\x1b[?25$p', ['\x1b[?25;1$y']], + ['DECRPM ?1004 focus events default', '\x1b[?1004$p', ['\x1b[?1004;2$y']], + ['DECRPM ?1006 SGR mouse default', '\x1b[?1006$p', ['\x1b[?1006;2$y']], + ['DECRPM ?1016 SGR pixels default', '\x1b[?1016$p', ['\x1b[?1016;2$y']], + ['DECRPM ?1049 alt screen default', '\x1b[?1049$p', ['\x1b[?1049;2$y']], + ['DECRPM ?2004 bracketed paste default', '\x1b[?2004$p', ['\x1b[?2004;2$y']], + ['DECRPM ?2026 synchronized output default', '\x1b[?2026$p', ['\x1b[?2026;2$y']], + ['DECRPM reports a set mode as enabled', '\x1b[?2004h\x1b[?2004$p', ['\x1b[?2004;1$y']], + ['DECRPM unknown mode reports 0', '\x1b[?12345$p', ['\x1b[?12345;0$y']], + ['DECRQM ANSI insert mode', '\x1b[4$p', ['\x1b[4;2$y']], + ['DECRQSS DECSTBM default margins', '\x1bP$qr\x1b\\', ['\x1bP1$r1;24r\x1b\\']], + ['DECRQSS DECSTBM after margin set', '\x1b[5;20r\x1bP$qr\x1b\\', ['\x1bP1$r5;20r\x1b\\']], + ['DECRQSS DECSCUSR default cursor', '\x1bP$q q\x1b\\', ['\x1bP1$r2 q\x1b\\']], + ['DECRQSS DECSCA', '\x1bP$q"q\x1b\\', ['\x1bP1$r0"q\x1b\\']], + ['DECRQSS SGR', '\x1bP$qm\x1b\\', ['\x1bP1$r0m\x1b\\']], + ['XTVERSION', '\x1b[>0q', ['\x1bP>|xterm.js(6.0.0)\x1b\\']], + ['kitty CSI ? u default flags', '\x1b[?u', ['\x1b[?0u']], + ['kitty CSI ? u reports pushed flags', '\x1b[=5;1u\x1b[?u', ['\x1b[?5u']] + ])('%s', async (_label, chunk, expectedReplies) => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-q') + + runtime.onPtyData('pty-q', chunk, Date.now()) + await settle(runtime, 'pty-q') + + expect(replies.map((reply) => reply.data)).toEqual(expectedReplies) + expect(replies.every((reply) => reply.ptyId === 'pty-q')).toBe(true) + }) + + it.each([ + ['XTWINOPS', '\x1b[14t'], + ['XTGETTCAP', '\x1bP+q544e\x1b\\'], + ['DSR ?15n printer status', '\x1b[?15n'], + ['DSR ?25n UDK status', '\x1b[?25n'], + ['DSR ?26n keyboard status', '\x1b[?26n'], + ['DSR ?53n locator status', '\x1b[?53n'], + // View-attribute class: silent until the slice-2 renderer attribute push + // — a fabricated default would resurrect the default-black OSC-11 bug. + ['OSC 10 foreground query', '\x1b]10;?\x07'], + ['OSC 11 background query', '\x1b]11;?\x07'], + ['OSC 12 cursor-color query', '\x1b]12;?\x1b\\'], + ['OSC 4 palette query', '\x1b]4;1;?\x07'], + ['DSR ?996n color-scheme query', '\x1b[?996n'] + ])('stays silent for %s', async (_label, chunk) => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-q') + + runtime.onPtyData('pty-q', chunk, Date.now()) + await settle(runtime, 'pty-q') + + expect(replies).toEqual([]) + }) +}) + +describe('reply ownership matrix', () => { + const DA1 = '\x1b[c' + + it('never answers delivered (unmarked) chunks — the visible xterm owns them', async () => { + const { runtime, replies } = createResponderRuntime() + + runtime.onPtyData('pty-v', DA1, Date.now()) + await settle(runtime, 'pty-v') + + expect(replies).toEqual([]) + }) + + it('never answers while renderer delivery interest holds the chunk delivered', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-i') + setRendererPtyDeliveryInterest('pty-i', true) + + runtime.onPtyData('pty-i', DA1, Date.now()) + await settle(runtime, 'pty-i') + + expect(replies).toEqual([]) + }) + + it.each([ + ['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)], + ['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)], + [ + 'terminalMainSideEffectAuthority', + () => (settingsState.terminalMainSideEffectAuthority = false) + ] + ])('never answers with kill switch %s off', async (_label, flip) => { + flip() + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-k') + + runtime.onPtyData('pty-k', DA1, Date.now()) + await settle(runtime, 'pty-k') + + expect(replies).toEqual([]) + }) + + it('yields while a remote view subscriber is attached and resumes on release', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-r') + const release = runtime.registerRemoteTerminalViewSubscriber('pty-r') + + runtime.onPtyData('pty-r', DA1, Date.now()) + await settle(runtime, 'pty-r') + expect(replies).toEqual([]) + + release() + // Releases are idempotent: a double release must not unbalance the count. + release() + runtime.onPtyData('pty-r', DA1, Date.now()) + await settle(runtime, 'pty-r') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c']) + }) + + it('counts overlapping remote view subscribers', () => { + const { runtime } = createResponderRuntime() + const releaseA = runtime.registerRemoteTerminalViewSubscriber('pty-m') + const releaseB = runtime.registerRemoteTerminalViewSubscriber('pty-m') + expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(true) + releaseA() + expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(true) + releaseB() + expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(false) + }) + + it('treats mobile subscriber records as remote view subscribers', async () => { + const { runtime } = createResponderRuntime() + await runtime.handleMobileSubscribe('pty-mob', 'client-1', { cols: 40, rows: 20 }) + expect(runtime.hasRemoteTerminalViewSubscriber('pty-mob')).toBe(true) + }) + + it('answers a dropped-chunk query exactly once', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-once') + + runtime.onPtyData('pty-once', DA1, Date.now()) + await settle(runtime, 'pty-once') + + expect(replies).toHaveLength(1) + }) +}) + +describe('main-side replay guard', () => { + const DA1 = '\x1b[c' + + it('never answers queries embedded in a seeded snapshot, then answers live bytes', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-seed') + + runtime.seedHeadlessTerminal('pty-seed', `restored prompt${DA1}`) + await settle(runtime, 'pty-seed') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-seed', DA1, Date.now()) + await settle(runtime, 'pty-seed') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c']) + }) + + it('never answers queries replayed by renderer-buffer hydration', async () => { + const { runtime, replies } = createResponderRuntime({ + rendererBuffer: { data: `restored screen${DA1}`, cols: 80, rows: 24 } + }) + markHiddenRendererPty('pty-hyd') + + // First live byte triggers maybeHydrateHeadlessFromRenderer; the hydration + // seed parses the embedded DA1 but must not forward its reply. + runtime.onPtyData('pty-hyd', 'live output', Date.now()) + await settle(runtime, 'pty-hyd') + + expect(replies).toEqual([]) + }) +}) + +describe('kitty flag re-seed parity (terminal-query-authority.md §kitty)', () => { + it('answers ?u with the persisted snapshot flags after a re-seed, silently applied', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-kitty') + + // Daemon warm-reattach threads modes.kittyKeyboardFlags through the + // spawn result into the seed; applying them is a seed-side write and + // must answer no one (main-side replay guard). + runtime.seedHeadlessTerminal('pty-kitty', 'restored prompt', undefined, { + kittyKeyboardFlags: 5 + }) + await settle(runtime, 'pty-kitty') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-kitty', '\x1b[?u', Date.now()) + await settle(runtime, 'pty-kitty') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?5u']) + }) + + it('answers ?0u when the snapshot carried no flags (fresh-shell paths)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-kitty0') + + runtime.seedHeadlessTerminal('pty-kitty0', 'restored prompt') + runtime.onPtyData('pty-kitty0', '\x1b[?u', Date.now()) + await settle(runtime, 'pty-kitty0') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?0u']) + }) +}) + +describe('ingestion-time ownership capture', () => { + const DA1 = '\x1b[c' + + it('still answers when the hidden mark flips off between ingestion and the async write', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-race') + + runtime.onPtyData('pty-race', DA1, Date.now()) + // Flip before the queued writeChain link runs: the captured decision wins. + unmarkHiddenRendererPty('pty-race') + await settle(runtime, 'pty-race') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c']) + }) + + it('stays silent when the hidden mark lands after ingestion', async () => { + const { runtime, replies } = createResponderRuntime() + + runtime.onPtyData('pty-race2', DA1, Date.now()) + markHiddenRendererPty('pty-race2') + await settle(runtime, 'pty-race2') + + expect(replies).toEqual([]) + }) +}) + +describe('stale writeChain links after dispose', () => { + const DA1 = '\x1b[c' + + it('never forwards a queued reply once the PTY state is disposed', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-stale') + + // Queue a forward-flagged chain link, then dispose before it runs. + runtime.onPtyData('pty-stale', DA1, Date.now()) + runtime.onPtyExit('pty-stale', 0) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(replies).toEqual([]) + }) + + it('never injects a stale reply into a successor PTY reusing the session id', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-reuse') + + // Daemon respawns reuse session ids: dispose with the flagged link still + // queued, then re-create the same id before the link runs. + runtime.onPtyData('pty-reuse', DA1, Date.now()) + runtime.onPtyExit('pty-reuse', 0) + runtime.onPtyData('pty-reuse', 'fresh shell banner', Date.now()) + await settle(runtime, 'pty-reuse') + + expect(replies).toEqual([]) + }) +}) + +describe('ConPTY DA1 override', () => { + it('retrofits the override when the spawn mark lands after data created the emulator', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-win-late') + + // Daemon warm-reattach flush: stream data creates the emulator before + // the awaited spawn response marks the PTY native-Windows. + runtime.onPtyData('pty-win-late', 'warm reattach flush', Date.now()) + markNativeWindowsConptyPty('pty-win-late') + + runtime.onPtyData('pty-win-late', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win-late') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c']) + }) + + it('keeps the override single-reply when installed at creation and marked again', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win-idem') + markHiddenRendererPty('pty-win-idem') + + runtime.onPtyData('pty-win-idem', 'boot output', Date.now()) + // A duplicate mark (e.g. respawn against a live emulator) must not stack + // a second handler that double-replies. + markNativeWindowsConptyPty('pty-win-idem') + + runtime.onPtyData('pty-win-idem', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win-idem') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c']) + }) + + it('answers CSI ?61;4c for marked native-Windows PTYs, suppressing the core ?1;2c', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win') + markHiddenRendererPty('pty-win') + + runtime.onPtyData('pty-win', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c']) + }) + + it('lets non-primary device-attribute queries fall through to the core', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win2') + markHiddenRendererPty('pty-win2') + + runtime.onPtyData('pty-win2', '\x1b[>c', Date.now()) + await settle(runtime, 'pty-win2') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[>0;276;0c']) + }) + + it('keeps the override silent for delivered chunks', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win3') + + runtime.onPtyData('pty-win3', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win3') + + expect(replies).toEqual([]) + }) +}) + +describe('HeadlessEmulator forwarding window', () => { + it('forwards replies only for writes flagged forwardQueryReplies', async () => { + const onQueryReply = vi.fn() + const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply }) + try { + await emulator.write('\x1b[c') + expect(onQueryReply).not.toHaveBeenCalled() + + await emulator.write('\x1b[c', { forwardQueryReplies: true }) + expect(onQueryReply).toHaveBeenCalledTimes(1) + expect(onQueryReply).toHaveBeenCalledWith('\x1b[?1;2c') + } finally { + emulator.dispose() + } + }) + + it('scopes the async-fallback forwarding window to the flagged chunk parse', async () => { + const onQueryReply = vi.fn() + const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply }) + // Force the async write path (xterm deprecates writeSync; the fallback + // must stay structurally safe without writeChain serialization). + const internals = emulator as unknown as { terminal: { _core: { writeSync?: unknown } } } + internals.terminal._core.writeSync = undefined + try { + // Enqueue an unflagged seed carrying a query, then a flagged live + // chunk, WITHOUT awaiting between them: both sit in xterm's write + // queue together. The seed parse must not see an open window. + const seed = emulator.write('seeded\x1b[c') + const live = emulator.write('\x1b[5n', { forwardQueryReplies: true }) + await Promise.all([seed, live]) + + expect(onQueryReply.mock.calls.map((call) => call[0])).toEqual(['\x1b[0n']) + } finally { + emulator.dispose() + } + }) + + it('keeps the ConPTY override inside the forwarding window', async () => { + const onQueryReply = vi.fn() + const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply }) + emulator.installConptyPrimaryDeviceAttributesOverride() + try { + // Unflagged (replayed/seeded) DA1 must answer no one even with the + // override installed. + await emulator.write('\x1b[c') + expect(onQueryReply).not.toHaveBeenCalled() + + await emulator.write('\x1b[c', { forwardQueryReplies: true }) + expect(onQueryReply).toHaveBeenCalledTimes(1) + expect(onQueryReply).toHaveBeenCalledWith('\x1b[?61;4c') + } finally { + emulator.dispose() + } + }) +}) + +describe('view-attribute bridge replies (after renderer push)', () => { + // Reply bytes pinned to the renderer xterm's format: OSC replies use the + // queried ident, 16-bit doubled-byte channels, and ST termination + // (CoreBrowserTerminal._handleColorEvent + toRgbString); ?996n answers with + // the contour 997 report, same bytes as mode2031SequenceFor. + it.each([ + ['OSC 10 foreground', '\x1b]10;?\x07', ['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\']], + ['OSC 11 background', '\x1b]11;?\x07', ['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']], + ['OSC 12 cursor color', '\x1b]12;?\x1b\\', ['\x1b]12;rgb:ffff/9999/0000\x1b\\']], + ['OSC 4 named palette slot', '\x1b]4;1;?\x07', ['\x1b]4;1;rgb:cccc/0000/0000\x1b\\']], + ['OSC 4 extended palette slot', '\x1b]4;196;?\x07', ['\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\']], + [ + 'OSC 4 multiple slots in one sequence', + '\x1b]4;1;?;196;?\x07', + ['\x1b]4;1;rgb:cccc/0000/0000\x1b\\', '\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\'] + ], + [ + 'OSC 10 stacked params report foreground then background', + '\x1b]10;?;?\x07', + ['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\', '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'] + ], + ['DSR ?996n dark', '\x1b[?996n', ['\x1b[?997;1n']], + ['DECRQSS DECSCUSR from pushed cursor options', '\x1bP$q q\x1b\\', ['\x1bP1$r5 q\x1b\\']], + ['DECRQM ?12 from pushed cursorBlink', '\x1b[?12$p', ['\x1b[?12;1$y']] + ])('%s', async (_label, chunk, expectedReplies) => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-view') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-view', chunk, Date.now()) + await settle(runtime, 'pty-view') + + expect(replies.map((reply) => reply.data)).toEqual(expectedReplies) + }) + + it('answers ?996n from palette luminance, not the pushed app mode (dark palette, light app mode)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-dark') + // Supported divergence: light app mode with terminalUseSeparateLightTheme + // off renders a dark terminal theme. A visible xterm answers ?996n from + // bg/fg relative luminance (CoreBrowserTerminal._reportColorScheme), so + // the hidden reply must say dark here too. + setTerminalViewAttributes(viewAttributes({ colorSchemeMode: 'light' })) + + runtime.onPtyData('pty-lum-dark', '\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-dark') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;1n']) + }) + + it('answers ?996n light for a light palette regardless of the pushed app mode', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-light') + setTerminalViewAttributes( + viewAttributes({ + foreground: [0x33, 0x33, 0x33], + background: [0xfa, 0xfa, 0xfa], + colorSchemeMode: 'dark' + }) + ) + + runtime.onPtyData('pty-lum-light', '\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-light') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n']) + }) + + it('answers ?996n from OSC-SET-mutated colors like a visible xterm', async () => { + // _reportColorScheme reads the CURRENT theme-service colors, which include + // OSC 10/11 SET mutations — the per-PTY overlays layer the same way. + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-set') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-lum-set', '\x1b]11;#ffffff\x07\x1b]10;#101010\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-set') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n']) + }) + + it('stays silent before the first push, then answers the same query after it', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-first') + + runtime.onPtyData('pty-first', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-first') + // No fabricated defaults: silence is the documented hidden status quo. + expect(replies).toEqual([]) + + setTerminalViewAttributes(viewAttributes()) + runtime.onPtyData('pty-first', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-first') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it('retrofits cursor options onto already-live emulators when the push lands late', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-late') + + // Emulator exists before any push: core default DECSCUSR is steady block. + runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now()) + await settle(runtime, 'pty-late') + expect(replies.map((reply) => reply.data)).toEqual(['\x1bP1$r2 q\x1b\\']) + + setTerminalViewAttributes(viewAttributes({ cursorStyle: 'underline', cursorBlink: false })) + runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now()) + await settle(runtime, 'pty-late') + expect(replies.map((reply) => reply.data).at(-1)).toBe('\x1bP1$r4 q\x1b\\') + }) +}) + +describe('per-PTY OSC color SET layering', () => { + it('layers an OSC 4 SET over the pushed base, isolated per PTY', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-a') + markHiddenRendererPty('pty-b') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-a', '\x1b]4;1;rgb:00/ff/00\x07\x1b]4;1;?\x07', Date.now()) + runtime.onPtyData('pty-b', '\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-a') + await settle(runtime, 'pty-b') + + expect(replies).toEqual([ + { ptyId: 'pty-a', data: '\x1b]4;1;rgb:0000/ffff/0000\x1b\\' }, + { ptyId: 'pty-b', data: '\x1b]4;1;rgb:cccc/0000/0000\x1b\\' } + ]) + }) + + it('restores a single indexed color via OSC 104;', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-104') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-104', '\x1b]4;1;#00ff00\x07\x1b]104;1\x07\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-104') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:cccc/0000/0000\x1b\\']) + }) + + it('restores the whole indexed table via bare OSC 104', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-104all') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData( + 'pty-104all', + '\x1b]4;1;#00ff00;196;#0000ff\x07\x1b]104\x07\x1b]4;1;?;196;?\x07', + Date.now() + ) + await settle(runtime, 'pty-104all') + + expect(replies.map((reply) => reply.data)).toEqual([ + '\x1b]4;1;rgb:cccc/0000/0000\x1b\\', + '\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\' + ]) + }) + + it('layers OSC 10/11/12 SETs and restores them via 110/111/112', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-special') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData( + 'pty-special', + '\x1b]10;#010203\x07\x1b]11;rgb:ff/ff/ff\x07\x1b]12;#0a0b0c\x07' + + '\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07' + + '\x1b]110\x07\x1b]111\x07\x1b]112\x07' + + '\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07', + Date.now() + ) + await settle(runtime, 'pty-special') + + expect(replies.map((reply) => reply.data)).toEqual([ + '\x1b]10;rgb:0101/0202/0303\x1b\\', + '\x1b]11;rgb:ffff/ffff/ffff\x1b\\', + '\x1b]12;rgb:0a0a/0b0b/0c0c\x1b\\', + '\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\', + '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\', + '\x1b]12;rgb:ffff/9999/0000\x1b\\' + ]) + }) + + it('tracks SET mutations parsed from a seed without replying, like renderer replay', async () => { + // Cold-restore scrollback replayed into a visible renderer xterm re-applies + // OSC SETs to its theme service; the model mirrors that state — but the + // replay guard still keeps the seed from ANSWERING anything. + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-seedset') + setTerminalViewAttributes(viewAttributes()) + + runtime.seedHeadlessTerminal('pty-seedset', 'restored\x1b]4;1;#00ff00\x07\x1b]4;1;?\x07') + await settle(runtime, 'pty-seedset') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-seedset', '\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-seedset') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:0000/ffff/0000\x1b\\']) + }) + + it('preserves per-PTY overrides on an identical re-push (fresh renderer process)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-idem') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-idem', '\x1b]11;#ffffff\x07', Date.now()) + await settle(runtime, 'pty-idem') + + // A second window / renderer reload / macOS re-activation re-pushes + // byte-identical attributes (its publisher dedupe is per-process). That is + // not a theme apply, so the OSC SET overlay must survive. + setTerminalViewAttributes(viewAttributes()) + runtime.onPtyData('pty-idem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-idem') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:ffff/ffff/ffff\x1b\\']) + }) + + it('clears per-PTY overrides when a new push lands (theme apply parity)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-clear') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-clear', '\x1b]11;#ffffff\x07', Date.now()) + await settle(runtime, 'pty-clear') + + // A theme apply overwrites OSC-SET-mutated colors on visible panes too + // (ThemeService._setTheme), so the model mirrors that on every CHANGED + // push (identical re-pushes are filtered — see the test above). + setTerminalViewAttributes(viewAttributes({ background: [0x10, 0x20, 0x30] })) + runtime.onPtyData('pty-clear', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-clear') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1010/2020/3030\x1b\\']) + }) +}) + +describe('view-attribute replay guard and suppression', () => { + it('never answers view-attribute queries embedded in a seeded snapshot', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vseed') + setTerminalViewAttributes(viewAttributes()) + + runtime.seedHeadlessTerminal('pty-vseed', 'prompt\x1b]11;?\x07\x1b[?996n') + await settle(runtime, 'pty-vseed') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-vseed', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vseed') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it('never answers view-attribute queries replayed by renderer-buffer hydration', async () => { + const { runtime, replies } = createResponderRuntime({ + rendererBuffer: { data: 'restored\x1b]11;?\x07\x1b[?996n', cols: 80, rows: 24 } + }) + markHiddenRendererPty('pty-vhyd') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vhyd', 'live output', Date.now()) + await settle(runtime, 'pty-vhyd') + + expect(replies).toEqual([]) + }) + + it('never answers a delivered (unmarked) view-attribute query — the visible xterm owns it', async () => { + const { runtime, replies } = createResponderRuntime() + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vvis', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-vvis') + + expect(replies).toEqual([]) + }) + + it('never answers while renderer delivery interest holds the chunk delivered', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vint') + setRendererPtyDeliveryInterest('pty-vint', true) + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vint', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vint') + + expect(replies).toEqual([]) + }) + + it('yields view-attribute replies while a remote view subscriber is attached', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vrem') + setTerminalViewAttributes(viewAttributes()) + const release = runtime.registerRemoteTerminalViewSubscriber('pty-vrem') + + runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vrem') + expect(replies).toEqual([]) + + release() + runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vrem') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it.each([ + ['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)], + ['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)], + [ + 'terminalMainSideEffectAuthority', + () => (settingsState.terminalMainSideEffectAuthority = false) + ] + ])('never answers view-attribute queries with kill switch %s off', async (_label, flip) => { + flip() + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vkill') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vkill', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-vkill') + + expect(replies).toEqual([]) + }) +}) diff --git a/src/main/runtime/terminal-view-attribute-store.ts b/src/main/runtime/terminal-view-attribute-store.ts new file mode 100644 index 00000000000..ee55581bfe9 --- /dev/null +++ b/src/main/runtime/terminal-view-attribute-store.ts @@ -0,0 +1,57 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): main-side cache of the renderer's `pty:terminalViewAttributes` + * push. One app-global snapshot, not per-PTY — per-pane font zoom never + * affects these attributes and the color/cursor settings are global. + * + * Null until the first push, and the responder answers NO view-attribute + * query while null (silent-until-first-push): a fabricated default would + * resurrect the default-black OSC-11 bug. Staleness is bounded by one IPC + * hop; subscribed TUIs are corrected by the renderer-owned 2031/997 flip. + */ +import { + terminalViewAttributesEqual, + type TerminalViewAttributes +} from '../../shared/terminal-view-attributes' + +// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts receives +// the push, the runtime emulators consult it at reply time via the getter. +let currentAttributes: TerminalViewAttributes | null = null + +// Why appliers (pattern of registerConptyDa1OverrideInstaller): each push +// must also reach already-live emulators — cursor options under the replay +// guard, plus the per-PTY override reset a theme apply implies. +type TerminalViewAttributesApplier = (attributes: TerminalViewAttributes) => void +const pushAppliers = new Set() + +export function registerTerminalViewAttributesApplier( + applier: TerminalViewAttributesApplier +): void { + pushAppliers.add(applier) +} + +/** Called from the pty:terminalViewAttributes IPC handler with a validated + * payload. Last push wins (replies always use the freshest snapshot). */ +export function setTerminalViewAttributes(attributes: TerminalViewAttributes): void { + // Why idempotent: the renderer publisher's dedupe is per-process, so a + // fresh renderer (second window, reload, macOS re-activation) re-pushes + // identical attributes. That is not a theme apply — fanning out would wipe + // every PTY's OSC SET overlay while visible panes keep theirs. + if (currentAttributes && terminalViewAttributesEqual(currentAttributes, attributes)) { + return + } + currentAttributes = attributes + for (const applier of pushAppliers) { + applier(attributes) + } +} + +export function getTerminalViewAttributes(): TerminalViewAttributes | null { + return currentAttributes +} + +/** Test seam: reset module state between tests. */ +export function _resetTerminalViewAttributesForTest(): void { + currentAttributes = null + pushAppliers.clear() +} diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts new file mode 100644 index 00000000000..0b4e8188c07 --- /dev/null +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -0,0 +1,48 @@ +import { vi, type Mock } from 'vitest' +import type { BrowserWindow } from 'electron' +import type { SshConnection } from './ssh-connection' +import type { Store } from '../persistence' +import type { SshPortForwardManager } from './ssh-port-forward' +import { deployAndLaunchRelay } from './ssh-relay-deploy' + +type SshRelaySessionTestDeps = { + mockConn: SshConnection + mockStore: Store + mockPortForward: SshPortForwardManager + getMainWindow: Mock<() => BrowserWindow | null> + mockWindow: BrowserWindow +} + +export function createMockDeps(): SshRelaySessionTestDeps { + const mockConn = {} as SshConnection + const mockStore = { + getRepos: vi.fn().mockReturnValue([]), + getSshRemotePtyLeases: vi.fn().mockReturnValue([]), + markSshRemotePtyLease: vi.fn(), + markSshRemotePtyLeases: vi.fn() + } as unknown as Store + const mockPortForward = { + removeAllForwards: vi.fn() + } as unknown as SshPortForwardManager + const mockWindow = { + isDestroyed: () => false, + // Why: the port scanner visibility-gates its ticks; a visible mock window + // keeps establish-path tests exercising the scan-on-ready behavior. + isVisible: () => true, + isMinimized: () => false, + webContents: { send: vi.fn() } + } as unknown as BrowserWindow + const getMainWindow = vi.fn().mockReturnValue(mockWindow) + return { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } +} + +export function mockDeploySuccess(): void { + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: { + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }, + platform: 'linux-x64' + }) +} diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index aebfd234b5c..82cbfaee42c 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -1,10 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { SshRelaySession } from './ssh-relay-session' import type { SshConnection } from './ssh-connection' -import type { Store } from '../persistence' -import type { SshPortForwardManager } from './ssh-port-forward' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types' +import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' const { muxRequestMock, installRemoteManagedAgentHooksMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn(), @@ -90,6 +89,12 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ })) const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') +// Why: the hidden-delivery gate module is intentionally real (pure state, no +// electron deps) so the SSH parity tests exercise the same gate main uses. +const { markHiddenRendererPty, setRendererPtyDeliveryInterest } = + await import('../ipc/pty-hidden-delivery-gate') +const { _resetHiddenRendererPtyDeliveryGateForTest } = + await import('../ipc/pty-hidden-delivery-gate') const { execCommand } = await import('./ssh-relay-deploy-helpers') const { getRemoteHostPlatform } = await import('./ssh-remote-platform') const { @@ -105,41 +110,6 @@ const { registerSshFilesystemProvider, unregisterSshFilesystemProvider } = const { registerSshGitProvider, unregisterSshGitProvider } = await import('../providers/ssh-git-dispatch') -function createMockDeps() { - const mockConn = {} as SshConnection - const mockStore = { - getRepos: vi.fn().mockReturnValue([]), - getSshRemotePtyLeases: vi.fn().mockReturnValue([]), - markSshRemotePtyLease: vi.fn(), - markSshRemotePtyLeases: vi.fn() - } as unknown as Store - const mockPortForward = { - removeAllForwards: vi.fn() - } as unknown as SshPortForwardManager - const mockWindow = { - isDestroyed: () => false, - // Why: the port scanner visibility-gates its ticks; a visible mock window - // keeps establish-path tests exercising the scan-on-ready behavior. - isVisible: () => true, - isMinimized: () => false, - webContents: { send: vi.fn() } - } - const getMainWindow = vi.fn().mockReturnValue(mockWindow) - return { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } -} - -function mockDeploySuccess() { - const mockTransport = { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - } - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: mockTransport, - platform: 'linux-x64' - }) -} - describe('SshRelaySession', () => { beforeEach(() => { vi.clearAllMocks() @@ -150,6 +120,95 @@ describe('SshRelaySession', () => { installRemoteManagedAgentHooksMock.mockResolvedValue([]) mockDeploySuccess() vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + _resetHiddenRendererPtyDeliveryGateForTest() + }) + + it('drops hidden-gated PTY data after runtime ingestion with one restore marker', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const runtime = { + onPtyData: vi.fn(() => 99), + onPtyExit: vi.fn() + } + const session = new SshRelaySession( + 'target-1', + getMainWindow, + mockStore, + mockPortForward, + runtime as never + ) + await session.establish(mockConn) + const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { + onData: ReturnType + } + const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { + id: string + data: string + }) => void + + markHiddenRendererPty('ssh-pty-1') + onData({ id: 'ssh-pty-1', data: 'hidden ssh output' }) + + // Runtime ingestion still ran; renderer delivery shrank to one marker. + expect(runtime.onPtyData).toHaveBeenCalledWith( + 'ssh-pty-1', + 'hidden ssh output', + expect.any(Number) + ) + expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1) + // Why out-of-band: an in-band empty pty:data sentinel is ambiguous with + // chunks fully consumed by renderer OSC-9999 stripping. + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: 'ssh-pty-1', + reason: 'hidden-drop', + markerSeq: 99 + }) + + onData({ id: 'ssh-pty-1', data: 'more hidden ssh output' }) + expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Delivery interest (renderer sidecars) suppresses the gate — parity with + // the local path in ipc/pty.ts. + setRendererPtyDeliveryInterest('ssh-pty-1', true) + onData({ id: 'ssh-pty-1', data: 'sidecar ssh bytes' }) + expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: 'ssh-pty-1', + data: 'sidecar ssh bytes', + seq: 99, + rawLength: 'sidecar ssh bytes'.length + }) + + // Non-hidden PTYs are unaffected. + onData({ id: 'ssh-pty-2', data: 'visible ssh output' }) + expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: 'ssh-pty-2', + data: 'visible ssh output', + seq: 99, + rawLength: 'visible ssh output'.length + }) + }) + + it('keeps hidden SSH delivery when the gate kill switch is off', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + ;(mockStore as unknown as { getSettings: () => unknown }).getSettings = vi.fn(() => ({ + terminalHiddenDeliveryGate: false + })) + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { + onData: ReturnType + } + const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { + id: string + data: string + }) => void + + markHiddenRendererPty('ssh-pty-1') + onData({ id: 'ssh-pty-1', data: 'still delivered' }) + + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: 'ssh-pty-1', + data: 'still delivered' + }) }) it('starts in idle state', () => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index b2564259ffd..5176d06411f 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -44,6 +44,11 @@ import { setPtyOwnership, answerStartupTerminalColorQueriesForPty } from '../ipc/pty' +import { + recordHiddenRendererPtyDataDrop, + shouldDropHiddenRendererPtyData +} from '../ipc/pty-hidden-delivery-gate' +import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider, @@ -1080,7 +1085,30 @@ export class SshRelaySession { const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now()) const rendererData = answerStartupTerminalColorQueriesForPty(payload.id, payload.data) const win = this.getMainWindow() - if (win && !win.isDestroyed() && rendererData.length > 0) { + if (!win || win.isDestroyed()) { + return + } + // Why: hidden-delivery gate parity with ipc/pty.ts — runtime ingestion + // above already consumed the chunk; gated renderer delivery is dropped + // and one out-of-band pty:modelRestoreNeeded signal latches + // model-restore-needed for reveal. Never an in-band pty:data sentinel: + // OSC-9999-only chunks legitimately strip to empty in the renderer. + const store = this.store as { getSettings?: Store['getSettings'] } + if (shouldDropHiddenRendererPtyData(payload.id, store.getSettings?.())) { + const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length) + if (drop.shouldEmitRestoreMarker) { + win.webContents.send('pty:modelRestoreNeeded', { + id: payload.id, + reason: 'hidden-drop', + ...(typeof seq === 'number' ? { markerSeq: seq } : {}) + } satisfies PtyModelRestoreNeededEvent) + } + return + } + // Why: startup color-query answering can strip query-only chunks to + // empty; skip empty sends and only attach seq metadata when the chunk + // reaches the renderer unmodified (seq tracks raw stream offsets). + if (rendererData.length > 0) { win.webContents.send('pty:data', { ...payload, data: rendererData, diff --git a/src/main/synthetic-title-frame-routing.test.ts b/src/main/synthetic-title-frame-routing.test.ts new file mode 100644 index 00000000000..b4bbf35ecd2 --- /dev/null +++ b/src/main/synthetic-title-frame-routing.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing' + +describe('shouldCopySyntheticTitleFrameToPtyData', () => { + it('keeps the legacy pty:data copy only while the kill switch is off', () => { + // Authority off: renderer byte parsers are the sole synthetic-frame + // consumer, so the legacy copy must keep flowing. + expect(shouldCopySyntheticTitleFrameToPtyData({ terminalMainSideEffectAuthority: false })).toBe( + true + ) + }) + + it('skips the copy under main authority — tracker ingest is the only consumer', () => { + // Why: under authority the copy would only mint phantom renderer ACKs + // for fabricated bytes main never metered. + expect(shouldCopySyntheticTitleFrameToPtyData({ terminalMainSideEffectAuthority: true })).toBe( + false + ) + // Default-on: an unset switch means main authority. + expect(shouldCopySyntheticTitleFrameToPtyData({})).toBe(false) + expect(shouldCopySyntheticTitleFrameToPtyData(null)).toBe(false) + expect(shouldCopySyntheticTitleFrameToPtyData(undefined)).toBe(false) + }) +}) diff --git a/src/main/synthetic-title-frame-routing.ts b/src/main/synthetic-title-frame-routing.ts new file mode 100644 index 00000000000..3bb10a030fa --- /dev/null +++ b/src/main/synthetic-title-frame-routing.ts @@ -0,0 +1,14 @@ +import type { GlobalSettings } from '../shared/types' + +/** + * Why: with the side-effect kill switch off, renderer byte parsers are the + * ONLY consumer of main-fabricated OSC title frames, so they must still ride + * `pty:data`. With main authority on (the default), the tracker ingest is the + * sole consumer and the legacy copy would only mint phantom renderer ACKs for + * bytes main never metered. See terminal-side-effect-authority.md (slice 3). + */ +export function shouldCopySyntheticTitleFrameToPtyData( + settings: Pick | null | undefined +): boolean { + return settings?.terminalMainSideEffectAuthority === false +} diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 453487a18aa..dc278cb1ffa 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -9,6 +9,8 @@ const { menuPopupMock, notificationMock, notificationShowMock, + powerMonitorOnMock, + powerMonitorRemoveListenerMock, isMock } = vi.hoisted(() => { const menuPopupMock = vi.fn() @@ -23,6 +25,8 @@ const { return { show: notificationShowMock } }), notificationShowMock, + powerMonitorOnMock: vi.fn(), + powerMonitorRemoveListenerMock: vi.fn(), isMock: { dev: false } } }) @@ -34,6 +38,7 @@ vi.mock('electron', () => ({ Menu: { buildFromTemplate: buildFromTemplateMock }, Notification: notificationMock, nativeTheme: { shouldUseDarkColors: false }, + powerMonitor: { on: powerMonitorOnMock, removeListener: powerMonitorRemoveListenerMock }, screen: { getPrimaryDisplay: () => ({ workAreaSize: { width: 1440, height: 900 } }) }, @@ -77,6 +82,8 @@ describe('createMainWindow', () => { menuPopupMock.mockClear() notificationMock.mockClear() notificationShowMock.mockClear() + powerMonitorOnMock.mockReset() + powerMonitorRemoveListenerMock.mockReset() isMock.dev = false vi.mocked(ipcMain.on).mockReset() vi.mocked(ipcMain.removeListener).mockReset() @@ -2853,6 +2860,103 @@ describe('createMainWindow', () => { }) }) + describe('system resume relay', () => { + function setupResumeWindow() { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn(), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDestroyed: vi.fn(() => false), + id: 1 + } + const instance = { + webContents, + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + // Why: maximized keeps forceRepaint from scheduling its size-nudge timer. + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return instance + }) + return { windowHandlers, webContents, instance } + } + + function getPowerResumeListener(): () => void { + const resumeCall = powerMonitorOnMock.mock.calls.find( + (call: unknown[]) => call[0] === 'resume' + ) + if (!resumeCall) { + throw new Error('missing powerMonitor resume listener') + } + return resumeCall[1] as () => void + } + + it('relays powerMonitor resume to the live window and forces a repaint', () => { + const { webContents } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + webContents.send.mockClear() + webContents.invalidate.mockClear() + + onResume() + + expect(webContents.send).toHaveBeenCalledWith('system:resumed') + expect(webContents.invalidate).toHaveBeenCalledTimes(1) + }) + + it('does not send the resume event once the window is destroyed', () => { + const { webContents, instance } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + instance.isDestroyed.mockReturnValue(true) + webContents.send.mockClear() + webContents.invalidate.mockClear() + + onResume() + + expect(webContents.send).not.toHaveBeenCalled() + expect(webContents.invalidate).not.toHaveBeenCalled() + }) + + it('does not send the resume event once webContents is destroyed', () => { + const { webContents } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + webContents.isDestroyed.mockReturnValue(true) + webContents.send.mockClear() + webContents.invalidate.mockClear() + + onResume() + + expect(webContents.send).not.toHaveBeenCalled() + expect(webContents.invalidate).not.toHaveBeenCalled() + }) + + it('removes the powerMonitor resume listener when the window closes', () => { + const { windowHandlers } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + + windowHandlers.closed() + + expect(powerMonitorRemoveListenerMock).toHaveBeenCalledWith('resume', onResume) + }) + }) + describe('minimize to tray on close (win32)', () => { const originalPlatform = process.platform diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 44b246e8980..1a30178a4e2 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -6,6 +6,7 @@ import { Menu, nativeTheme, Notification, + powerMonitor, screen, shell } from 'electron' @@ -313,6 +314,19 @@ export function createMainWindow( }) } + // Why: a focus-preserving system/display wake fires no window focus or + // visibility events in the renderer, so terminal wake recovery would never + // run. Relay powerMonitor resume explicitly (supported on mac/win/linux) + // and force a repaint so stale compositor surfaces recover too. + const onSystemResume = (): void => { + if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed?.() === true) { + return + } + forceRepaint(mainWindow) + mainWindow.webContents.send('system:resumed') + } + powerMonitor.on('resume', onSystemResume) + mainWindow.webContents.on('dom-ready', () => { const level = store?.getUI().uiZoomLevel ?? 0 mainWindow.webContents.setZoomLevel(level) @@ -1203,6 +1217,9 @@ export function createMainWindow( ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused) ipcMain.removeListener(floatingTerminalInputFocusChannel, onFloatingTerminalInputFocused) ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused) + // Why: powerMonitor is app-global; without this the closed window's + // resume relay would leak and fire against a destroyed webContents. + powerMonitor.removeListener('resume', onSystemResume) clearTrustedUIRendererWebContentsId(rendererWebContentsId) // Why: on updater-triggered shutdown, BrowserWindow can emit `closed` // after its webContents has already been destroyed. The destroyed diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e278d863e21..ceb0d6a8d54 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -206,22 +206,18 @@ import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../shared/types' - -type GitLabRepoSelectorArgs = { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null -} - -type GitHubRepoSelectorArgs = { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null -} +import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' +import type { + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../shared/pty-renderer-delivery-health' +import type { TerminalViewAttributes } from '../shared/terminal-view-attributes' +import type { PtyMainDeliveryDiagnostics } from '../shared/pty-delivery-diagnostics' import type { WarpThemeImportPreview, WarpThemeImportSource } from '../shared/terminal-custom-themes' + import type { SetupScriptImportCandidate } from '../shared/setup-script-imports' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' @@ -292,6 +288,7 @@ import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' +import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import type { RuntimeBrowserDriverState, RuntimeMobileSessionTabMove, @@ -445,6 +442,18 @@ import type { } from '../shared/workspace-cleanup' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' +type GitLabRepoSelectorArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + +type GitHubRepoSelectorArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + export type BrowserApi = { registerGuest: (args: { browserPageId: string @@ -1206,6 +1215,10 @@ export type PreloadApi = { shellOverride?: string projectRuntime?: ProjectExecutionRuntimeResolution terminalColorQueryReplies?: { foreground?: string; background?: string } + // Why: hidden-at-spawn declaration — main marks the PTY hidden before + // its first byte so the delivery gate + model responder own spawn-time + // queries (terminal-query-authority.md §races). + initiallyHidden?: boolean // Why: closes the SIGKILL race documented in INVESTIGATION.md — main // sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before // pty:spawn returns. Only the renderer's daemon-host path threads these. @@ -1237,12 +1250,34 @@ export type PreloadApi = { clearBuffer: (id: string) => void kill: (id: string, opts?: { keepHistory?: boolean }) => Promise ackColdRestore: (id: string) => void - ackData: (id: string, charCount: number) => void + ackData: (id: string, charCount: number, processedChars?: number) => void + onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void) => () => void + respondDeliveryResync: (payload: { + requestId: number + processedCharsByPty: Record + }) => void + /** Renderer-initiated delivery health/heal lane over invoke — reaches main + * even when every main→renderer push channel is dead (field wedge). */ + reportRendererDeliveryState: ( + report: PtyRendererDeliveryStateReport + ) => Promise + /** Live pty:data listener count on the preload emitter (sync) — heal-time + * discriminator between a detached listener and a dead channel. */ + getPtyDataListenerCount: () => number /** One-shot signal that this page's pty:data dispatcher is registered, so * main can release sends held during the load/reload boot window. */ rendererDispatcherReady: () => void setActiveRendererPty: (id: string, active: boolean) => void setRendererPtyVisible: (id: string, visible: boolean) => void + /** Hidden-delivery gate (Phase 4): hidden=true lets main drop renderer + * byte delivery after model ingestion; reveal restores from snapshots. */ + setHiddenRendererPty: (id: string, hidden: boolean) => void + /** Ref-counted-on-the-renderer delivery-interest signal that suppresses + * the hidden-delivery gate while any raw-byte consumer is registered. */ + setPtyDeliveryInterest: (id: string, interested: boolean) => void + /** View-attribute bridge (Phase 5 slice 2): app-global composed terminal + * appearance push backing main's hidden-PTY OSC/DSR color replies. */ + publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise @@ -1258,8 +1293,16 @@ export type PreloadApi = { rows: number cwd?: string | null seq?: number + /** Start of main's pending renderer-delivery queue at snapshot time + * (equals `seq` when empty) — bounds the renderer's post-restore + * duplicate window. */ + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + /** Authoritative normal buffer paired with an alternate-screen frame. */ + scrollbackAnsi?: string + /** Trailing incomplete escape the emulator ingested; the restorer must + * write it after its post-replay resets, last before live chunks. */ pendingEscapeTailAnsi?: string } | null> getRendererDeliveryDebugSnapshot: () => Promise<{ @@ -1276,6 +1319,14 @@ export type PreloadApi = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + diagnostics: PtyMainDeliveryDiagnostics rendererLifecycleResetCount: number lastLifecycleResetClearedChars: number rendererPtyDispatcherReady: boolean @@ -1289,10 +1340,19 @@ export type PreloadApi = { seq?: number rawLength?: number background?: boolean - droppedBacklog?: boolean + droppedOutput?: boolean }) => void ) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void + /** Out-of-band main→renderer signal that renderer-bound bytes were + * dropped (hidden-delivery gate / pending cap); the pane restores from + * the model snapshot. Never delivered in-band on pty:data. */ + onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void) => () => void + /** Batched derived side-effect facts for PTYs whose bytes transit local + * main; see docs/reference/terminal-side-effect-authority.md. */ + onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void + /** Title-only replay snapshot for (re)attach; attention facts never replay. */ + getSideEffectSnapshot: (id: string) => Promise onExit: (callback: (data: { id: string; code: number }) => void) => () => void onSerializeBufferRequest: ( callback: (data: { @@ -1993,6 +2053,10 @@ export type PreloadApi = { telemetryAcknowledgeBanner: () => Promise settings: { get: () => Promise + /** Synchronous persisted-settings read for startup decisions that cannot + * wait for async hydration (terminal side-effect authority). Blocking + * IPC — call sparingly. */ + getSync: () => GlobalSettings | null set: (args: Partial) => Promise listFonts: () => Promise previewGhosttyImport: () => Promise diff --git a/src/preload/e2e-config.ts b/src/preload/e2e-config.ts index 079bdceb181..f1cdf668ce3 100644 --- a/src/preload/e2e-config.ts +++ b/src/preload/e2e-config.ts @@ -20,5 +20,8 @@ const exposeStore = preloadEnv?.MODE === 'e2e' || isEnvFlagEnabled(preloadEnv?.V export const preloadE2EConfig = createE2EConfig({ headless: process.env.ORCA_E2E_HEADLESS === '1', exposeStore, - userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null + userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null, + // Why: Number('') is 0 and Number(undefined) is NaN; both coerce to null so + // only a real positive override reaches the renderer parking policy. + terminalParkingDelayMs: Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || null }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 8f24dc18217..2e960b9c461 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -54,6 +54,13 @@ import type { WorktreeDefaultTabsLaunch, WorktreeRemoteBranchConflictEvent } from '../shared/types' +import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' +import type { + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../shared/pty-renderer-delivery-health' +import type { TerminalViewAttributes } from '../shared/terminal-view-attributes' +import type { PtyMainDeliveryDiagnostics } from '../shared/pty-delivery-diagnostics' import type { WarpThemeImportPreview, WarpThemeImportSource @@ -131,6 +138,7 @@ import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' +import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import type { SpeechErrorEvent, SpeechLifecycleEvent, @@ -792,6 +800,10 @@ const api = { shellOverride?: string projectRuntime?: ProjectExecutionRuntimeResolution terminalColorQueryReplies?: { foreground?: string; background?: string } + // Why: hidden-at-spawn declaration — main marks the PTY hidden before + // its first byte so the delivery gate + model responder own spawn-time + // queries (terminal-query-authority.md §races). + initiallyHidden?: boolean // Why: closes the SIGKILL race documented in INVESTIGATION.md by // letting main patch + sync-flush the (worktreeId, tabId, leafId → // ptyId) binding before pty:spawn returns. Only the renderer's @@ -851,9 +863,39 @@ const api = { ackColdRestore: (id: string): void => { ipcRenderer.send('pty:ackColdRestore', { id }) }, - ackData: (id: string, charCount: number): void => { - ipcRenderer.send('pty:ackData', { id, charCount }) + /** charCount is the legacy per-chunk delta; processedChars is the + * cumulative per-pty total (self-healing under lost ACK messages). */ + ackData: (id: string, charCount: number, processedChars?: number): void => { + ipcRenderer.send('pty:ackData', { + id, + charCount, + ...(typeof processedChars === 'number' ? { processedChars } : {}) + }) }, + /** Main asks for the renderer's cumulative processed totals when terminal + * delivery looks stuck on lost ACKs. */ + onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { requestId: number }) => + callback(payload) + ipcRenderer.on('pty:requestDeliveryResync', listener) + return () => ipcRenderer.removeListener('pty:requestDeliveryResync', listener) + }, + respondDeliveryResync: (payload: { + requestId: number + processedCharsByPty: Record + }): void => { + ipcRenderer.send('pty:deliveryResyncResponse', payload) + }, + /** Renderer-initiated delivery health/heal lane. Rides invoke because the + * field wedge (v1.4.121-rc.0 snapshot) kills main→renderer push events + * while invoke stays alive — push-initiated recovery can't reach it. */ + reportRendererDeliveryState: ( + report: PtyRendererDeliveryStateReport + ): Promise => + ipcRenderer.invoke('pty:reportRendererDeliveryState', report), + /** Sync count of live pty:data listeners on this preload's emitter — the + * watchdog's "listener detached" vs "channel dead" discriminator. */ + getPtyDataListenerCount: (): number => ipcRenderer.listenerCount('pty:data'), rendererDispatcherReady: (): void => { ipcRenderer.send('pty:rendererDispatcherReady') }, @@ -863,6 +905,24 @@ const api = { setRendererPtyVisible: (id: string, visible: boolean): void => { ipcRenderer.send('pty:setRendererPtyVisible', { id, visible }) }, + /** Hidden-delivery gate (Phase 4): hidden=true lets main DROP renderer + * byte delivery after model ingestion; reveal restores from the model + * snapshot. Fire-and-forget like setActiveRendererPty. */ + setHiddenRendererPty: (id: string, hidden: boolean): void => { + ipcRenderer.send('pty:setHiddenRendererPty', { id, hidden }) + }, + /** Delivery-interest signal: any renderer party that needs raw bytes + * (dispatcher sidecars, eager pre-mount buffers) suppresses the + * hidden-delivery gate for that PTY while registered. */ + setPtyDeliveryInterest: (id: string, interested: boolean): void => { + ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested }) + }, + /** View-attribute bridge (Phase 5 slice 2): app-global composed terminal + * appearance push that lets main's model responder answer OSC 4/10/11/12 + * and DSR ?996n for hidden-gated PTYs with renderer-true values. */ + publishTerminalViewAttributes: (attributes: TerminalViewAttributes): void => { + ipcRenderer.send('pty:terminalViewAttributes', attributes) + }, kill: (id: string, opts?: { keepHistory?: boolean }): Promise => ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), @@ -880,8 +940,10 @@ const api = { rows: number cwd?: string | null seq?: number + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), @@ -899,6 +961,14 @@ const api = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + diagnostics: PtyMainDeliveryDiagnostics rendererLifecycleResetCount: number lastLifecycleResetClearedChars: number rendererPtyDispatcherReady: boolean @@ -934,7 +1004,7 @@ const api = { seq?: number rawLength?: number background?: boolean - droppedBacklog?: boolean + droppedOutput?: boolean }) => void ): (() => void) => { const listener = ( @@ -945,7 +1015,7 @@ const api = { seq?: number rawLength?: number background?: boolean - droppedBacklog?: boolean + droppedOutput?: boolean } ) => callback(data) ipcRenderer.on('pty:data', listener) @@ -959,6 +1029,32 @@ const api = { return () => ipcRenderer.removeListener('pty:replay', listener) }, + /** Out-of-band signal that main dropped renderer-bound bytes for a PTY + * (hidden-delivery gate / pending cap) — the pane must restore from the + * model snapshot. Deliberately NOT on pty:data: an in-band marker is + * ambiguous with chunks fully stripped by OSC-9999 cleaning. */ + onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, event: PtyModelRestoreNeededEvent) => + callback(event) + ipcRenderer.on('pty:modelRestoreNeeded', listener) + return () => ipcRenderer.removeListener('pty:modelRestoreNeeded', listener) + }, + + /** Batched derived side-effect facts (title/bell/agent transitions) for + * PTYs whose bytes transit local main. Per-PTY in-order; deliberately not + * synchronized with pty:data (terminal-side-effect-authority.md). */ + onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, batch: TerminalSideEffectBatch) => + callback(batch) + ipcRenderer.on('pty:sideEffect', listener) + return () => ipcRenderer.removeListener('pty:sideEffect', listener) + }, + + /** Title-only replay snapshot applied on (re)attach — attention facts + * (bells/completions) never replay. */ + getSideEffectSnapshot: (id: string): Promise => + ipcRenderer.invoke('pty:sideEffectSnapshot', { id }), + onExit: (callback: (data: { id: string; code: number }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { id: string; code: number }) => callback(data) @@ -1739,6 +1835,10 @@ const api = { settings: { get: (): Promise => ipcRenderer.invoke('settings:get'), + // Why: blocking read for the few startup decisions (terminal side-effect + // authority) that cannot wait for async hydration. Call sparingly. + getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'), + set: (args: Record): Promise => ipcRenderer.invoke('settings:set', args), @@ -3506,11 +3606,6 @@ const api = { ipcRenderer.on('terminal:zoom', listener) return () => ipcRenderer.removeListener('terminal:zoom', listener) }, - onSystemResumed: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('system:resumed', listener) - return () => ipcRenderer.removeListener('system:resumed', listener) - }, readClipboardText: (options?: ReadClipboardTextOptions): Promise => ipcRenderer.invoke('clipboard:readText', options), readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise => @@ -3576,6 +3671,14 @@ const api = { ipcRenderer.on('window:fullscreen-changed', listener) return () => ipcRenderer.removeListener('window:fullscreen-changed', listener) }, + /** Fired when the OS resumes from sleep (main relays powerMonitor). A + * focus-preserving display wake fires no renderer focus/visibility + * events, so terminal wake recovery listens to this explicit signal. */ + onSystemResumed: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('system:resumed', listener) + return () => ipcRenderer.removeListener('system:resumed', listener) + }, /** Desktop custom titlebar only: minimize via renderer-drawn window controls. */ minimize: (): void => { ipcRenderer.send('window:minimize') diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ce443c55454..eeaff50abb5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -130,6 +130,8 @@ import { } from './startup/startup-diagnostics' import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility' import { applyDocumentTheme } from './lib/document-theme' +import { getSystemPrefersDark } from './lib/terminal-theme' +import { publishTerminalViewAttributesAtAppStart } from './components/terminal-pane/terminal-appearance' import { isEditableTarget } from './lib/editable-target' import { getSelectedTextForFileSearch } from './lib/file-search-selection' import { useShortcutLabel } from './hooks/useShortcutLabel' @@ -176,6 +178,11 @@ import { subscribeBackgroundTerminalWorktreeMountRequests } from './components/terminal/background-terminal-worktree-mount' +// Why: agents alive during a hard kill (crash, forced update install) need a +// reasonably fresh resume record on disk; one minute bounds the lost window +// without measurable per-tick cost (the capture skips unchanged records). +const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 + const isMac = navigator.userAgent.includes('Mac') const isWindows = !isMac && navigator.userAgent.includes('Windows') const shortcutPlatform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' @@ -886,6 +893,14 @@ function App(): React.JSX.Element { // Load settings first so a persisted remote runtime does not boot against // the local filesystem and then hydrate stale local workspace state. await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings()) + // Why here: hidden-at-launch PTYs (background terminal reconnects, + // agent sessions) can query OSC 10/11 before any terminal pane mounts + // and main's responder is silent-until-first-push. Publish composed + // view attributes as soon as settings exist, before any spawn below. + publishTerminalViewAttributesAtAppStart( + useAppStore.getState().settings, + getSystemPrefersDark() + ) // Why: keybindings + onboarding are main-side reads with no dependency // on the catalog/session steps below, so start them now and await them // at their original positions — the round-trips overlap the local @@ -1365,6 +1380,22 @@ function App(): React.JSX.Element { return () => window.removeEventListener('beforeunload', captureAndFlush) }, []) + // Why: beforeunload never fires on a hard kill (crash, forced update + // install, TerminateProcess), so agents alive at that moment would leave no + // resume record. This periodic capture stores only agent session ids — not + // scrollback, see the no-periodic-scrollback note below — and the store + // action skips unchanged records, so idle ticks write nothing; real changes + // flow through the debounced session-write subscriber. + useEffect(() => { + const timer = window.setInterval(() => { + if (!shouldPersistWorkspaceSession(useAppStore.getState())) { + return + } + useAppStore.getState().captureAllSleepingAgentSessions() + }, SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS) + return () => window.clearInterval(timer) + }, []) + // Own the single window-close-request subscription at the always-mounted App // root. Why: the rich confirmation flow lives in Terminal, which is not // mounted on the no-workspace landing page (and is lazy-loaded elsewhere), so diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index c4d331697c2..f8ae284a365 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -73,6 +73,18 @@ import { import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs' +import { + getTerminalWorktreeColdParkRecheckDelayMs, + selectColdParkedTerminalWorktrees, + type TerminalWorktreeColdParkCandidate +} from './terminal-pane/terminal-hidden-view-parking' +import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides' +import { + canWatcherCoverParkedTerminalTab, + pruneParkedTerminalWatchers, + shouldDeferParkedPtyExitTabClose, + syncParkedTerminalTabWatchers +} from './terminal-pane/terminal-parked-tab-watchers' import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue' import { setWindowCloseRequestHandler } from './window-close-request-coordinator' import CodexRestartChip from './CodexRestartChip' @@ -132,6 +144,18 @@ const EDITOR_TAB_CONTENT_TYPES = new Set([ type TerminalStoreSnapshot = ReturnType +function haveSameWorktreeIds(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false + } + for (const id of left) { + if (!right.has(id)) { + return false + } + } + return true +} + function findUnifiedTabByVisibleId( state: TerminalStoreSnapshot, worktreeId: string, @@ -214,6 +238,8 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext { function Terminal(): React.JSX.Element | null { const mountedWorktreeIdsRef = useRef(new Set()) const measurableBackgroundWorktreeIdsRef = useRef(new Set()) + const terminalWorktreeHiddenSinceRef = useRef(new Map()) + const terminalWorktreeParkingTimersRef = useRef(new Map()) const allWorktrees = useAllWorktrees() const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) const workspaceSurfaces = useMemo( @@ -230,6 +256,8 @@ function Terminal(): React.JSX.Element | null { const renderedActiveWorktreeId = activeWorktreeId const activeView = useAppStore((s) => s.activeView) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId) + const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false) const activeTabId = useAppStore((s) => s.activeTabId) const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree) const createTab = useAppStore((s) => s.createTab) @@ -737,11 +765,15 @@ function Terminal(): React.JSX.Element | null { // Only mount TerminalPanes for visited worktrees to prevent mass PTY // spawning when restoring a session with many saved worktree tabs. const measurableBackgroundWorktreeTimersRef = useRef(new Map()) + const [backgroundMountRevision, setBackgroundMountRevision] = useState(0) + const [terminalParkingRevision, setTerminalParkingRevision] = useState(0) + const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState>( + () => new Set() + ) // Why: background-mounted worktrees restricted to specific tabs (targeted // wake/resume) must not instantiate a TerminalPane per saved tab. A worktree // absent from this map mounts all of its tabs. const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) - const [, setBackgroundMountRevision] = useState(0) useEffect(() => { const timers = measurableBackgroundWorktreeTimersRef.current const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current @@ -798,6 +830,122 @@ function Terminal(): React.JSX.Element | null { closeDialogDebounceTimers.clear() } }, []) + + useEffect(() => { + const timers = terminalWorktreeParkingTimersRef.current + return () => { + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + } + }, []) + + // Why: worktree-level cold-park policy — hiddenSince bookkeeping, parked-set + // selection, and one recheck timer per still-pending deadline so React + // re-renders exactly when the hysteresis elapses instead of polling. + useEffect(() => { + const parkingTimers = terminalWorktreeParkingTimersRef.current + for (const timer of parkingTimers.values()) { + window.clearTimeout(timer) + } + parkingTimers.clear() + + const nowMs = Date.now() + const overrides = getTerminalParkingPolicyOverrides() + const portalWorktreeIds = new Set(activityTerminalPortals.map((portal) => portal.worktreeId)) + const currentWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id)) + for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) { + if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + } + } + + const retentionCandidates: TerminalWorktreeColdParkCandidate[] = [] + for (const workspace of workspaceSurfaces) { + const worktreeId = workspace.id + if (!mountedWorktreeIdsRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + continue + } + const isVisible = activeView === 'terminal' && renderedActiveWorktreeId === worktreeId + const shouldMeasureHiddenWorktree = + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktreeId) + const hasActivityTerminalPortal = portalWorktreeIds.has(worktreeId) + if (isVisible || shouldMeasureHiddenWorktree || hasActivityTerminalPortal) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + } else if (!terminalWorktreeHiddenSinceRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.set(worktreeId, nowMs) + } + + retentionCandidates.push({ + worktreeId, + terminalTabs: tabsByWorktree[worktreeId] ?? [], + isVisible, + shouldMeasureHiddenWorktree, + hasActivityTerminalPortal, + hiddenSinceMs: terminalWorktreeHiddenSinceRef.current.get(worktreeId) ?? null + }) + } + + const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({ + worktrees: retentionCandidates, + pendingStartupByTabId, + parkingEnabled: terminalParkingEnabled, + nowMs, + ...overrides + }) + // Why: a worktree with any tab the byte watchers cannot cover (no + // capture, no layout snapshot, legacy leaf ids) must never park — it + // would go silent for bells/titles/completions, the failure that sank + // the first parking attempt. + for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) { + const tabs = tabsByWorktree[worktreeId] ?? [] + if (!tabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab))) { + nextParkedTerminalWorktreeIds.delete(worktreeId) + } + } + setParkedTerminalWorktreeIds((current) => + haveSameWorktreeIds(current, nextParkedTerminalWorktreeIds) + ? current + : nextParkedTerminalWorktreeIds + ) + + for (const candidate of retentionCandidates) { + if ( + candidate.isVisible || + candidate.shouldMeasureHiddenWorktree || + candidate.hasActivityTerminalPortal || + nextParkedTerminalWorktreeIds.has(candidate.worktreeId) + ) { + continue + } + const delayMs = getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: terminalParkingEnabled, + hiddenSinceMs: candidate.hiddenSinceMs, + nowMs, + ...overrides + }) + if (delayMs !== null && delayMs > 0) { + const worktreeId = candidate.worktreeId + const timer = window.setTimeout(() => { + parkingTimers.delete(worktreeId) + setTerminalParkingRevision((revision) => revision + 1) + }, delayMs) + parkingTimers.set(worktreeId, timer) + } + } + }, [ + activeView, + activityTerminalPortals, + backgroundMountRevision, + pendingStartupByTabId, + renderedActiveWorktreeId, + tabsByWorktree, + terminalParkingEnabled, + terminalParkingRevision, + workspaceSurfaces + ]) // Why: gated on workspaceSessionReady to prevent TerminalPane from mounting // before reconnectPersistedTerminals() has finished eagerly spawning PTYs. // Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId @@ -829,6 +977,59 @@ function Terminal(): React.JSX.Element | null { groupsByWorktree, activeGroupIdByWorktree ) + // Why: parked byte-watcher reconciliation for the legacy (non-split) + // terminal host, which renders TerminalPanes directly. In split mode each + // TerminalPaneOverlayLayer owns its worktree's watchers, so here we only + // dispose worktrees that render no overlay layer (no layout / unmounted) + // and prune watchers for deleted worktrees. + useEffect(() => { + pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id))) + for (const workspace of workspaceSurfaces) { + if ( + anyMountedWorktreeHasLayout && + mountedWorktreeIdsRef.current.has(workspace.id) && + getEffectiveLayoutForWorktree(workspace.id) + ) { + continue + } + const tabs = tabsByWorktree[workspace.id] ?? [] + const parkedTabIds = new Set() + if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspace.id)) { + const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId + const shouldMeasureHiddenWorktree = + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) + const parked = + !isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(workspace.id) + if (parked) { + for (const tab of tabs) { + const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { + worktreeId: workspace.id, + tabId: tab.id + }) + if (!activityTerminalPortal) { + parkedTabIds.add(tab.id) + } + } + } + } + syncParkedTerminalTabWatchers({ worktreeId: workspace.id, tabs, parkedTabIds }) + } + }, [ + activeView, + activityTerminalPortals, + anyMountedWorktreeHasLayout, + backgroundMountRevision, + getEffectiveLayoutForWorktree, + parkedTerminalWorktreeIds, + renderedActiveWorktreeId, + tabsByWorktree, + workspaceSurfaces + ]) + // Why: symmetric with useTerminalTabColdParking's unmount cleanup — when + // the terminal host unmounts, no reconciliation effect will run again, so + // dispose every remaining parked watcher here (overlay-layer children have + // already disposed theirs by the time this parent cleanup runs). + useEffect(() => () => pruneParkedTerminalWatchers(new Set()), []) // Auto-create first tab when worktree activates useEffect(() => { if (!workspaceSessionReady) { @@ -1127,6 +1328,12 @@ function Terminal(): React.JSX.Element | null { if (consumeSuppressedPtyExit(ptyId)) { return } + // Why: a parked multi-leaf tab has no PaneManager to promote split + // siblings, so closing the tab here would kill them; the reveal + // remount handles dead PTYs per leaf instead. + if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) { + return + } handleCloseTab(tabId) }, [consumeSuppressedPtyExit, handleCloseTab] @@ -1859,6 +2066,10 @@ function Terminal(): React.JSX.Element | null { activeView === 'terminal' && workspace.id === renderedActiveWorktreeId const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) + const shouldColdParkTerminalPanes = + !isVisible && + !shouldMeasureHiddenWorktree && + parkedTerminalWorktreeIds.has(workspace.id) return ( | null }): React.JSX.Element { @@ -2207,6 +2430,8 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ worktreeId={worktreeId} worktreePath={worktreePath} isWorktreeActive={isVisible} + coldParkTerminalPanes={shouldColdParkTerminalPanes} + shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree} activityTerminalPortals={activityTerminalPortals} backgroundMountTabIds={backgroundMountTabIds} /> diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 8e1c8f17d8d..c30f5754327 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -13,6 +13,8 @@ import TerminalPane from './TerminalPane' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount' import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut' +import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers' +import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' type TerminalOverlayAssignment = { unifiedTabId: string @@ -238,6 +240,12 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ if (consumeSuppressedPtyExit(ptyId)) { return } + // Why: a parked multi-leaf tab has no PaneManager to promote split + // siblings, so closing the tab here would kill them; the reveal + // remount handles dead PTYs per leaf instead. + if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) { + return + } closeTab(terminalTabId) leaveWorktreeIfEmpty() }} @@ -279,12 +287,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId, worktreePath, isWorktreeActive, + coldParkTerminalPanes = false, + shouldMeasureHiddenWorktree = false, activityTerminalPortals = EMPTY_ACTIVITY_PORTALS, backgroundMountTabIds = null }: { worktreeId: string worktreePath: string isWorktreeActive: boolean + coldParkTerminalPanes?: boolean + shouldMeasureHiddenWorktree?: boolean activityTerminalPortals?: ActivityTerminalPortalTarget[] /** Non-null for targeted background mounts: only these terminal tabs get a * TerminalPane, so waking one slept agent does not connect every saved tab. */ @@ -351,6 +363,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ return entries }, [groupActiveTabById, unifiedTabs]) + const parkedTerminalTabIds = useTerminalTabColdParking({ + worktreeId, + terminalTabs, + assignments, + isWorktreeActive, + coldParkTerminalPanes, + shouldMeasureHiddenWorktree, + activityTerminalPortals + }) + if (!worktreePath) { return null } @@ -369,6 +391,11 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId, tabId: terminalTab.id }) + // Why: parking unmounts only the view; the parked watcher owns exit + // and side-effect handling until this tab is eligible to remount. + if (parkedTerminalTabIds.has(terminalTab.id)) { + return null + } return ( | null +} + +export function isAgentTaskCompleteOsNotificationEnabledFromState( + state: NotificationSettingsState +): boolean { + const notifications = state.settings?.notifications + return notifications?.enabled !== false && notifications?.agentTaskComplete !== false +} + +export function isTerminalAttentionEnabledFromState(state: NotificationSettingsState): boolean { + return state.settings?.experimentalTerminalAttention === true +} + +/** Completion tracking runs when either consumer (OS notification or the + * experimental terminal-attention marker) is enabled. */ +export function isAgentTaskCompleteTrackingEnabledFromState( + state: NotificationSettingsState +): boolean { + return ( + isAgentTaskCompleteOsNotificationEnabledFromState(state) || + isTerminalAttentionEnabledFromState(state) + ) +} + +export function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean { + return Boolean( + entry && + Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS && + (entry.lastAssistantMessage || entry.toolName || entry.toolInput) + ) +} + +export function canDispatchAgentNotificationAfterGrace( + entry: AgentStatusEntry | undefined, + options: { allowDoneDetailAfterGrace?: boolean } = {} +): boolean { + // Why: hook-backed goal/mission loops can report `done` between milestones. + // User-input states may notify as soon as detail arrives, but `done` waits + // for the max quiet window so resumed work can cancel the pending banner. + return ( + hasAgentNotificationDetail(entry) && + (entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true) + ) +} diff --git a/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts b/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts new file mode 100644 index 00000000000..0c855bac3c3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts @@ -0,0 +1,558 @@ +import { describe, expect, it } from 'vitest' +import { + buildAgentTuiStreamOps, + mulberry32, + splitIntoRandomChunks, + type AgentTuiStreamDims +} from '../../../../shared/agent-tui-ansi-fuzz-stream' +import { extractPartialEscapeTail } from '../../../../shared/terminal-partial-escape-tail' +import { + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, + SNAPSHOT_REPLAY_PREAMBLE_ALT, + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + bufferHasSerializeHostileWrappedRow, + buildParityMainBufferSnapshot, + createRendererParityTerminal, + cursorPosition, + normalBufferRowsTrimmed, + visibleRows, + visibleRowStyles, + writeChunksToTerminal +} from '../../../../shared/terminal-restore-parity-fixture' + +// Property fuzz for the hidden-reveal seq-reconciliation path in +// pty-connection.ts. When a pane is hidden, main keeps metering PTY output as +// seq'd chunks. On reveal the renderer applies a HeadlessEmulator snapshot +// captured at seq S (paints everything <= S), then stitches the racing tail +// (chunks straddling / after S) on top WITHOUT duplicating or losing a byte. +// The byte arithmetic lives in two non-exported closures: +// - getChunkDataAfterSnapshot (pty-connection.ts ~L4629): slices a +// pending chunk against the snapshot seq. +// - reconcileChunkAgainstRestoredSnapshot (pty-connection.ts ~L4660): dedups +// backlog, detects a restarted seq domain, and heals dropped-output gaps by +// forcing a fresh restore. +// This suite mirrors those rules EXACTLY (each branch tagged with its source +// line) and asserts the invariant they guarantee: for any reveal boundary, +// snapshot(hidden prefix) + reconciled tail reproduces the same screen an +// always-visible terminal shows for the full stream. A wrong slice = a garble. +// +// Runtime knobs: +// FUZZ_ITERATIONS=2000 deep/nightly (default 200, <60s with the fidelity suite) +// FUZZ_SEED=1234 re-run exactly one seed + +const DEFAULT_ITERATIONS = 200 +const FIXED_SEED = readPositiveIntEnv('FUZZ_SEED') +const ITERATIONS = + FIXED_SEED !== null ? 1 : (readPositiveIntEnv('FUZZ_ITERATIONS') ?? DEFAULT_ITERATIONS) + +function readPositiveIntEnv(name: string): number | null { + const raw = Number(process.env[name]) + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null +} + +const DIMS: readonly AgentTuiStreamDims[] = [ + { cols: 80, rows: 24 }, + { cols: 100, rows: 30 } +] + +/** A metered chunk exactly as main delivers to onData: text, the seq of the + * LAST raw byte, the raw (pre-OSC-strip) length, and delivery-domain markers. + * domain increments across a ptyId-exit seq restart (production clears the + * restored baseline at that boundary, so the tail writes verbatim). */ +type MeteredChunk = { + data: string + seq?: number + rawLength?: number + domain: number + droppedOutput?: boolean +} + +/** Mirror of getChunkDataAfterSnapshot (pty-connection.ts ~L4629): how much of + * a chunk in the SNAPSHOT'S seq domain survives once the snapshot at + * snapshotSeq has painted everything <= snapshotSeq. null = "cannot slice, + * force a fresh snapshot" (OSC stripping desynced raw offsets). Byte-identical + * to production; each return tagged with its branch. */ +function chunkDataAfterSnapshot( + chunk: MeteredChunk, + snapshotSeq: number | undefined +): string | null { + if (typeof snapshotSeq !== 'number' || typeof chunk.seq !== 'number') { + return chunk.data // L4633: unmetered — pass through + } + const rawLength = chunk.rawLength ?? chunk.data.length + const startSeq = chunk.seq - rawLength + if (snapshotSeq >= chunk.seq) { + return '' // L4638: fully before/at snapshot — already painted + } + if (snapshotSeq <= startSeq) { + return chunk.data // L4641: fully after snapshot — keep whole + } + const offset = snapshotSeq - startSeq + if (rawLength !== chunk.data.length) { + return null // L4645: OSC-stripped, offsets unmappable + } + return chunk.data.slice(offset) // L4648: straddles — drop the painted prefix +} + +type RevealResult = { + /** Full normal-buffer content (visible + scrollback, trailing blanks + * trimmed). Viewport-independent: a byte lost or duplicated by reconciliation + * changes this even when the visible viewport happens to line up. */ + content: string[] + rows: string[] + styles: string[] + cursor: { x: number; y: number } + /** True when the buffer scrolled (baseY > 0). The visible viewport's top + * anchor after a snapshot restore vs continuous writing can differ by a row + * or two purely from trailing-blank trimming — a snapshot-scrollback-depth + * concern owned by the fidelity suite, not seq reconciliation — so styles and + * cursor (viewport-relative) are only asserted on non-scrolled scenarios. */ + scrolled: boolean + /** True when the terminal ended on the alternate screen. Alt has no + * scrollback and a fixed viewport, so its content is the visible rows and the + * normal-buffer content comparison does not apply (the alt-screen restore + * contract is scrollback-free by design — serializeHeadlessTerminalBuffer). */ + alternate: boolean + forcedFreshRestore: boolean + knownSerializeWrapBug: boolean +} + +async function readScreen( + term: ReturnType +): Promise { + const alternate = term.terminal.buffer.active.type === 'alternate' + return { + content: alternate ? visibleRows(term.terminal) : normalBufferRowsTrimmed(term.terminal), + rows: visibleRows(term.terminal), + styles: visibleRowStyles(term.terminal), + cursor: cursorPosition(term.terminal), + scrolled: term.terminal.buffer.active.baseY > 0, + alternate, + forcedFreshRestore: false, + knownSerializeWrapBug: false + } +} + +/** The renderer's screen for a hide→reveal cycle. `revealIdx` is the delivery + * index at which the pane is revealed: chunks [0, revealIdx) were captured by + * the HeadlessEmulator snapshot; chunks [revealIdx, end) are the racing tail. + * The snapshot seq is the seq of the last hidden chunk in the snapshot's + * domain; the tail is stitched via the production slice/reconcile rules. */ +async function revealFromSnapshot( + dims: AgentTuiStreamDims, + chunks: MeteredChunk[], + revealIdx: number +): Promise { + const source = createRendererParityTerminal(dims) + const restored = createRendererParityTerminal(dims) + try { + // Snapshot source = every hidden chunk painted in order. + const hiddenChunks = chunks.slice(0, revealIdx).map((c) => c.data) + await writeChunksToTerminal(source.terminal, hiddenChunks) + // Snapshot seq = seq of the last hidden chunk that carried one (the seq the + // emulator would report). undefined when the hidden prefix was unmetered. + let snapshotSeq: number | undefined + let snapshotDomain = 0 + for (let i = revealIdx - 1; i >= 0; i--) { + if (typeof chunks[i]!.seq === 'number') { + snapshotSeq = chunks[i]!.seq + snapshotDomain = chunks[i]!.domain + break + } + } + const snapshot = buildParityMainBufferSnapshot(source, snapshotSeq ?? 0, { + // Mirror of HeadlessEmulator's ingest tracker: the hidden stream's + // trailing incomplete escape rides the snapshot out-of-band (Bug E fix). + pendingEscapeTail: extractPartialEscapeTail(hiddenChunks.join('')) + }) + const alt = snapshot.alternateScreen + const knownSerializeWrapBug = bufferHasSerializeHostileWrappedRow(source.terminal) + // Mirror of applyMainBufferSnapshot's write order: the pending escape tail + // is the FINAL replay write — any later ESC (e.g. the post-replay reset) + // would abort the dangling sequence before the racing tail completes it. + const preamble = + alt && snapshot.scrollbackAnsi !== undefined + ? `\x1b[?1049l\x1b[2J\x1b[3J\x1b[H${snapshot.scrollbackAnsi}${SNAPSHOT_REPLAY_PREAMBLE_ALT}` + : alt + ? SNAPSHOT_REPLAY_PREAMBLE_ALT + : SNAPSHOT_REPLAY_PREAMBLE_NORMAL + await writeChunksToTerminal(restored.terminal, [ + preamble, + snapshot.data, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, + ...(snapshot.pendingEscapeTailAnsi ? [snapshot.pendingEscapeTailAnsi] : []) + ]) + + // Stitch the tail. A chunk in the snapshot's domain is sliced against the + // snapshot seq (drains the painted prefix); a chunk from a later domain + // (post-exit seq restart) writes verbatim — production clears the baseline + // at the ptyId-exit boundary, so no seq dedupe applies across domains. + let forcedFreshRestore = false + for (let i = revealIdx; i < chunks.length; i++) { + const chunk = chunks[i]! + if (chunk.domain !== snapshotDomain) { + await writeChunksToTerminal(restored.terminal, [chunk.data]) + continue + } + const sliced = chunkDataAfterSnapshot(chunk, snapshotSeq) + if (sliced === null) { + // Production re-fetches a fresh snapshot here; a fresh snapshot of the + // full stream trivially matches the always-visible screen, so mark the + // scenario as a fresh-restore (excluded from the stitch comparison). + forcedFreshRestore = true + continue + } + if (sliced) { + await writeChunksToTerminal(restored.terminal, [sliced]) + } + } + const result = await readScreen(restored) + result.forcedFreshRestore = forcedFreshRestore + result.knownSerializeWrapBug = knownSerializeWrapBug + return result + } finally { + source.terminal.dispose() + restored.terminal.dispose() + } +} + +async function alwaysVisible( + dims: AgentTuiStreamDims, + chunks: MeteredChunk[] +): Promise { + const term = createRendererParityTerminal(dims) + try { + await writeChunksToTerminal( + term.terminal, + chunks.map((c) => c.data) + ) + return await readScreen(term) + } finally { + term.terminal.dispose() + } +} + +/** Reference for the seq-reconciliation gate: a snapshot of the WHOLE stream + * (reveal at the very end, no racing tail). Sharing the snapshot machinery with + * revealFromSnapshot cancels out snapshot-fidelity gaps (Bug C cursor restore, + * Bug B bold loss — owned by the fidelity suite), so a diff against a mid-point + * reveal is attributable purely to the tail-stitch seq arithmetic. */ +async function fullSnapshotReference( + dims: AgentTuiStreamDims, + chunks: MeteredChunk[] +): Promise { + return revealFromSnapshot(dims, chunks, chunks.length) +} + +type Scenario = { + seed: number + dims: AgentTuiStreamDims + chunks: MeteredChunk[] + revealIdx: number + domains: number + hasDropped: boolean +} + +const TAIL_WORDS = [ + 'reading', + 'tokens 12.4k', + '你好世界', + '터미널', + '✅ done', + 'PASS test.ts', + '+142 -37', + 'diff --git' +] as const +// Why no SGR 7 (inverse): inverse marks trailing BLANK cells with an +// inverse-fg the serializer round-trips slightly differently depending on how +// much of the buffer it captures — a serialize SGR-fidelity nuance (the Bug B +// class) that has nothing to do with seq reconciliation. Keeping it out of the +// tail keeps the styles gate a clean function of the byte-stitch. +const TAIL_SGR = ['\x1b[0m', '\x1b[31m', '\x1b[1m', '\x1b[38;5;204m', '\x1b[22m'] as const + +/** Append-only racing tail: SGR runs + text + newlines only. No absolute/ + * relative cursor motion, scroll regions, alt frames, or DECSC — so the tail + * paints wherever the cursor sits and its screen effect is a pure function of + * which bytes the seq-reconciliation applies. Terminal-state-loss garbles + * (Bugs C/D/E, scroll region) are pinned separately; this suite isolates the + * seq slice. */ +function buildAppendOnlyTail(rng: () => number, lines: number): string { + const out: string[] = [] + for (let i = 0; i < lines; i++) { + const words: string[] = [] + const count = 1 + Math.floor(rng() * 3) + for (let w = 0; w < count; w++) { + words.push(TAIL_WORDS[Math.floor(rng() * TAIL_WORDS.length)]!) + } + const sgr = TAIL_SGR[Math.floor(rng() * TAIL_SGR.length)]! + out.push(`${sgr}${words.join(' ')}\x1b[0m\r\n`) + } + return out.join('') +} + +/** Builds a seeded metered stream with a random reveal boundary. A running seq + * counter tags the LAST byte of each chunk; at most ONE mid-stream restart + * bumps the domain and resets the counter low (a revived ptyId with a fresh + * main-side counter — production clears the restored baseline at that exit + * boundary, so the post-restart tail writes verbatim). Rare unmetered chunks + * (no seq) and droppedOutput markers model no-metering runtimes and pending-cap + * trims. Op counts are kept modest so most scenarios reach the full + * non-scrolled comparison (the tail-stitch invariant); scrolled scenarios fall + * back to the snapshot-depth-tolerant path (see the assertion loop). */ +function buildScenario(seed: number): Scenario { + const rng = mulberry32(seed) + const dims = DIMS[Math.floor(rng() * DIMS.length)]! + // Hidden prefix: full agent-TUI churn (cursor motion, panels, alt frames, + // scroll regions, DECSC…) so the SNAPSHOT is taken over rich state. + const prefixOps = buildAgentTuiStreamOps(rng, dims, { + includeMouseModes: false, + includeOscHyperlinks: false, + opCount: 3 + Math.floor(rng() * 6) + }) + const prefixStream = prefixOps.join('') + const prefixRaw = splitIntoRandomChunks(mulberry32(seed ^ 0x51ed270b), prefixStream, { + minLen: 2, + maxLen: 64 + }) + // Racing tail: append-only content. Isolates the seq-reconciliation byte + // stitch from terminal-state-loss garbles (Bugs C/D/E — pinned separately). + const tailStream = buildAppendOnlyTail(rng, 2 + Math.floor(rng() * 5)) + const tailRaw = splitIntoRandomChunks(mulberry32(seed ^ 0x2545f491), tailStream, { + minLen: 2, + maxLen: 40 + }) + + const chunks: MeteredChunk[] = [] + let seq = 100 + Math.floor(rng() * 50) + let domain = 0 + let hasDropped = false + // At most one restart, placed inside the tail so the snapshot's own domain + // has a stable prefix — mirrors a single mid-session ptyId revival. + const restartAt = + rng() < 0.25 ? prefixRaw.length + Math.floor(rng() * Math.max(1, tailRaw.length)) : -1 + const allRaw = [...prefixRaw, ...tailRaw] + for (let i = 0; i < allRaw.length; i++) { + const data = allRaw[i]! + if (i === restartAt) { + seq = 10 + Math.floor(rng() * 20) + domain = 1 + } + const rawLength = data.length + seq += rawLength + const chunk: MeteredChunk = { data, seq, rawLength, domain } + // Rare unmetered / dropped markers, only in the tail (the prefix must meter + // so the snapshot carries a seq). + if (i >= prefixRaw.length && rng() < 0.05) { + delete chunk.seq + delete chunk.rawLength + } + if (i >= prefixRaw.length && rng() < 0.04) { + chunk.droppedOutput = true + hasDropped = true + } + chunks.push(chunk) + } + + // Reveal at or after the prefix ends, so the racing tail is purely the + // append-only region — the snapshot captures all cursor-motion/scroll/alt + // state and the tail cannot depend on unserialized terminal state. Landing + // the reveal at various points INSIDE the append-only tail exercises the + // straddle and "snapshot seq inside the tail" seq-slice cases. + const tailSpan = Math.max(1, chunks.length - prefixRaw.length) + const revealIdx = Math.min(chunks.length, prefixRaw.length + Math.floor(rng() * tailSpan)) + return { seed, dims, chunks, revealIdx, domains: domain + 1, hasDropped } +} + +function firstRowDiff(a: string[], b: string[]): number | null { + const n = Math.max(a.length, b.length) + for (let i = 0; i < n; i++) { + if ((a[i] ?? '') !== (b[i] ?? '')) { + return i + } + } + return null +} + +function formatFailure(s: Scenario, stage: string, expected: unknown, actual: unknown): string { + return [ + `hidden-reveal reconciliation divergence — stage: ${stage}`, + `seed: ${s.seed} (re-run: FUZZ_SEED=${s.seed})`, + `dims: ${s.dims.cols}x${s.dims.rows} revealIdx: ${s.revealIdx}/${s.chunks.length} domains: ${s.domains} dropped: ${s.hasDropped}`, + `chunks: ${JSON.stringify( + s.chunks.map((c) => ({ seq: c.seq, len: c.data.length, dom: c.domain })) + )}`, + `expected (always-visible): ${JSON.stringify(expected)}`, + `actual (reveal-from-snapshot): ${JSON.stringify(actual)}` + ].join('\n') +} + +describe('hidden reveal seq-reconciliation fuzz', () => { + it('is a byte-exact identity when the snapshot seq splits a straddling chunk', async () => { + // Pin the core invariant on a hand-built case so the property test cannot + // pass vacuously. rawLength === data.length so the straddle slices cleanly. + // Chunk 0 fully hidden; chunk 1 straddles the reveal; chunk 2 is the tail. + const dims = { cols: 40, rows: 6 } + const c0 = 'red line one\r\n' + const c1 = 'green straddle\r\n' + const c2 = 'plain tail rest' + const chunks: MeteredChunk[] = [ + { data: c0, seq: c0.length, rawLength: c0.length, domain: 0 }, + { data: c1, seq: c0.length + c1.length, rawLength: c1.length, domain: 0 }, + { data: c2, seq: c0.length + c1.length + c2.length, rawLength: c2.length, domain: 0 } + ] + // Reveal after chunk 1 (snapshot seq = end of c1); tail = c2. Also exercise + // the straddle by revealing at chunk 1 with a snapshot seq mid-c1 handled by + // chunkDataAfterSnapshot when c1 is in the tail — covered by revealIdx=1. + const revealAfterC1 = await revealFromSnapshot(dims, chunks, 2) + const control = await alwaysVisible(dims, chunks) + expect(revealAfterC1.rows).toEqual(control.rows) + expect(revealAfterC1.styles).toEqual(control.styles) + expect(revealAfterC1.cursor).toEqual(control.cursor) + + // Reveal at chunk 1: c0 hidden (snapshot seq = end of c0), c1+c2 are the + // tail. c1 is fully after the snapshot seq so it writes whole — identity. + const revealAtC1 = await revealFromSnapshot(dims, chunks, 1) + expect(revealAtC1.rows).toEqual(control.rows) + expect(revealAtC1.styles).toEqual(control.styles) + expect(revealAtC1.cursor).toEqual(control.cursor) + }) + + // ── Bug D regression guard: DECSC saved-cursor register across hide/reveal ── + // The serialized screen cannot carry the VT100 saved-cursor register, so a + // hidden DECSC followed by a post-reveal DECRC restored to home and the next + // writes clobbered the wrong cells. FIXED: the snapshot epilogue re-saves at + // the source's saved position before the final absolute CUP + // (serializeWithAbsoluteCursor + readSavedCursorRegister). Found by fuzz + // seed 3; mechanism in notes/garble-fuzz-divergences.md (Bug D). + it('preserves the DECSC saved-cursor register across a hide/reveal boundary', async () => { + const dims = { cols: 20, rows: 4 } + // Hidden: write 'AB', DECSC saves cursor at r0c2, move to r3c9, write 'CD'. + const hidden: MeteredChunk = { + data: 'AB\x1b7\x1b[4;10HCD', + seq: 'AB\x1b7\x1b[4;10HCD'.length, + rawLength: 'AB\x1b7\x1b[4;10HCD'.length, + domain: 0 + } + // Tail: DECRC restores the saved cursor, write 'X' → live shows 'ABX'. + const tail: MeteredChunk = { + data: '\x1b8X', + seq: hidden.seq! + 3, + rawLength: 3, + domain: 0 + } + const reveal = await revealFromSnapshot(dims, [hidden, tail], 1) + const live = await alwaysVisible(dims, [hidden, tail]) + // Pre-fix this showed 'XB' (DECRC landed at home) instead of live's 'ABX'. + expect(reveal.rows).toEqual(live.rows) + expect(reveal.cursor).toEqual(live.cursor) + }) + + // ── Bug E regression guard: snapshot boundary mid-escape-sequence ── + // A PTY read (one delivery record) can split an escape; the partial lives in + // the emulator's parser, not the serialized screen, so the racing tail's + // continuation rendered literal ('ABmCD'). FIXED: the emulator tracks the + // unparsed trailing partial (terminal-partial-escape-tail.ts) and the + // snapshot ships it out-of-band (pendingEscapeTailAnsi); the reveal replay + // re-arms it as the final write. Found by fuzz seed 4; mechanism in + // notes/garble-fuzz-divergences.md (Bug E). + it('completes an escape sequence split across the hide/reveal boundary', async () => { + const dims = { cols: 20, rows: 3 } + // Hidden prefix ends mid-escape: 'AB' then ESC[3 (no final byte). + const hidden: MeteredChunk = { data: 'AB\x1b[3', seq: 5, rawLength: 5, domain: 0 } + // Tail completes it: 'm' → ESC[3m (italic), then 'CD' italic. Live: 'ABCD'. + const tail: MeteredChunk = { data: 'mCD', seq: 8, rawLength: 3, domain: 0 } + const reveal = await revealFromSnapshot(dims, [hidden, tail], 1) + const live = await alwaysVisible(dims, [hidden, tail]) + // Pre-fix the reveal showed 'ABmCD' (the 'm' became literal). + expect(reveal.rows).toEqual(live.rows) + }) + + it(`stitches the racing tail losslessly across ${ITERATIONS} seeded hide/reveal scenarios`, async () => { + let statsCompared = 0 + let statsSkippedScrolled = 0 + let statsForcedFresh = 0 + let statsKnownWrapBug = 0 + for (let i = 0; i < ITERATIONS; i++) { + const seed = FIXED_SEED ?? 1 + i + const scenario = buildScenario(seed) + // Bug E (snapshot boundary mid-escape-sequence) is no longer tolerated: + // the snapshot now carries the trailing partial escape out-of-band and + // the reveal replay re-arms it last, so these scenarios must compare + // clean like any other — a regression fails the corpus loudly. + // Primary control: a snapshot of the WHOLE stream. Sharing the snapshot + // machinery isolates the seq-reconciliation tail-stitch from + // snapshot-fidelity gaps (fidelity suite's Bugs B/C). If snapshot-at-S + + // tail differs from snapshot-of-everything, a byte was lost/duped/mis- + // sliced. Also compare against always-visible where the two agree, to + // catch a tail that diverges from live output. + const [reveal, reference, live] = await Promise.all([ + revealFromSnapshot(scenario.dims, scenario.chunks, scenario.revealIdx), + fullSnapshotReference(scenario.dims, scenario.chunks), + alwaysVisible(scenario.dims, scenario.chunks) + ]) + if (reveal.forcedFreshRestore || reference.forcedFreshRestore) { + statsForcedFresh += 1 + continue + } + if (reveal.knownSerializeWrapBug || reference.knownSerializeWrapBug) { + statsKnownWrapBug += 1 + continue + } + if (reveal.alternate !== reference.alternate) { + expect.fail( + formatFailure(scenario, 'alt-screen-state', reference.alternate, reveal.alternate) + ) + } + // Scrolled scenarios: top-row survival is a snapshot-depth question + // (fidelity suite), not seq reconciliation. + if (reveal.scrolled || reference.scrolled) { + statsSkippedScrolled += 1 + continue + } + statsCompared += 1 + // Seq-reconciliation gate (snapshot-fidelity-neutral). + if (firstRowDiff(reveal.content, reference.content) !== null) { + expect.fail( + formatFailure(scenario, 'tail-stitch-content', reference.content, reveal.content) + ) + } + if (firstRowDiff(reveal.rows, reference.rows) !== null) { + expect.fail(formatFailure(scenario, 'tail-stitch-visible', reference.rows, reveal.rows)) + } + if (firstRowDiff(reveal.styles, reference.styles) !== null) { + expect.fail(formatFailure(scenario, 'tail-stitch-styles', reference.styles, reveal.styles)) + } + if (JSON.stringify(reveal.cursor) !== JSON.stringify(reference.cursor)) { + expect.fail(formatFailure(scenario, 'tail-stitch-cursor', reference.cursor, reveal.cursor)) + } + // Stronger gate: when a full-stream SNAPSHOT already matches the + // always-visible LIVE screen (no fidelity gap in play), the mid-point + // reveal must match live too — a true end-to-end garble check. + if ( + !live.scrolled && + live.alternate === reference.alternate && + firstRowDiff(reference.rows, live.rows) === null && + firstRowDiff(reference.styles, live.styles) === null && + JSON.stringify(reference.cursor) === JSON.stringify(live.cursor) + ) { + if (firstRowDiff(reveal.rows, live.rows) !== null) { + expect.fail(formatFailure(scenario, 'live-visible', live.rows, reveal.rows)) + } + if (firstRowDiff(reveal.styles, live.styles) !== null) { + expect.fail(formatFailure(scenario, 'live-styles', live.styles, reveal.styles)) + } + if (JSON.stringify(reveal.cursor) !== JSON.stringify(live.cursor)) { + expect.fail(formatFailure(scenario, 'live-cursor', live.cursor, reveal.cursor)) + } + } + } + // Guard against a degenerate corpus that skips its way to green: a healthy + // fraction of scenarios must reach the full non-scrolled comparison. + if (FIXED_SEED === null) { + expect(statsCompared).toBeGreaterThan(ITERATIONS * 0.2) + } + expect( + statsCompared + statsSkippedScrolled + statsForcedFresh + statsKnownWrapBug + ).toBeLessThanOrEqual(ITERATIONS) + }, 120_000) +}) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts new file mode 100644 index 00000000000..bd492d5194c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -0,0 +1,813 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSideEffectFact } from '../../../../shared/terminal-side-effect-facts' +import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher' + +const PTY_ID = 'pty-parked-1' +const TAB_ID = 'tab-1' +const WORKTREE_ID = 'repo-1::/tmp/wt-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `${TAB_ID}:${LEAF_ID}` +const PANE_ID = 1 +// Mirrors PARKED_NOTIFICATION_GRACE_MS / AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS. +const NOTIFICATION_GRACE_MS = 250 + +// Real agent-detection titles: braille spinner classifies as working, +// the "✳ " Claude prefix as idle, and both as Claude agents. +const WORKING_TITLE_OSC = '\x1b]0;⠋ Build feature\x07' +const IDLE_TITLE = '✳ Build feature' +const IDLE_TITLE_OSC = `\x1b]0;${IDLE_TITLE}\x07` + +type MockStoreState = { + settings: { + theme?: 'system' | 'dark' | 'light' + promptCacheTimerEnabled?: boolean + experimentalTerminalAttention?: boolean + terminalMainSideEffectAuthority?: boolean + terminalHiddenDeliveryGate?: boolean + notifications?: { enabled?: boolean; agentTaskComplete?: boolean } + } | null + setRuntimePaneTitle: ReturnType + clearRuntimePaneTitle: ReturnType + updateTabTitle: ReturnType + markWorktreeUnread: ReturnType + markTerminalTabUnread: ReturnType + markTerminalPaneUnread: ReturnType + setCacheTimerStartedAt: ReturnType + observeTerminalGitHubPullRequestLink: ReturnType +} + +const dispatchTerminalNotification = vi.fn() +let mockStoreState: MockStoreState + +vi.mock('./use-notification-dispatch', () => ({ + dispatchTerminalNotification +})) + +vi.mock('@/lib/terminal-theme', () => ({ + getSystemPrefersDark: () => true +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState + } +})) + +function createMockStoreState(): MockStoreState { + return { + // Why: terminalMainSideEffectAuthority false pins the legacy byte-parser + // mode this suite was written for; the authority-on fact-consumer mode is + // covered by the dedicated describe block below. + settings: { + theme: 'system', + promptCacheTimerEnabled: true, + experimentalTerminalAttention: false, + terminalMainSideEffectAuthority: false, + notifications: { enabled: true, agentTaskComplete: true } + }, + setRuntimePaneTitle: vi.fn(), + clearRuntimePaneTitle: vi.fn(), + updateTabTitle: vi.fn(), + markWorktreeUnread: vi.fn(), + markTerminalTabUnread: vi.fn(), + markTerminalPaneUnread: vi.fn(), + setCacheTimerStartedAt: vi.fn(), + observeTerminalGitHubPullRequestLink: vi.fn() + } +} + +describe('startParkedTerminalByteWatcher', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let onData: ((payload: { id: string; data: string }) => void) | null = null + + function emit(data: string): void { + onData?.({ id: PTY_ID, data }) + } + + // The output processor defers title/bell side effects onto a 0ms drain timer. + function flushSideEffects(): void { + vi.advanceTimersByTime(0) + } + + async function startWatcher( + overrides: Partial = {} + ): Promise<{ dispose: () => void; sendInput: ReturnType }> { + const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher') + const sendInput = vi.fn() + const dispose = startParkedTerminalByteWatcher({ + ptyId: PTY_ID, + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneId: PANE_ID, + sendInput, + ...overrides + }) + return { dispose, sendInput } + } + + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + dispatchTerminalNotification.mockClear() + onData = null + mockStoreState = createMockStoreState() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + pty: { + onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { + onData = callback + return () => {} + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + ackData: vi.fn() + } + } + } as unknown as typeof window + }) + + afterEach(() => { + vi.useRealTimers() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('forwards every OSC title in order to the pane and tab title store actions', async () => { + const { dispose } = await startWatcher() + + emit(`${WORKING_TITLE_OSC}${IDLE_TITLE_OSC}`) + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle.mock.calls).toEqual([ + [TAB_ID, PANE_ID, '⠋ Build feature'], + [TAB_ID, PANE_ID, IDLE_TITLE] + ]) + expect(mockStoreState.updateTabTitle.mock.calls).toEqual([ + [TAB_ID, '⠋ Build feature'], + [TAB_ID, IDLE_TITLE] + ]) + dispose() + }) + + it('drops the bare cursor-agent native title before it reaches the store', async () => { + const { dispose } = await startWatcher() + + emit('\x1b]0;Cursor Agent\x07') + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle).not.toHaveBeenCalled() + expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled() + dispose() + }) + + it('does not drive the tab title when drivesTabTitle is false', async () => { + const { dispose } = await startWatcher({ drivesTabTitle: false }) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled() + dispose() + }) + + it('marks unread on BEL and schedules the delayed terminal-bell OS notification', async () => { + const { dispose } = await startWatcher() + + emit('build finished\x07') + flushSideEffects() + + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID) + expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID) + expect(mockStoreState.markTerminalPaneUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'terminal-bell', + paneKey: PANE_KEY + }) + dispose() + }) + + it('marks the exact pane unread when experimental terminal attention is enabled', async () => { + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: true + } + const { dispose } = await startWatcher() + + emit('\x07') + flushSideEffects() + + expect(mockStoreState.markTerminalPaneUnread).toHaveBeenCalledWith(PANE_KEY) + dispose() + }) + + it('does not treat an OSC-terminator BEL as a bell, even split across chunks', async () => { + const { dispose } = await startWatcher() + + emit('\x1b]0;par') + emit('tial title\x07') + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('fires the prompt-cache timer and agent-task-complete on working→idle', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + flushSideEffects() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + PANE_KEY, + expect.any(Number) + ) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY + }) + dispose() + }) + + it('suppresses the completion OS notification when only terminal attention is on', async () => { + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: true, + notifications: { enabled: true, agentTaskComplete: false } + } + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY, + suppressOsNotification: true + }) + dispose() + }) + + it('skips completion dispatch when tracking is fully disabled, keeping the cache timer', async () => { + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: false, + notifications: { enabled: false } + } + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + PANE_KEY, + expect.any(Number) + ) + dispose() + }) + + it('lets a same-burst completion supersede the pending bell OS notification', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + flushSideEffects() + emit(`${IDLE_TITLE_OSC}\x07`) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 8) + + // The bell still marks unread immediately; only the OS notification yields. + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID) + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith( + WORKTREE_ID, + expect.objectContaining({ source: 'agent-task-complete' }) + ) + dispose() + }) + + it('cancels the pending completion and clears the cache timer when working resumes', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + emit(WORKING_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + dispose() + }) + + it('answers a DECSET 2031 subscribe split across chunks via sendInput', async () => { + const { dispose, sendInput } = await startWatcher() + + emit('\x1b[?20') + expect(sendInput).not.toHaveBeenCalled() + + emit('31h') + expect(sendInput).toHaveBeenCalledTimes(1) + // theme=system + prefers-dark → dark reply per terminal-color-scheme-protocol. + expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + emit('\x1b[?2031l') + expect(sendInput).toHaveBeenCalledTimes(1) + dispose() + }) + + it('stops answering DECSET 2031 after dispose', async () => { + const { dispose, sendInput } = await startWatcher() + + dispose() + emit('\x1b[?2031h') + + expect(sendInput).not.toHaveBeenCalled() + }) + + it('observes GitHub PR links across chunk boundaries', async () => { + const { dispose } = await startWatcher() + + emit('PR: https://github.com/orca-dev/orca/pull/42') + expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() + + emit('1\r\ndone') + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1) + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith( + WORKTREE_ID, + expect.objectContaining({ + url: 'https://github.com/orca-dev/orca/pull/421', + number: 421, + slug: { owner: 'orca-dev', repo: 'orca' } + }) + ) + dispose() + }) + + it('fires completion when seeded with a working title and the agent goes idle while parked', async () => { + // Why: the pane was working at park time; the watcher's fresh tracker + // must be seeded or this working→idle transition can never fire. + const { dispose } = await startWatcher({ initialTitle: '⠋ Build feature' }) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY + }) + dispose() + }) + + it('does not fire completion for an idle title without a seed or observed transition', async () => { + const { dispose } = await startWatcher() + + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('clears the watcher-written runtime title slot on dispose', async () => { + const { dispose } = await startWatcher() + + emit(IDLE_TITLE_OSC) + flushSideEffects() + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + + dispose() + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID) + }) + + it('leaves the runtime title slot alone on dispose when it never wrote one', async () => { + const { dispose } = await startWatcher() + + emit('plain output with no titles\r\n') + flushSideEffects() + + dispose() + expect(mockStoreState.clearRuntimePaneTitle).not.toHaveBeenCalled() + }) + + it('shutdown dispose cancels the armed completion timer and silences the final flush', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + + // Equivalent to shutdownWorktreeTerminals → disposeParkedTerminalWatchersForPtyIds. + dispose() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + // The teardown flush that main emits after pty.kill must be a no-op. + emit('final teardown flush\x07') + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + }) + + it('dispose unregisters the sidecar and cancels the pending bell notification', async () => { + const { dispose } = await startWatcher() + + emit('\x07') + flushSideEffects() + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1) + + dispose() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + emit('\x07') + flushSideEffects() + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1) + + // Idempotent: a second dispose must not throw or clobber another watcher. + dispose() + }) + + it('disposes the previous watcher when a new one starts for the same PTY', async () => { + await startWatcher({ paneId: 1 }) + const second = await startWatcher({ paneId: 2 }) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledTimes(1) + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2, IDLE_TITLE) + second.dispose() + }) + + // ─── Main side-effect authority (terminal-side-effect-authority.md) ──── + // + // With the kill switch on, the watcher must not register byte parsers — + // main is the single byte parser and the watcher's policy block consumes + // pty:sideEffect facts instead. The byte sidecar stays ONLY for the 2031 + // reply (query authority never moves to main); PR links arrive as facts. + describe('with main side-effect authority on', () => { + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } + } + + async function dispatchFacts( + facts: TerminalSideEffectFact[], + options: { seq?: number; replay?: boolean } = {} + ): Promise { + const handler = await import('./terminal-side-effect-facts-handler') + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: PTY_ID, + seq: options.seq ?? 0, + ...(options.replay ? { replay: true } : {}), + facts + }) + } + + /** Feed chunks the way OrcaRuntimeService.onPtyData does: OSC 9999 strip, + * shared title tracker, one fact batch per chunk — the main half of the + * migration-safety parity check. */ + async function emitViaMainTrackerFacts(chunks: string[]): Promise { + const { createAgentStatusOscProcessor } = await import('../../../../shared/agent-status-osc') + const { createTerminalTitleTracker } = + await import('../../../../shared/terminal-output-side-effects') + const handler = await import('./terminal-side-effect-facts-handler') + const processStatusChunk = createAgentStatusOscProcessor() + let pending: TerminalSideEffectFact[] = [] + const tracker = createTerminalTitleTracker({ + onTitle: (normalizedTitle, rawTitle) => + pending.push({ kind: 'title', normalizedTitle, rawTitle }), + onAgentBecameWorking: () => pending.push({ kind: 'agent-working' }), + onAgentBecameIdle: (title) => pending.push({ kind: 'agent-idle', title }), + onAgentExited: () => pending.push({ kind: 'agent-exited' }), + onBell: () => pending.push({ kind: 'bell' }) + }) + let seq = 0 + for (const chunk of chunks) { + seq += chunk.length + tracker.handleChunk(processStatusChunk(chunk).cleanData) + if (pending.length > 0) { + handler._dispatchTerminalSideEffectBatchForTest({ ptyId: PTY_ID, seq, facts: pending }) + pending = [] + } + } + tracker.dispose() + } + + type RecordedCall = [string, ...unknown[]] + + /** Wrap the policy-visible store actions so byte mode and fact mode can be + * compared as one ordered outcome sequence. Timestamps are masked. */ + function recordPolicyOutcomes(): RecordedCall[] { + const calls: RecordedCall[] = [] + mockStoreState.setRuntimePaneTitle.mockImplementation((...args: unknown[]) => { + calls.push(['setRuntimePaneTitle', ...args]) + }) + mockStoreState.updateTabTitle.mockImplementation((...args: unknown[]) => { + calls.push(['updateTabTitle', ...args]) + }) + mockStoreState.markWorktreeUnread.mockImplementation((...args: unknown[]) => { + calls.push(['markWorktreeUnread', ...args]) + }) + mockStoreState.markTerminalTabUnread.mockImplementation((...args: unknown[]) => { + calls.push(['markTerminalTabUnread', ...args]) + }) + mockStoreState.markTerminalPaneUnread.mockImplementation((...args: unknown[]) => { + calls.push(['markTerminalPaneUnread', ...args]) + }) + mockStoreState.setCacheTimerStartedAt.mockImplementation((key: unknown, at: unknown) => { + calls.push(['setCacheTimerStartedAt', key, typeof at === 'number' ? '' : at]) + }) + dispatchTerminalNotification.mockImplementation((...args: unknown[]) => { + calls.push(['dispatchTerminalNotification', ...args]) + }) + return calls + } + + it('does not consume bytes: a byte BEL produces no unread or notification', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + emit('build finished\x07') + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('applies bell facts with the byte-mode policy: unread now, OS notification delayed', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([{ kind: 'bell' }]) + + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID) + expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'terminal-bell', + paneKey: PANE_KEY + }) + dispose() + }) + + it('fires the cache timer and completion from working→idle facts', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([ + { kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' }, + { kind: 'agent-working' } + ]) + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + + await dispatchFacts([ + { kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }, + { kind: 'agent-idle', title: IDLE_TITLE } + ]) + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + PANE_KEY, + expect.any(Number) + ) + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY + }) + dispose() + }) + + it('clears state without completion attention for stale-derived idle facts', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([ + { kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' }, + { kind: 'agent-working' } + ]) + // Main's unthrottled 3s stale-title rewrite: titles/cache clear, but a + // merely-paused agent must not earn a task-complete notification. + await dispatchFacts([ + { + kind: 'title', + normalizedTitle: 'Build feature', + rawTitle: 'Build feature', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Build feature', staleWorkingTitleClear: true } + ]) + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenLastCalledWith( + TAB_ID, + PANE_ID, + 'Build feature' + ) + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + dispose() + }) + + it('replay batches restore the title only — attention facts never replay', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts( + [ + { kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }, + { kind: 'bell' }, + { kind: 'agent-idle', title: IDLE_TITLE } + ], + { replay: true } + ) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.setCacheTimerStartedAt).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('answers DECSET 2031 from the main 2031-subscribe fact, never the byte scan', async () => { + // Why: with the hidden-delivery gate on (default), parked PTY bytes are + // dropped in main — the fact is the only 2031 signal, and the byte + // sidecar must NOT exist (its registration would re-enable delivery). + enableMainAuthority() + const { dispose, sendInput } = await startWatcher() + + emit('\x1b[?2031h') + expect(sendInput).not.toHaveBeenCalled() + + await dispatchFacts([{ kind: '2031-subscribe' }]) + expect(sendInput).toHaveBeenCalledTimes(1) + expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + // Why: pr-link facts arrive on the channel; byte-scanning here too + // would observe every link twice. + emit('PR: https://github.com/orca-dev/orca/pull/42\r\n') + expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() + dispose() + }) + + it('marks the PTY hidden for delivery on start and clears it on dispose', async () => { + enableMainAuthority() + const setHiddenRendererPty = vi.fn() + ;( + window as unknown as { api: { pty: Record } } + ).api.pty.setHiddenRendererPty = setHiddenRendererPty + const { dispose } = await startWatcher() + + expect(setHiddenRendererPty).toHaveBeenCalledWith(PTY_ID, true) + + dispose() + // Why: the unhide must land before reveal re-registers pane handlers — + // the watcher registry disposes watchers before the remount effect runs. + expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false) + }) + + it('keeps the byte 2031 responder and no hidden bit when the gate kill switch is off', async () => { + enableMainAuthority() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalHiddenDeliveryGate: false + } as MockStoreState['settings'] + const setHiddenRendererPty = vi.fn() + ;( + window as unknown as { api: { pty: Record } } + ).api.pty.setHiddenRendererPty = setHiddenRendererPty + const { dispose, sendInput } = await startWatcher() + + // Gate off — bytes keep flowing, so the split-chunk byte scan answers. + emit('\x1b[?20') + expect(sendInput).not.toHaveBeenCalled() + emit('31h') + expect(sendInput).toHaveBeenCalledTimes(1) + expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + // Why: a 2031-subscribe fact must not double-fire the reply in byte + // mode — exactly one responder owns the answer at any time. + await dispatchFacts([{ kind: '2031-subscribe' }]) + expect(sendInput).toHaveBeenCalledTimes(1) + + expect(setHiddenRendererPty).not.toHaveBeenCalled() + dispose() + }) + + it('observes PR links from pr-link facts with worktree attribution', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + const link = { + url: 'https://github.com/orca-dev/orca/pull/421', + slug: { owner: 'orca-dev', repo: 'orca' }, + number: 421 + } + await dispatchFacts([{ kind: 'pr-link', link }]) + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1) + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith( + WORKTREE_ID, + link + ) + dispose() + }) + + it('dispose unregisters the fact consumer and clears a written title slot', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }]) + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + + dispose() + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID) + + await dispatchFacts([{ kind: 'bell' }]) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + }) + + // The key migration-safety check: the same bytes produce the identical + // ordered store outcome whether the watcher parses them directly (kill + // switch off) or consumes main-derived facts over the channel. + it('produces identical store outcomes via the channel as the byte parser did', async () => { + const fixtureChunks = [WORKING_TITLE_OSC, 'agent response body\r\n', `${IDLE_TITLE_OSC}\x07`] + + // Pass 1: legacy byte-parser mode. + const byteModeCalls = recordPolicyOutcomes() + { + const { dispose } = await startWatcher() + for (const chunk of fixtureChunks) { + emit(chunk) + flushSideEffects() + } + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + dispose() + } + + // Pass 2: fresh modules/store, authority on, facts derived by the + // shared main tracker from the same bytes. + vi.resetModules() + mockStoreState = createMockStoreState() + dispatchTerminalNotification.mockReset() + const factModeCalls = recordPolicyOutcomes() + { + enableMainAuthority() + const { dispose } = await startWatcher() + await emitViaMainTrackerFacts(fixtureChunks) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + dispose() + } + + expect(byteModeCalls.length).toBeGreaterThan(0) + expect(factModeCalls).toEqual(byteModeCalls) + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts new file mode 100644 index 00000000000..d5bd9eec579 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -0,0 +1,347 @@ +/** + * Parked terminal side-effect watcher. + * + * Why: parking unmounts the TerminalPane subtree, which tears down the pane's + * side-effect consumers — the parked tab's only source of bell, title, + * agent-completion, and PR-link policy. (Losing them is the gap that sank the + * first parking attempt.) Under main side-effect authority the watcher is + * purely fact-driven (one pty:sideEffect consumer, no byte parsing); with the + * kill switch off it registers the legacy byte parsers on the dispatcher + * sidecar channel. DECSET 2031 ownership follows the hidden-delivery gate: + * gate ON answers from main's '2031-subscribe' fact (no parked bytes exist), + * gate OFF keeps the byte sidecar (parked-terminal-mode2031-responder.ts). + * Either way the reply is sent from the renderer — query authority never + * moves to main. See docs/reference/terminal-hidden-view-parking.md and + * docs/reference/terminal-side-effect-authority.md. + */ +import { isClaudeAgent } from '../../../../shared/agent-detection' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { useAppStore } from '@/store' +import { + mode2031SequenceFor, + resolveTerminalColorSchemeMode +} from '../../../../shared/terminal-color-scheme-protocol' +import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import { + AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, + isAgentTaskCompleteOsNotificationEnabledFromState, + isAgentTaskCompleteTrackingEnabledFromState +} from './agent-task-complete-policy' +import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder' +import { subscribeToPtyData } from './pty-data-sidecar-subscriptions' +import { createPtyOutputProcessor } from './pty-transport' +import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate' +import { + isMainTerminalSideEffectAuthorityForPty, + registerTerminalSideEffectFactConsumer +} from './terminal-side-effect-facts-handler' +import { dispatchTerminalNotification } from './use-notification-dispatch' +import { acquireHiddenRendererPtyDeliveryClaim } from './pty-renderer-delivery-claims' + +// Why: mirrors AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS in pty-connection.ts. +// The parked path must keep the live path's BEL-vs-completion race window so +// notification behavior is identical whether a tab is parked or mounted. +const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS + +type StoreState = ReturnType + +function isAgentTaskCompleteOsNotificationEnabled(state: StoreState): boolean { + return isAgentTaskCompleteOsNotificationEnabledFromState(state) +} + +function isAgentTaskCompleteTrackingEnabled(state: StoreState): boolean { + return isAgentTaskCompleteTrackingEnabledFromState(state) +} + +export type ParkedTerminalByteWatcherOptions = { + ptyId: string + tabId: string + worktreeId: string + /** Stable terminal-layout leaf UUID. Combined with tabId into the paneKey + * used for cache-timer, unread, and notification attribution. */ + leafId: string + /** PaneManager pane id the unmounted pane used. Runtime pane titles are + * keyed by it, so the watcher must write the slot the live path wrote — + * a different id would leave a stale (possibly "working") title behind. */ + paneId: number + /** Whether this PTY's pane was the tab's active split pane. Mirrors the + * live path, where only the focused split drives the tab title. */ + drivesTabTitle?: boolean + /** The pane's last known runtime title at park time. Seeds the agent + * tracker so an agent that was working when the pane unmounted still + * fires its completion when it goes idle while parked. */ + initialTitle?: string + /** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */ + sendInput: (data: string) => void +} + +const parkedWatcherDisposersByPtyId = new Map void>() + +export function startParkedTerminalByteWatcher( + options: ParkedTerminalByteWatcherOptions +): () => void { + const { ptyId, tabId, worktreeId, paneId, sendInput } = options + const drivesTabTitle = options.drivesTabTitle ?? true + const paneKey = makePaneKey(tabId, options.leafId) + + // Why: one watcher per PTY. A stale watcher from a previous park cycle would + // double-fire bell/completion side effects for the same bytes. + parkedWatcherDisposersByPtyId.get(ptyId)?.() + + let disposed = false + let pendingBellNotification = false + // Why: a watcher-written runtime title (especially into a negative fallback + // slot) has no live pane to overwrite it after reveal; a stale 'working' + // entry would pin worktree status forever. Track writes so dispose can + // clear exactly the slot this watcher touched. + let wroteRuntimeTitleSlot = false + let bellNotificationTimer: ReturnType | null = null + let agentTaskCompleteTimer: ReturnType | null = null + + const clearBellNotificationTimer = (): void => { + if (bellNotificationTimer !== null) { + clearTimeout(bellNotificationTimer) + bellNotificationTimer = null + } + } + + const clearAgentTaskCompleteTimer = (): void => { + if (agentTaskCompleteTimer !== null) { + clearTimeout(agentTaskCompleteTimer) + agentTaskCompleteTimer = null + } + } + + // Why: like the live path, a BEL OS notification only yields when the + // pending completion would itself produce an OS notification. + const hasPendingAgentTaskCompleteNotification = (): boolean => + agentTaskCompleteTimer !== null && + isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState()) + + const scheduleTerminalBellNotification = (): void => { + if (bellNotificationTimer !== null) { + return + } + bellNotificationTimer = setTimeout(() => { + bellNotificationTimer = null + if (disposed) { + pendingBellNotification = false + return + } + if (hasPendingAgentTaskCompleteNotification()) { + return + } + pendingBellNotification = false + dispatchTerminalNotification(worktreeId, { source: 'terminal-bell', paneKey }) + }, PARKED_NOTIFICATION_GRACE_MS) + } + + // Why: one policy block for both consumption modes — byte parsing (kill + // switch off) and pty:sideEffect facts (main authority on). The semantics + // must be identical or flipping the switch changes notification behavior. + const sideEffectCallbacks = { + onTitleChange: (title: string): void => { + const state = useAppStore.getState() + wroteRuntimeTitleSlot = true + state.setRuntimePaneTitle(tabId, paneId, title) + if (drivesTabTitle) { + state.updateTabTitle(tabId, title) + } + }, + onBell: (): void => { + const state = useAppStore.getState() + state.markWorktreeUnread(worktreeId) + state.markTerminalTabUnread(tabId) + if (state.settings?.experimentalTerminalAttention === true) { + state.markTerminalPaneUnread(paneKey) + } + // Why: agent CLIs often emit BEL in the same completion burst as their + // working→idle title change. Delay only the OS notification so the + // richer agent-task-complete notification can win (live-path parity). + pendingBellNotification = true + if (!hasPendingAgentTaskCompleteNotification()) { + scheduleTerminalBellNotification() + } + }, + onAgentBecameIdle: (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => { + // Why: stale-derived idles come from main's unthrottled 3s timer, not + // observed bytes — clear session state, never schedule the completion + // notification a merely-paused agent did not earn (live-path parity). + if (meta?.staleWorkingTitleClear) { + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + return + } + const state = useAppStore.getState() + // Why: mirrors pty-connection — null settings means "not hydrated yet"; + // a spurious timestamp is harmless while a dropped one loses the timer. + if ( + isClaudeAgent(title) && + (state.settings === null || state.settings.promptCacheTimerEnabled) + ) { + state.setCacheTimerStartedAt(paneKey, Date.now()) + } + if (!isAgentTaskCompleteTrackingEnabled(state)) { + return + } + clearAgentTaskCompleteTimer() + agentTaskCompleteTimer = setTimeout(() => { + agentTaskCompleteTimer = null + if (disposed) { + return + } + // Why: the completion supersedes a concurrent BEL so each completion + // burst yields exactly one OS notification, same as the live path. + pendingBellNotification = false + clearBellNotificationTimer() + dispatchTerminalNotification(worktreeId, { + source: 'agent-task-complete', + terminalTitle: title, + paneKey, + ...(isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState()) + ? {} + : { suppressOsNotification: true }) + }) + }, PARKED_NOTIFICATION_GRACE_MS) + }, + onAgentBecameWorking: (): void => { + // Why: a new API call refreshes the prompt-cache TTL, so clear any + // running countdown; it restarts when the agent next becomes idle. + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + clearAgentTaskCompleteTimer() + if (pendingBellNotification) { + scheduleTerminalBellNotification() + } + }, + onAgentExited: (): void => { + // Why: title reverting to a plain shell means the agent session ended; + // a stale countdown must not survive in the sidebar while parked. + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + } + } + + // Why: parking eligibility excludes remote-runtime and SSH PTYs, so every + // watched PTY's bytes transit local main — when the authority switch is on, + // the watcher must NOT register byte parsers (the fact consumer below is + // the single policy consumer; double registration would double-fire bells). + const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({ + settings: useAppStore.getState().settings, + runtimeEnvironmentId: null + }) + // Why: under the Phase-4 gate a parked PTY needs no renderer bytes at all — + // facts carry side effects and the reveal remount restores from the model + // snapshot. Decided once at watcher start: it picks which 2031 responder + // (byte sidecar vs fact reply) exists, so it must never flip per chunk. + const hiddenDeliveryGateActive = + mainSideEffectAuthority && + isRendererHiddenPtyDeliveryGateEnabled(useAppStore.getState().settings) + + const sendMode2031Reply = (): void => { + const settings = useAppStore.getState().settings + sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()))) + } + + // Why (byte-parser mode only): reuse the transport's output processor so + // the parked path keeps the exact live-path parsing semantics — all-titles + // ordering, normalization, the cursor-agent native-title drop, the + // OSC-aware stateful bell detector, and the working/idle agent tracker. + // initialAgentTitle: an agent already working at park time must still + // produce a working→idle transition; main's continuous tracker covers this + // in fact-consumer mode. + const processor = mainSideEffectAuthority + ? null + : createPtyOutputProcessor({ + ...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}), + ...sideEffectCallbacks + }) + // Why (byte-parser mode only): with main authority, pr-link facts arrive on + // the channel below; byte-scanning too would observe every link twice. + const observeTerminalGitHubPRLink = mainSideEffectAuthority + ? null + : createTerminalGitHubPRLinkDetector() + const unregisterFactConsumer = mainSideEffectAuthority + ? registerTerminalSideEffectFactConsumer({ + ptyId, + // Why: no title snapshot on park — the pane's runtime title slot is + // already current at park time, exactly like the byte-parser mode. + callbacks: { + ...sideEffectCallbacks, + onPrLink: (link) => + useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link), + // Why (gate mode only): bytes never arrive while gated, so the 2031 + // subscribe arrives as a main-tracker fact instead of a byte scan. + // The reply is still sent from here — query authority stays with + // the view/watcher (model/view contract invariant 6). + ...(hiddenDeliveryGateActive ? { onMode2031Subscribe: sendMode2031Reply } : {}) + } + }) + : null + + // Why: no xterm exists while parked, so nothing answers a DECSET 2031 + // subscription. With the hidden-delivery gate OFF the byte responder is the + // parked path's only byte consumer under main authority. With the gate ON it + // must NOT register: its subscribeToPtyData sidecar doubles as a + // delivery-interest signal that would force-feed bytes to the gated PTY — + // the fact callback above replaces the byte scan. + const stopMode2031Responder = hiddenDeliveryGateActive + ? null + : startParkedTerminalMode2031Responder({ ptyId, sendInput }) + + // Why: parked tabs are the canonical hidden view — mark the PTY gated so + // main stops renderer byte delivery; dispose clears the bit before the + // reveal remount re-registers pane handlers (existing dispose ordering). + const releaseHiddenDeliveryClaim = hiddenDeliveryGateActive + ? acquireHiddenRendererPtyDeliveryClaim(ptyId) + : null + + // Why (byte-parser mode only): with main authority the watcher consumes + // pty:sideEffect facts exclusively and registers NO byte parsers here — + // title/bell/agent parsing and the PR-link scan would double-fire policy. + const unsubscribeByteParsers = + processor === null + ? null + : subscribeToPtyData(ptyId, (data) => { + // Why: empty pane callbacks — the watcher wants only the parser + // side effects; there is no xterm to deliver bytes to. + processor.processData(data, {}) + if (observeTerminalGitHubPRLink) { + for (const link of observeTerminalGitHubPRLink(data)) { + useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link) + } + } + }) + + const dispose = (): void => { + if (disposed) { + return + } + disposed = true + // Why: unhide BEFORE the reveal remount registers pane handlers — main + // resumes delivery and (if bytes were dropped) emits the restore marker + // the remounted pane's restore machinery consumes. + releaseHiddenDeliveryClaim?.() + stopMode2031Responder?.() + unsubscribeByteParsers?.() + unregisterFactConsumer?.() + // Why: cancels the deferred side-effect drain, stale-title timer, and + // tracker/bell-detector state so the watcher cannot fire after the + // revealed pane's live parsers take over. + processor?.clearAccumulatedState() + clearBellNotificationTimer() + clearAgentTaskCompleteTimer() + pendingBellNotification = false + // Why: the store merge never deletes title slots, so a watcher-written + // entry would strand after reveal (the revealing pane re-registers under + // its own pane id) and could pin worktree status 'working'. The revealed + // pane repopulates its slot via its own title flow. + if (wroteRuntimeTitleSlot) { + wroteRuntimeTitleSlot = false + useAppStore.getState().clearRuntimePaneTitle(tabId, paneId) + } + if (parkedWatcherDisposersByPtyId.get(ptyId) === dispose) { + parkedWatcherDisposersByPtyId.delete(ptyId) + } + } + parkedWatcherDisposersByPtyId.set(ptyId, dispose) + return dispose +} diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts new file mode 100644 index 00000000000..da804026de6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts @@ -0,0 +1,50 @@ +/** + * DECSET 2031 color-scheme responder for parked terminals (byte-scan mode). + * + * Why a dedicated byte sidecar: no xterm exists while a tab is parked, so + * nothing answers a TUI's mode-2031 theme subscription. Query authority stays + * with the view/watcher (model/view contract invariant 6), so this reply can + * never move to main. Phase 4: this subscribeToPtyData registration doubles + * as a delivery-interest signal, so it is only used while the hidden-delivery + * gate is OFF — gated parked PTYs answer from the main tracker's + * '2031-subscribe' fact instead (parked-terminal-byte-watcher.ts). + * + * Survives Phase 6 (skip-grammar deletion): mounted switch-off hidden panes + * answer 2031 from xterm once the background queue drains, but a PARKED tab + * has no xterm in any switch-off mode, and the '2031-subscribe' fact is only + * consumed while the gate is ON — this sidecar stays the only answerer here. + */ +import { + mode2031SequenceFor, + resolveTerminalColorSchemeMode, + scanMode2031Sequences +} from '../../../../shared/terminal-color-scheme-protocol' +import { useAppStore } from '@/store' +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import { subscribeToPtyData } from './pty-data-sidecar-subscriptions' + +export type ParkedTerminalMode2031ResponderOptions = { + ptyId: string + /** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */ + sendInput: (data: string) => void +} + +export function startParkedTerminalMode2031Responder( + options: ParkedTerminalMode2031ResponderOptions +): () => void { + const { ptyId, sendInput } = options + // Why: a DECSET 2031 subscribe can be split across PTY chunks; the scan + // carries a bounded tail between chunks so split sequences still match. + let scanTail = '' + return subscribeToPtyData(ptyId, (data) => { + const scan = scanMode2031Sequences(scanTail, data) + scanTail = scan.tail + if (!scan.subscribe) { + return + } + // Why: reply with the resolved theme so TUIs that subscribe while parked + // still learn it before the pane is ever revealed. + const settings = useAppStore.getState().settings + sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()))) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 70abd4fae3f..8e151c6ab5c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -76,4 +76,10 @@ export type PtyConnectionDeps = { setCacheTimerStartedAt: (key: string, ts: number | null) => void syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void + /** Records a DECSET 2031 subscription answered from main's + * '2031-subscribe' fact, mirroring the xterm CSI handler's registry write + * (paneMode2031 + last replied theme) so later theme flips push CSI 997. + * The reply itself is sent by the fact handler — query authority stays + * with the view (model/view contract invariant 6). */ + recordPaneMode2031Subscription?: (paneId: number, repliedMode: 'dark' | 'light') => void } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 72aac62a8e8..f4e80152546 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -149,6 +149,8 @@ type StoreState = { terminalWindowsShell?: string terminalWindowsWslDistro?: string | null localWindowsRuntimeDefault?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string | null } + terminalMainSideEffectAuthority?: boolean + terminalHiddenDeliveryGate?: boolean notifications?: { enabled?: boolean agentTaskComplete?: boolean @@ -195,7 +197,10 @@ type StoreState = { } type ConnectCallbacks = { - onData?: (data: string, meta?: { seq?: number; rawLength?: number; background?: boolean }) => void + onData?: ( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean; droppedOutput?: boolean } + ) => void onReplayData?: (data: string, meta?: { clearBeforeReplay?: boolean }) => void onError?: (msg: string) => void } @@ -379,7 +384,8 @@ function createPane(paneId: number) { type: 'normal' as const, viewportY: 0, baseY: 0, - cursorY: 0 + cursorY: 0, + cursorX: 0 } return { id: paneId, @@ -397,6 +403,7 @@ function createPane(paneId: number) { sendFocusMode: false }, options: { + scrollback: 5_000, ignoreBracketedPasteMode: false, theme: { foreground: '#eeeeee', @@ -738,7 +745,14 @@ describe('connectPanePty', () => { projects: [], sshConnectionStates: new Map(), cacheTimerByKey: {}, - settings: { promptCacheTimerEnabled: true, experimentalTerminalAttention: true }, + // Why: terminalMainSideEffectAuthority false pins the legacy renderer + // byte-parser wiring this suite asserts on (onTitleChange/onBell on the + // transport). The authority-on fact-consumer mode has its own tests. + settings: { + promptCacheTimerEnabled: true, + experimentalTerminalAttention: true, + terminalMainSideEffectAuthority: false + }, codexRestartNoticeByPtyId: {}, deferredSshReconnectTargets: [], deferredSshSessionIdsByTabId: {}, @@ -811,6 +825,8 @@ describe('connectPanePty', () => { hasChildProcesses: vi.fn().mockResolvedValue(false), write: vi.fn(), writeAccepted: vi.fn().mockResolvedValue(true), + setHiddenRendererPty: vi.fn(), + setPtyDeliveryInterest: vi.fn(), ackColdRestore: vi.fn(), onClearBufferRequest: vi.fn(() => vi.fn()), onSerializeBufferRequest: vi.fn(() => vi.fn()), @@ -2367,6 +2383,32 @@ describe('connectPanePty', () => { expect(manager.setActivePane).toHaveBeenCalledWith(1, { focus: true }) }) + it('closes a hidden split pane whose PTY exits before output instead of keeping a ghost', async () => { + // Why (regression, ghost blank pane): the keep above is a visible-failure + // UX. A hidden pane's bytes are withheld by the hidden-delivery gate, so + // "no output" proves nothing there — keeping it strands a binding-less + // pane that remounts as a permanently blank ghost on reveal. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2, 2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + isVisibleRef: { current: false }, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect(onPtyExit).toBeTypeOf('function') + + onPtyExit?.('pty-pane-2') + + expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-pane-2') + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + expect(manager.closePane).toHaveBeenCalledWith(2) + }) + it('keeps a worktree sole terminal mounted when its freshly-spawned PTY exits before input (direnv failure)', async () => { // Why (regression): a PR worktree can ship an .envrc whose direnv command // fails, so the only terminal's login shell exits non-zero immediately. The @@ -7316,18 +7358,89 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalled() }) - it('keeps non-visible local PTY bytes on the live xterm path for release', async () => { - const pendingTimeouts: (() => void)[] = [] - const originalSetTimeout = globalThis.setTimeout - globalThis.setTimeout = vi.fn((fn: () => void) => { - pendingTimeouts.push(fn) - return 999 as unknown as ReturnType - }) as unknown as typeof setTimeout + // Why: Phase 6 deleted the hidden-skip eligibility grammar. With the kill + // switch off, EVERY hidden chunk — plain, control-heavy, rich glyphs, + // synchronized frames, embedded queries — rides the bounded background + // scheduler queue and parses in xterm; nothing is content-scanned per chunk. + it('queues hidden PTY bytes on the background scheduler without per-chunk scanning', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() try { + const hiddenChunks = [ + 'plain hidden text\r\n', + '\x1b[2J\x1b[Hcontrol redraw\r\n', + '\x1b[2J\x1b[H╭ table 😀 ╮\r\n', + '\x1b[?2026h| Sam Syntax | 😀 |\r\n\x1b[?2026l', + '\x1b[?2026h\x1b[6n' + ] + for (const chunk of hiddenChunks) { + capturedDataCallback.current?.(chunk) + } + + // Background path defers writes; nothing is written synchronously. + expect(pane.terminal.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(50) + // The drain may coalesce queued chunks into one write — assert content. + const written = pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + for (const chunk of hiddenChunks) { + expect(written).toContain(chunk) + } + // No model restore is latched for bounded hidden output. + expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + describe('hidden-delivery gate', () => { + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } as StoreState['settings'] + } + + function getSetHiddenRendererPtyMock(): ReturnType { + return window.api.pty.setHiddenRendererPty as unknown as ReturnType + } + + async function connectHiddenPane(deps: ReturnType): Promise<{ + transport: MockTransport + pane: ReturnType + dataCallback: ( + data: string, + meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } + ) => void + binding: { syncProcessTracking: () => void; dispose: () => void } + }> { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } + ) => void) + | null + } = { current: null } transport.connect.mockImplementation( async ({ callbacks }: { callbacks: ConnectCallbacks }) => { capturedDataCallback.current = callbacks.onData ?? null @@ -7335,101 +7448,721 @@ describe('connectPanePty', () => { } ) transportFactoryQueue.push(transport) - const pane = createPane(1) const manager = createManager(1) + const binding = connectPanePty(pane as never, manager as never, deps as never) as { + syncProcessTracking: () => void + dispose: () => void + } + await flushAsyncTicks(6) + expect(capturedDataCallback.current).not.toBeNull() + return { transport, pane, dataCallback: capturedDataCallback.current!, binding } + } + + it('marks the PTY hidden on hidden output and clears it before requesting restore on reveal', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'model snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + pane.terminal.options.scrollback = 50_000 + + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) + + // Why: with the skip grammar gone, gated drops latch the restore via + // main's out-of-band marker, not a renderer-side content scan. + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + + // Reveal rides the visible-resume backlog recovery hook. + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 50_000 }) + // The unhide IPC must precede the snapshot request (seq-guard contract). + const unhideOrder = setHiddenRendererPty.mock.invocationCallOrder.at(-1)! + const snapshotOrder = getMainBufferSnapshot.mock.invocationCallOrder[0]! + expect(unhideOrder).toBeLessThan(snapshotOrder) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('model snapshot'), + expect.any(Function) + ) + }) + + it('clears the hidden bit on visibility flips through syncProcessTracking', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { dataCallback, binding } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + ;(deps.isVisibleRef as { current: boolean }).current = true + binding.syncProcessTracking() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + + // Hiding again re-marks through the same lifecycle hook. + ;(deps.isVisibleRef as { current: boolean }).current = false + binding.syncProcessTracking() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + }) + + it('marks hidden codex panes immediately — no startup renderer-query window remains', async () => { + enableMainAuthority() const deps = createDeps({ - isVisibleRef: { current: false } + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const { transport, dataCallback } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + // Why: Phase 6 deleted the 10s codex window — codex startups gate like + // any hidden pane and the main responder answers their startup probes. + dataCallback('startup probe output\r\n') + expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) + + // The fact stays the sole 2031 responder for gate-managed PTYs. + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 8, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + }) + + it('latches model restore from the out-of-band marker and restores on reveal', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'dropped bytes snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + // Why: the marker subscription is keyed by the live PTY id — the byte + // path latches it on the first hidden chunk, like the hidden mark. + dataCallback('pre-drop output\r\n', { seq: 16, rawLength: 17 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + + // Main dropped gated bytes and signalled it out-of-band. + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('dropped bytes snapshot'), + expect.any(Function) + ) + }) + + it('answers each 2031-subscribe fact exactly once, before any hidden mark exists', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport } = await connectHiddenPane(deps) + // Simulate the transport's spawn completion so the pane registers its + // side-effect fact consumer (the mock transport never calls onPtySpawn). + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + // Why: no pty:data has flowed, so no hidden mark was sent — the fact + // can outrun the mark (codex post-startup-window race) and must still + // reply: ownership is structural, never mark-dependent. + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + // Why: a visible gated pane still answers via the fact — the lifecycle + // suppresses the xterm CSI reply for gate-managed panes, so this stays + // the only reply for the new subscribe. + ;(deps.isVisibleRef as { current: boolean }).current = true + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 24, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(2) + expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[?997;1n') + }) + + it('registers the fact-answered 2031 subscription for later theme flips', async () => { + enableMainAuthority() + const recordPaneMode2031Subscription = vi.fn() + const deps = createDeps({ + isVisibleRef: { current: false }, + recordPaneMode2031Subscription + }) + const { transport } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] }) - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + // Why: without the registry write, applyTerminalAppearance's + // maybePushMode2031Flip never pushes CSI 997 after a theme change and + // the revealed TUI keeps a stale theme. + expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, 'dark') + }) - expect(capturedDataCallback.current).not.toBeNull() - capturedDataCallback.current?.('hello\r\n') - expect(pane.terminal.write).not.toHaveBeenCalledWith('hello\r\n') + it('reports the gate-managed predicate on the binding for the xterm 2031 observer', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { binding } = await connectHiddenPane(deps) + const bindingWithPredicate = binding as typeof binding & { + isHiddenDeliveryGateManagedPty: () => boolean + } + expect(bindingWithPredicate.isHiddenDeliveryGateManagedPty()).toBe(true) + }) - for (const fn of pendingTimeouts) { - fn() + it('declares hidden-at-spawn on connect for hidden panes', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport } = await connectHiddenPane(deps) + // Why: waiting for the first dataCallback sync left a spawn-time query + // window where neither side replied (the spawn-time DA1 loss). The flag + // lets main mark the PTY hidden before its first byte. + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ initiallyHidden: true }) + ) + }) + + it('keeps visible spawns undeclared (visible spawn unchanged)', async () => { + enableMainAuthority() + const deps = createDeps() + const { transport } = await connectHiddenPane(deps) + expect(transport.connect.mock.calls[0]![0]).not.toHaveProperty('initiallyHidden') + }) + + it('declares hidden-at-spawn for hidden codex panes too', async () => { + enableMainAuthority() + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const { transport } = await connectHiddenPane(deps) + // Why: the 10s codex startup window is deleted — codex spawns are + // main-owned from byte zero, with the model responder answering their + // startup probes (including ConPTY's blocking DA1; the main-side pin is + // pty.test.ts 'answers DA1 from the model on the first chunk of a + // hidden-at-spawn PTY'). + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ initiallyHidden: true }) + ) + }) + + it('does not gate or fact-reply when the hidden-delivery kill switch is off', async () => { + enableMainAuthority() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalHiddenDeliveryGate: false + } as StoreState['settings'] + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport, dataCallback, binding } = await connectHiddenPane(deps) + // Why: the lifecycle's xterm CSI observer consults this predicate — + // kill switch off must keep the legacy xterm reply path. + expect( + ( + binding as typeof binding & { isHiddenDeliveryGateManagedPty: () => boolean } + ).isHiddenDeliveryGateManagedPty() + ).toBe(false) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).not.toHaveBeenCalled() + + // Why: gate off keeps the byte-scan responder authoritative — the fact + // must not produce a second reply for the same subscribe. + const factsHandler = await import('./terminal-side-effect-facts-handler') + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).not.toHaveBeenCalled() + }) + + it('clears a marked-hidden PTY on dispose so a remount is never gated', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { dataCallback, binding } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + + binding.dispose() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + }) + + it('never treats a live chunk that strips to empty as a restore marker', async () => { + // Why: a chunk that is purely OSC 9999 reaches the data callback as '' + // (transport stripping) — only the out-of-band pty:modelRestoreNeeded + // channel may trigger a snapshot restore, or visible panes would be + // spuriously cleared and repainted mid-session. + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + dataCallback('', { seq: 32, rawLength: 24 }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + }) + + it('gates marker re-arms during an in-flight foreground restore and repaints once after', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const firstSnapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + }>() + getMainBufferSnapshot + .mockReturnValueOnce(firstSnapshot.promise) + .mockResolvedValue({ data: 'post-flood repaint\r\n', cols: 100, rows: 30, seq: 96 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'pending-cap', markerSeq: 64 }) + await flushAsyncTicks(4) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + try { + // Why (rc.7.perf feedback loop): a second drop marker while the first + // snapshot is still serializing on a VISIBLE pane is this pane's own + // restore backpressure. Re-fetching per marker kept the loop alive for + // the whole flood — the marker must NOT schedule another fetch. + vi.useFakeTimers() + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 80 + }) + firstSnapshot.resolve({ data: 'first snapshot\r\n', cols: 100, rows: 30, seq: 64 }) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + // Flood quiet: the suppression window elapses and exactly ONE deferred + // repaint fetches a fresh snapshot to heal the dropped gap. + vi.advanceTimersByTime(2_100) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + describe('foreground flood restore feedback loop (rc.7.perf)', () => { + function writtenFloodData(pane: ReturnType): string { + return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') } - expect(pane.terminal.write).toHaveBeenCalledWith('hello\r\n') - } finally { - globalThis.setTimeout = originalSetTimeout - } - }) + async function startInFlightRestore(): Promise<{ + pane: ReturnType + transport: MockTransport + dataCallback: ( + data: string, + meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } + ) => void + getMainBufferSnapshot: ReturnType + resolveFirstSnapshot: (snapshot: { + data: string + cols: number + rows: number + seq: number + }) => void + }> { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { pane, transport, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const firstSnapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + }>() + getMainBufferSnapshot + .mockReturnValueOnce(firstSnapshot.promise) + .mockResolvedValue({ data: 'repaint snapshot\r\n', cols: 100, rows: 30, seq: 5_000_000 }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 64 + }) + await flushAsyncTicks(4) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + return { + pane, + transport, + dataCallback, + getMainBufferSnapshot, + resolveFirstSnapshot: (snapshot) => firstSnapshot.resolve(snapshot) + } + } - it('keeps visually rich hidden PTY bytes on the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) + it('abandons the restore on queue overflow, writes the stream through, and repaints once', async () => { + const { pane, dataCallback, getMainBufferSnapshot, resolveFirstSnapshot } = + await startInFlightRestore() - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } + // Flood while the snapshot is in flight: overflows the 512KB restore + // queue — the live stream is outrunning snapshot fetch+replay. + dataCallback('f'.repeat(300 * 1024), { seq: 300 * 1024 + 64, rawLength: 300 * 1024 }) + dataCallback('g'.repeat(300 * 1024), { seq: 600 * 1024 + 64, rawLength: 300 * 1024 }) + + try { + vi.useFakeTimers() + resolveFirstSnapshot({ data: 'flood snapshot\r\n', cols: 100, rows: 30, seq: 64 }) + await flushAsyncTicks(20) + + // Cut 1: the overflow abandons the restore instead of re-fetching. + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + // Cut 2: drop sentinels and seq-gap chunks during the flood window + // must not re-arm restores — the post-gap bytes write through. + dataCallback('', { droppedOutput: true }) + dataCallback('AFTER-FLOOD', { seq: 700 * 1024, rawLength: 11 }) + await flushAsyncTicks(8) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(writtenFloodData(pane)).toContain('AFTER-FLOOD') + + // After the flood goes quiet: exactly ONE deferred repaint. + vi.advanceTimersByTime(2_100) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(5_000) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('salvages stateful queries out of an overflowing restore queue', async () => { + const { pane, transport, dataCallback } = await startInFlightRestore() + + // Queue 400KB, then a chunk that overflows the cap and carries a DSR + // probe. The content is discarded (snapshot owns it) but the probe's + // reply is SYNTHESIZED directly — replaying it into xterm would race + // the restore's discard and the replay guard's auto-reply swallow. + dataCallback('a'.repeat(400 * 1024), { seq: 400 * 1024, rawLength: 400 * 1024 }) + dataCallback(`${'b'.repeat(200 * 1024)}\x1b[6n`, { + seq: 600 * 1024 + 4, + rawLength: 200 * 1024 + 4 + }) + await flushAsyncTicks(8) + + const replies = transport.sendInput.mock.calls.map((call) => String(call[0])) + // oxlint-disable-next-line no-control-regex -- the ESC byte IS the payload: this matches the CPR reply + expect(replies.some((reply) => /^\u001b\[\d+;\d+R$/.test(reply))).toBe(true) + const written = writtenFloodData(pane) + expect(written).not.toContain('aaaa') + expect(written).not.toContain('bbbb') + }) + + it('keeps the hidden-pane drop sentinel arming a reveal restore (gate unchanged)', async () => { + enableMainAuthority() + const isVisibleRef = { current: false } + const deps = createDeps({ isVisibleRef }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'hidden reveal snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + + // Hidden pane: the sentinel latches restore-needed but must not fetch. + dataCallback('', { droppedOutput: true }) + await flushAsyncTicks(8) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + + // Reveal: the latched restore fetches exactly one snapshot. + isVisibleRef.current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(writtenFloodData(pane)).toContain('hidden reveal snapshot') + }) }) - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) + describe('post-restore backlog reconciliation', () => { + async function restoreVisiblePaneToBaseline(): Promise<{ + pane: ReturnType + dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void + getMainBufferSnapshot: ReturnType + }> { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'restored snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 64 + }) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('restored snapshot'), + expect.any(Function) + ) + pane.terminal.write.mockClear() + return { pane, dataCallback, getMainBufferSnapshot } + } - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - try { - const hiddenTuiChunk = '\x1b[2J\x1b[H╭ table 😀 ╮\r\n' - capturedDataCallback.current?.(hiddenTuiChunk) + function writtenData(pane: ReturnType): string { + return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + } - expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenTuiChunk) - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(hiddenTuiChunk) - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) + it('drops backlog chunks the restored snapshot already covers', async () => { + const { pane, dataCallback } = await restoreVisiblePaneToBaseline() - it('keeps split hidden synchronized output frames on the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' + // Whole chunk at or before the baseline seq: duplicate, never written. + dataCallback('OLD-DUPLICATE', { seq: 60, rawLength: 13 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).not.toContain('OLD-DUPLICATE') + + // Contiguous post-baseline chunk flows through normally. + dataCallback('NEW', { seq: 67, rawLength: 3 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('NEW') + }) + + it('slices a partial overlap when raw and clean lengths match', async () => { + const { pane, dataCallback } = await restoreVisiblePaneToBaseline() + + // start seq 61 < baseline 64 < end seq 67 — only the last 3 chars are new. + dataCallback('ABCDEF', { seq: 67, rawLength: 6 }) + await flushAsyncTicks(8) + + const written = writtenData(pane) + expect(written).toContain('DEF') + expect(written).not.toContain('ABC') + }) + + it('forces a fresh snapshot for an overlap whose offsets cannot be mapped', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'second snapshot\r\n', + cols: 100, + rows: 30, + seq: 80 + }) + + // rawLength (6) !== data.length (4): renderer-side OSC stripping makes + // the slice offset unmappable — restore from a fresh snapshot instead. + dataCallback('ABCD', { seq: 67, rawLength: 6 }) + await flushAsyncTicks(20) + + expect(writtenData(pane)).not.toContain('ABCD') + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(writtenData(pane)).toContain('second snapshot') + }) + + it('detects a seq gap after restore and forces another restore', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'gap-heal snapshot\r\n', + cols: 100, + rows: 30, + seq: 120 + }) + + // Why: a chunk starting past the continuity point (start seq 87 > + // expected 64) means main trimmed bytes after the one-shot overflow + // marker was consumed — only the model snapshot can heal the gap. + dataCallback('AFTER-GAP', { seq: 96, rawLength: 9 }) + await flushAsyncTicks(20) + + expect(writtenData(pane)).not.toContain('AFTER-GAP') + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(writtenData(pane)).toContain('gap-heal snapshot') + }) + + it('writes genuinely-new live output whose seq sits below an empty-backlog baseline', async () => { + // E2E twin (terminal-hidden-tui-visual-restore "keeps newer live + // output correct"): main's snapshot seq is a cumulative PTY counter + // (shell init + prompt echo + hidden frame), while a synthetic live + // chunk meters only its own frames — far below the baseline. With an + // empty pending queue main can never re-deliver seqs at or below the + // snapshot, so the chunk must write, never silently drop. + enableMainAuthority() + const isVisibleRef = { current: true } + const deps = createDeps({ isVisibleRef }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + // Visible prompt echo metered in main's cumulative seq domain. + dataCallback('$ node frame-script.mjs\r\n', { seq: 2_315, rawLength: 25 }) + // Pane hides mid-stream; main drops the hidden frame and marks restore. + isVisibleRef.current = false + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + markerSeq: 2_472 + }) + // Reveal: the snapshot covers everything ingested; pending queue empty + // (pendingDeliveryStartSeq === seq). + getMainBufferSnapshot.mockResolvedValue({ + data: 'LOW_RISK_RESTORE_FRAME_40\r\n', + cols: 100, + rows: 30, + seq: 2_472, + pendingDeliveryStartSeq: 2_472 + }) + isVisibleRef.current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + expect(writtenData(pane)).toContain('LOW_RISK_RESTORE_FRAME_40') + pane.terminal.write.mockClear() + + // Newer live frame injected with a seq domain unrelated to main's + // counter (e2e __terminalPtyDataInjection twin). + dataCallback('LOW_RISK_RESTORE_FRAME_41\r\n', { seq: 315, rawLength: 27 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('LOW_RISK_RESTORE_FRAME_41') + + // The retired baseline keeps subsequent low-seq live chunks flowing. + dataCallback('progress=041\r\n', { seq: 329, rawLength: 14 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('progress=041') + }) + + it('keeps suppressing backlog duplicates inside the reported pending window', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'windowed snapshot\r\n', + cols: 100, + rows: 30, + seq: 96, + pendingDeliveryStartSeq: 80 + }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 96 + }) + await flushAsyncTicks(20) + expect(writtenData(pane)).toContain('windowed snapshot') + pane.terminal.write.mockClear() + + // Inside the pending window (80, 96]: a draining backlog duplicate. + dataCallback('IN-WINDOW-DUP-16', { seq: 96, rawLength: 16 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).not.toContain('IN-WINDOW-DUP-16') + + // Past the baseline: genuinely-new live output still flows. + dataCallback('PAST-BASELINE', { seq: 109, rawLength: 13 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('PAST-BASELINE') + + // Below the pending window (≤ 80): main can never re-send these seqs, + // so this is a foreign seq domain — written, never silently dropped. + dataCallback('BELOW-WINDOW', { seq: 60, rawLength: 12 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('BELOW-WINDOW') + }) }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - try { - const startChunk = '\x1b[?2026h' - const plainRowChunk = '| Sam Syntax | Compiler | Online |\r\n' - const endChunk = 'LONG_TABLE_SCROLL_RESTORE_marker\r\n\x1b[?2026l' - - capturedDataCallback.current?.(startChunk) - capturedDataCallback.current?.(plainRowChunk) - capturedDataCallback.current?.(endChunk) - - expect(pane.terminal.write).not.toHaveBeenCalledWith(plainRowChunk) - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(`${startChunk}${plainRowChunk}${endChunk}`) - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } }) it('schedules WebGL atlas recovery after hidden synchronized output parses', async () => { @@ -7986,7 +8719,7 @@ describe('connectPanePty', () => { binding.dispose() }) - it('side-channel answers mode 2031 when hidden Codex output is snapshot-backed', async () => { + it('writes mode 2031 through hidden xterm instead of side-channel answering it', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -8002,17 +8735,10 @@ describe('connectPanePty', () => { const pane = createPane(1) const manager = createManager(1) - const paneMode2031Ref = { current: new Map() } - const paneLastThemeModeRef = { current: new Map() } const binding = connectPanePty( pane as never, manager as never, - createDeps({ - isVisibleRef: { current: false }, - paneMode2031Ref, - paneLastThemeModeRef, - startup: { command: 'codex' } - }) as never + createDeps({ isVisibleRef: { current: false } }) as never ) await flushAsyncTicks(6) @@ -8021,10 +8747,8 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('\x1b[?2031h') vi.advanceTimersByTime(50) - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;2n') - expect(paneMode2031Ref.current.get(1)).toBe(true) - expect(paneLastThemeModeRef.current.get(1)).toBe('light') - expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[?2031h') + expect(transport.sendInput).not.toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?2031h') } finally { vi.useRealTimers() } @@ -8583,6 +9307,53 @@ describe('connectPanePty', () => { } }) + it('repaints from the main-owned snapshot when main drops pending output at the cap', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { droppedOutput?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'healed from snapshot\r\n', + cols: 100, + rows: 30 + }) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ isVisibleRef: { current: true } }) as never + ) + try { + await flushAsyncTicks(6) + getMainBufferSnapshot.mockClear() + + // Main hit the per-PTY pending cap while the renderer was starved and + // sent the droppedOutput sentinel: the stream has a gap, so the pane + // must repaint from the authoritative main-owned buffer. + capturedDataCallback.current?.('', { droppedOutput: true }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('healed from snapshot'), + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + it('keeps split stateful Codex queries live after becoming visible', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -8868,12 +9639,21 @@ describe('connectPanePty', () => { }) it('does not apply stale background Codex query chunks after hidden snapshot restore', async () => { - const visibilityChangeHandler: { current: (() => void) | null } = { current: null } + // Fire-all like a real event target: the pane resync handler and the + // stale-visibility trust handler both listen for visibilitychange. + const visibilityChangeListeners: (() => void)[] = [] + const visibilityChangeHandler = { + current: (): void => { + for (const listener of visibilityChangeListeners) { + listener() + } + } + } ;(globalThis as { document?: Document }).document = { visibilityState: 'visible', addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { if (type === 'visibilitychange') { - visibilityChangeHandler.current = listener as () => void + visibilityChangeListeners.push(listener as () => void) } }), removeEventListener: vi.fn() @@ -9169,17 +9949,21 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('writes ordinary hidden remote runtime output live instead of restoring a snapshot', async () => { + it('restores overflowed hidden remote runtime output from its serialized snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('remote:env-1@@terminal-1') const capturedDataCallback: { current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null } = { current: null } + // Why: with the skip grammar gone, the model restore for remote-runtime + // PTYs is latched by background-queue overflow, not per-chunk scanning. + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible remote output\r\n' transport.serializeBuffer = vi.fn().mockResolvedValue({ - data: 'remote snapshot\r\n', + data: 'remote snapshot with hidden remote output\r\n', cols: 120, rows: 40, - seq: 40, + seq: hidden.length + live.length, source: 'headless' }) transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { @@ -9197,26 +9981,27 @@ describe('connectPanePty', () => { const disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden remote output\r\n' - const live = 'visible remote output\r\n' capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) ;(deps.isVisibleRef as { current: boolean }).current = true capturedDataCallback.current?.(live, { - seq: 40 + live.length, + seq: hidden.length + live.length, rawLength: live.length }) await flushAsyncTicks(20) expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(transport.serializeBuffer).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(hidden) - expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('remote snapshot with hidden remote output'), + expect.any(Function) + ) disposable.dispose() }) - it('keeps inactive split-pane hidden output live instead of deferring snapshot restore', async () => { + it('defers inactive split-pane plain hidden output restore until the pane returns', async () => { const { resetHiddenOutputRestoreSchedulerForTests } = await import('./hidden-output-restore-scheduler') let disposable: { dispose: () => void } | null = null @@ -9250,7 +10035,9 @@ describe('connectPanePty', () => { disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden inactive output\r\n' + // Why: overflowing the background queue is what latches the model + // restore now — the per-chunk skip grammar is gone. + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const live = 'visible inactive output\r\n' expect(capturedDataCallback.current).not.toBeNull() capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -9268,9 +10055,12 @@ describe('connectPanePty', () => { await new Promise((resolve) => setTimeout(resolve, 30)) await flushAsyncTicks(20) - expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(hidden) - expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('inactive snapshot'), + expect.any(Function) + ) } finally { disposable?.dispose() resetHiddenOutputRestoreSchedulerForTests() @@ -9311,7 +10101,8 @@ describe('connectPanePty', () => { disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden inactive output\r\n' + // Why: overflow latches the model restore (no skip grammar remains). + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const live = 'visible inactive output\r\n' expect(capturedDataCallback.current).not.toBeNull() capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -9335,7 +10126,7 @@ describe('connectPanePty', () => { } }) - it('does not retry remote snapshots for ordinary hidden runtime output', async () => { + it('retries null remote snapshots for overflowed hidden runtime output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('remote:env-1@@terminal-1') const capturedDataCallback: { @@ -9360,7 +10151,8 @@ describe('connectPanePty', () => { const disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden remote output\r\n' + // Why: overflow latches the model restore (no skip grammar remains). + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const firstLive = 'first visible output\r\n' capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -9371,7 +10163,7 @@ describe('connectPanePty', () => { }) await flushAsyncTicks(20) - expect(transport.serializeBuffer).not.toHaveBeenCalled() + expect(transport.serializeBuffer).toHaveBeenCalledTimes(1) expect(pane.terminal.write).not.toHaveBeenCalledWith( expect.stringContaining('Orca skipped hidden terminal output'), expect.any(Function) @@ -9384,11 +10176,17 @@ describe('connectPanePty', () => { await new Promise((resolve) => setTimeout(resolve, 80)) await flushAsyncTicks(20) - expect(transport.serializeBuffer).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(firstLive, expect.any(Function)) + expect(transport.serializeBuffer).toHaveBeenCalledTimes(2) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('remote recovered snapshot'), + expect.any(Function) + ) disposable.dispose() }) + // Why: pins the entire switch-off hidden fallback chain — hidden bytes ride + // the background queue, the 2MB lossy cap drops the backlog and latches the + // restore, and reveal repaints from the model snapshot. it('restores hidden backlog overflow from the main terminal snapshot on foreground output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -9438,7 +10236,7 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('clears only the alternate screen when restoring an alternate-screen snapshot', async () => { + it('rebuilds normal and alternate buffers from an authoritative alternate snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { @@ -9459,7 +10257,8 @@ describe('connectPanePty', () => { cols: 100, rows: 30, seq: hidden.length + live.length, - alternateScreen: true + alternateScreen: true, + scrollbackAnsi: 'preserved-shell-history\r\n' }) const pane = createPane(1) @@ -9481,23 +10280,25 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) - // Why: the destructive clear wipes xterm's scrollback. Alt-screen TUIs keep - // their history in xterm, so restoring one must NOT emit the clear. - expect(pane.terminal.write).not.toHaveBeenCalledWith( - '\x1b[2J\x1b[3J\x1b[H', + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', expect.any(Function) ) - // Why: the snapshot's ?1049h no-ops on an already-alt pane and serialized - // frames skip blank cells, so the pre-hide frame bleeds through unless the - // alt screen is cleared (without \x1b[3J) before the snapshot paints. expect(pane.terminal.write).toHaveBeenCalledWith( - '\x1b[?1049h\x1b[2J\x1b[H', + 'preserved-shell-history\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', expect.any(Function) ) const writes = (pane.terminal.write as ReturnType).mock.calls.map( (call) => call[0] ) - expect(writes.indexOf('\x1b[?1049h\x1b[2J\x1b[H')).toBeLessThan( + expect(writes.indexOf('preserved-shell-history\r\n')).toBeLessThan( + writes.indexOf('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') + ) + expect(writes.indexOf('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H')).toBeLessThan( writes.indexOf('altscreen-snapshot\r\n') ) expect(pane.terminal.write).toHaveBeenCalledWith('altscreen-snapshot\r\n', expect.any(Function)) @@ -10154,14 +10955,23 @@ describe('connectPanePty', () => { it('keeps recovery pending when hidden output arrives during an in-flight snapshot', async () => { let visibilityState: DocumentVisibilityState = 'visible' - const visibilityChangeHandler: { current: (() => void) | null } = { current: null } + // Fire-all like a real event target: the pane resync handler and the + // stale-visibility trust handler both listen for visibilitychange. + const visibilityChangeListeners: (() => void)[] = [] + const visibilityChangeHandler = { + current: (): void => { + for (const listener of visibilityChangeListeners) { + listener() + } + } + } ;(globalThis as { document?: Document }).document = { get visibilityState() { return visibilityState }, addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { if (type === 'visibilitychange') { - visibilityChangeHandler.current = listener as () => void + visibilityChangeListeners.push(listener as () => void) } }), removeEventListener: vi.fn() @@ -10588,6 +11398,73 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('replays rich headless snapshots as the future hidden TUI view source', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const richSnapshot = [ + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + '\x1b[2;36m╭────────────────────────────╮\x1b[0m\r\n', + '\x1b[2;36m│ Codex rich restore 🟢 ███░ │\x1b[0m\r\n', + '\x1b[2;36m│ status streaming │\x1b[0m\r\n', + '\x1b[2;36m╰────────────────────────────╯\x1b[0m', + '\x1b[6;4H\x1b[?25h' + ].join('') + const visibleTrigger = 'visible-trigger\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: richSnapshot, + cols: 96, + rows: 18, + seq: hidden.length + visibleTrigger.length, + source: 'headless' + }) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(visibleTrigger, { + seq: hidden.length + visibleTrigger.length, + rawLength: visibleTrigger.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.resize).toHaveBeenCalledWith(96, 18) + expect(pane.terminal.write).toHaveBeenCalledWith(richSnapshot, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(visibleTrigger, expect.any(Function)) + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(deps.replayingPanesRef.current.size).toBe(0) + disposable.dispose() + }) + it('refreshes visible rows after replaying a hidden TUI snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -12439,6 +13316,452 @@ describe('connectPanePty', () => { expect(deps.markTerminalPaneUnread).not.toHaveBeenCalled() }) + // ─── Main side-effect authority (terminal-side-effect-authority.md) ──── + // + // With the kill switch on (the default), local/SSH transports must not + // register title/bell/agent byte parsers; the pane's policy callbacks are + // registered as the PTY's single pty:sideEffect fact consumer instead. + describe('with main side-effect authority on', () => { + const SIDE_EFFECT_PARSER_CALLBACKS = [ + 'onTitleChange', + 'onBell', + 'onAgentBecameIdle', + 'onAgentBecameWorking', + 'onAgentExited' + ] as const + + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } + } + + it('omits byte-parser callbacks from the local transport options', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + + expect(createdTransportOptions[0]).toBeDefined() + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeUndefined() + } + // The lifecycle callbacks stay on the transport — only side-effect + // parsing moves to the fact consumer. + expect(createdTransportOptions[0]?.onPtySpawn).toBeTypeOf('function') + expect(createdTransportOptions[0]?.onPtyExit).toBeTypeOf('function') + }) + + it('keeps byte-parser callbacks on remote-runtime transports', async () => { + enableMainAuthority() + enableActiveRuntimeEnvironment() + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object)) + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeTypeOf('function') + } + }) + + it('consumes pty:sideEffect facts with the live-path policy after spawn', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + connectPanePty(pane as never, manager as never, deps as never) + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-1') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-1', + seq: 10, + facts: [ + { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }, + { kind: 'bell' } + ] + }) + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex working') + expect(deps.markWorktreeUnread).toHaveBeenCalledTimes(1) + expect(deps.markTerminalTabUnread).toHaveBeenCalledWith('tab-1') + expect(deps.dispatchNotification).not.toHaveBeenCalled() + vi.advanceTimersByTime(250) + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'terminal-bell', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + ) + }) + + it('stops consuming facts after the pane binding is disposed', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const deps = createDeps() + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + deps as never + ) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-2') + + binding.dispose() + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-2', + seq: 1, + facts: [{ kind: 'bell' }] + }) + + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + expect(deps.markTerminalTabUnread).not.toHaveBeenCalled() + }) + + it('schedules the completion notification for genuine working→idle facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-genuine') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-genuine', + seq: 1, + facts: [ + { kind: 'title', normalizedTitle: '⠋ Codex working', rawTitle: '⠋ Codex working' }, + { kind: 'agent-working' } + ] + }) + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-genuine', + seq: 2, + facts: [ + { kind: 'title', normalizedTitle: '* Codex done', rawTitle: '* Codex done' }, + { kind: 'agent-idle', title: '* Codex done' } + ] + }) + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('clears state without completion attention for stale-derived facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-stale') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-stale', + seq: 1, + facts: [ + { kind: 'title', normalizedTitle: '⠋ Codex working', rawTitle: '⠋ Codex working' }, + { kind: 'agent-working' } + ] + }) + // Main's unthrottled 3s stale-title rewrite for a merely-paused agent. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-stale', + seq: 2, + facts: [ + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ] + }) + + // The cleared title still lands; the cache timer is cleared. + expect(deps.setRuntimePaneTitle).toHaveBeenLastCalledWith('tab-1', 1, 'Codex') + expect(deps.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + makePaneKey('tab-1', LEAF_1), + null + ) + // But no task-complete notification or unread attention is scheduled. + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS * 2) + expect(deps.dispatchNotification).not.toHaveBeenCalled() + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + expect(deps.markTerminalPaneUnread).not.toHaveBeenCalled() + }) + + it('drops the agent status from a command-finished fact like the byte path did', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'hi', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'codex', + stateHistory: [] + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-133') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-133', + seq: 1, + facts: [{ kind: 'command-finished', exitCode: 130 }] + }) + + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() + }) + + it('routes pr-link facts to the worktree PR observer', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-pr') + + const link = { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-pr', + seq: 1, + facts: [{ kind: 'pr-link', link }] + }) + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith('wt-1', link) + }) + + it('does not byte-scan PR links or OSC 133 — facts are the only consumer', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-authority-bytes' + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'hi', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'codex', + stateHistory: [] + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('Created https://github.com/acme/orca/pull/42\r\n') + capturedDataCallback.current?.('\x1b]133;D;130\x07prompt $ ') + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + }) + + it('seeds and settles Command Code status from command-code facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-cc') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc', + seq: 1, + facts: [{ kind: 'command-code-working', prompt: 'say hi' }] + }) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'say hi', + agentType: 'command-code' + }) + + // Why: the done fact is a hint — the settle timer stays in the pane + // policy because it must consult the live status row before completing. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc', + seq: 2, + facts: [{ kind: 'command-code-done', prompt: 'say hi' }] + }) + vi.advanceTimersByTime(1499) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'working' }) + vi.advanceTimersByTime(1) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + prompt: 'say hi', + agentType: 'command-code' + }) + }) + + it('keeps Command Code working when a working fact lands before the done settles', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-cc-repaint') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 1, + facts: [{ kind: 'command-code-working', prompt: 'Run a slow command' }] + }) + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 2, + facts: [{ kind: 'command-code-done', prompt: 'Run a slow command' }] + }) + vi.advanceTimersByTime(1000) + // An active repaint within the settle window cancels the pending done. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 3, + facts: [{ kind: 'command-code-working', prompt: 'Run a slow command' }] + }) + vi.advanceTimersByTime(2000) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'Run a slow command', + agentType: 'command-code' + }) + }) + + it('does not byte-scan Command Code output — facts are the only consumer', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-authority-cc-bytes' + } + ) + transportFactoryQueue.push(transport) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup: { command: 'command-code --trust' } }) as never + ) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('# Command Code v0.27.2\r\n') + capturedDataCallback.current?.('❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m') + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + }) + + it('honors the persisted kill switch for panes bound before settings hydrate', async () => { + // Pre-hydration: the store has no settings yet, but the user persisted + // the kill switch off. The pane must register byte parsers, not a fact + // consumer — and hydration must not produce a second consumer. + mockStoreState.settings = null + ;(window.api as unknown as Record).settings = { + getSync: vi.fn(() => ({ terminalMainSideEffectAuthority: false })) + } + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeTypeOf('function') + } + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-prehydration') + + // No fact consumer registered: channel batches are dropped. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-prehydration', + seq: 1, + facts: [{ kind: 'bell' }] + }) + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + + // Hydration lands with the switch still off: byte parsing stays the + // single consumer — one BEL marks unread exactly once. + mockStoreState.settings = { terminalMainSideEffectAuthority: false } + notifyStoreSubscribers() + const onBell = createdTransportOptions[0]?.onBell as () => void + onBell() + expect(deps.markWorktreeUnread).toHaveBeenCalledTimes(1) + }) + }) + it('lets concurrent agent-complete notifications win over terminal bell notifications', async () => { const { connectPanePty } = await import('./pty-connection') const { useNotificationDispatch } = await vi.importActual( diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 94fd3774fa4..ae7dbfbcf1c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -13,6 +13,17 @@ import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-ki import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic' import { parseTerminalOscColorQuery } from '../../../../shared/terminal-osc-color-reply' +import { + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS, + containsCsiRendererQuery, + containsStatefulRendererQuery, + extractHiddenStartupRendererQueryData, + findCsiFinalByteIndex, + isStatefulRendererReplyCsiQuery, + isStatelessRendererReplyCsiQuery +} from '../../../../shared/terminal-reply-query-extraction' +import { takeCurrentPtyDeliveryAckCredit } from './terminal-pty-ack-gate' +import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport' import { createIpcPtyTransport } from './pty-transport' @@ -37,6 +48,12 @@ import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty-size-reconcile' import { createPtySizeReassertion } from './pty-size-reassertion' import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard' +import { + isDocumentVisibilityProvenStale, + registerStaleDocumentVisibilityRecovery +} from './stale-document-visibility' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' import { nativeWindowsRewriteNeedsFollowupRenderRefresh, terminalOutputPrefersRenderRefresh, @@ -81,7 +98,10 @@ import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity' -import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility' +import { + isLocalNativeWindowsConpty, + resolveWindowsShellOverride +} from '@/lib/pane-manager/windows-pty-compatibility' import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' import { captureTerminalWriteScrollIntent, @@ -133,21 +153,30 @@ import { import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform' import { resolveTerminalPasteRuntime } from './terminal-paste-runtime' import { isKnownTuiAgentTerminalStartupCommand } from './terminal-startup-command-classifier' -import { createCommandCodeOutputStatusDetector } from './command-code-output-status' +import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status' import type { PtyDataMeta } from './pty-dispatcher' import { getEagerPtyBufferHandle } from './pty-dispatcher' -import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector' +import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' import { CONPTY_DA1_RESPONSE, + DEFAULT_DA1_RESPONSE, createTerminalPixelSizeQueryResponder, installTerminalCapabilityReplyHandlers, sendTerminalOscColorQueryReplies } from './terminal-capability-replies' +import { registerPtyModelRestoreNeededHandler } from './pty-model-restore-channel' +import { + acquireHiddenRendererPtyDeliveryClaim, + declareRendererPtyDeliveryVisible, + releaseRendererPtyVisibilityClaim, + setRendererPtyVisibilityClaim +} from './pty-renderer-delivery-claims' import { cancelScheduledHiddenOutputRestore, scheduleHiddenOutputRestore } from './hidden-output-restore-scheduler' +import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-scrollback' import { getExecutionHostIdForWorktree, getSettingsForWorktreeRuntimeOwner, @@ -185,14 +214,23 @@ import { beginAgentStartupDeliveryAttempt, releaseAgentStartupDeliveryAttempt } from '@/lib/agent-startup-delayed-delivery' +import { + AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, + AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS, + canDispatchAgentNotificationAfterGrace, + isAgentTaskCompleteOsNotificationEnabledFromState, + isAgentTaskCompleteTrackingEnabledFromState +} from './agent-task-complete-policy' +import { + isMainTerminalSideEffectAuthorityForPty, + registerTerminalSideEffectFactConsumer +} from './terminal-side-effect-facts-handler' +import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' const REMOTE_PTY_ID_PREFIX = 'remote:' const PTY_CONNECT_DIAG_LIMIT = 200 -const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250 -const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500 -const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000 const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500 const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500 const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096 @@ -202,11 +240,22 @@ const STARTUP_DRAFT_PASTE_QUIET_MS = 1500 export const STARTUP_CWD_FALLBACK_NOTICE = '\r\n[Orca opened this terminal at the workspace root because its saved start folder no longer exists.]\r\n' const STARTUP_DRAFT_PASTE_TIMEOUT_MS = 8000 -const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024 const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50 const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3 const HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS = 750 +// Why (rc.7.perf DSR-timeout feedback loop): under a foreground flood the +// restore pipeline is its own bottleneck — each synchronous snapshot replay +// starves ACK processing, main pins at the in-flight cap, drops at the +// pending cap, and every drop marker re-armed another restore until the +// flood ended. Backpressure evidence opens this suppression window: drop +// markers inside it must not re-arm restores; live bytes write through and +// ONE deferred repaint (when the window closes) heals the visual gap. +const HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS = 2000 +// Backstop for the same loop: a single in-flight restore task may re-iterate +// (fresh-snapshot marks, unmappable slices) only this many times before it +// abandons and lets live bytes flow. +const HIDDEN_OUTPUT_RESTORE_MAX_LOOP_ITERATIONS = 3 const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h' const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' @@ -357,6 +406,9 @@ type E2eTerminalHiddenSnapshotOverride = { const e2eTerminalHiddenSnapshotOverrides = new Map() +// Why: the per-chunk hidden-skip grammar is deleted (Phase 6) — hidden bytes +// either never reach the renderer (delivery gate) or ride the background +// scheduler queue. Only the mode-2031 fact-reply counter still has a producer. type E2eTerminalPtyOutputDebugSnapshot = { hiddenRendererSkipCount: number hiddenRendererSkippedChars: number @@ -516,157 +568,6 @@ function containsHiddenStartupRendererQuery(data: string): boolean { return containsCsiRendererQuery(data) || data.includes('\x1b]10;?') || data.includes('\x1b]11;?') } -const HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS = 64 - -function extractHiddenStartupRendererQueryData( - data: string, - pending: string -): { - statelessQueryData: string - statefulQueryData: string - oscColorQueryData: string - pending: string -} { - const input = pending + data - let statelessQueryData = '' - let statefulQueryData = '' - let oscColorQueryData = '' - let offset = 0 - - while (offset < input.length) { - const candidateIndex = input.indexOf('\x1b', offset) - if (candidateIndex === -1) { - break - } - if (candidateIndex + 1 >= input.length) { - return { - statelessQueryData, - statefulQueryData, - oscColorQueryData, - pending: input.slice(candidateIndex) - } - } - if (input.startsWith('\x1b[', candidateIndex)) { - const finalByteIndex = findCsiFinalByteIndex(input, candidateIndex + 2) - if (finalByteIndex === -1) { - return { - statelessQueryData, - statefulQueryData, - oscColorQueryData, - pending: input.slice( - candidateIndex, - candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS - ) - } - } - const sequence = input.slice(candidateIndex, finalByteIndex + 1) - if (isStatelessRendererReplyCsiQuery(sequence)) { - statelessQueryData += sequence - } else if (isStatefulRendererReplyCsiQuery(sequence)) { - statefulQueryData += sequence - } - offset = finalByteIndex + 1 - continue - } - - if (input.startsWith('\x1b]', candidateIndex)) { - const query = parseTerminalOscColorQuery(input, candidateIndex) - if (query.kind === 'partial') { - return { - statelessQueryData, - statefulQueryData, - oscColorQueryData, - pending: input.slice( - candidateIndex, - candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS - ) - } - } - if (query.kind === 'none') { - offset = candidateIndex + 2 - continue - } - oscColorQueryData += input.slice(candidateIndex, query.endIndex) - offset = query.endIndex - continue - } - - if (parseTerminalOscColorQuery(input, candidateIndex).kind === 'partial') { - return { - statelessQueryData, - statefulQueryData, - oscColorQueryData, - pending: input.slice(candidateIndex) - } - } - - { - offset = candidateIndex + 1 - continue - } - } - - return { statelessQueryData, statefulQueryData, oscColorQueryData, pending: '' } -} - -function containsCsiRendererQuery(data: string): boolean { - let offset = data.indexOf('\x1b[') - while (offset !== -1) { - const finalByteIndex = findCsiFinalByteIndex(data, offset + 2) - if (finalByteIndex === -1) { - return false - } - const sequence = data.slice(offset, finalByteIndex + 1) - if (isStatelessRendererReplyCsiQuery(sequence) || isStatefulRendererReplyCsiQuery(sequence)) { - return true - } - offset = data.indexOf('\x1b[', finalByteIndex + 1) - } - return false -} - -function containsStatefulRendererQuery(data: string): boolean { - let offset = data.indexOf('\x1b[') - while (offset !== -1) { - const finalByteIndex = findCsiFinalByteIndex(data, offset + 2) - if (finalByteIndex === -1) { - return false - } - const sequence = data.slice(offset, finalByteIndex + 1) - if (isStatefulRendererReplyCsiQuery(sequence)) { - return true - } - offset = data.indexOf('\x1b[', finalByteIndex + 1) - } - return false -} - -function findCsiFinalByteIndex(data: string, offset: number): number { - for (let index = offset; index < data.length; index++) { - const code = data.charCodeAt(index) - if (code >= 0x40 && code <= 0x7e) { - return index - } - } - return -1 -} - -function isStatelessRendererReplyCsiQuery(sequence: string): boolean { - if (sequence.endsWith('c')) { - return true - } - return ( - sequence === '\x1b[5n' || - sequence === '\x1b[>q' || - sequence === '\x1b[14t' || - sequence === '\x1b[16t' - ) -} - -function isStatefulRendererReplyCsiQuery(sequence: string): boolean { - return sequence === '\x1b[6n' || (sequence.startsWith('\x1b[?') && sequence.endsWith('$p')) -} - let codexRestartNoticePresenceSource: Record< string, { previousAccountLabel: string; nextAccountLabel: string } @@ -691,40 +592,19 @@ type PanePtyBinding = IDisposable & { sampleForegroundAgentOnFocus: () => void reconcileIfSessionDead: (liveSessionIds: Set, snapshotRequestedAt?: number) => void reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void + /** True when the hidden-delivery gate structurally manages the pane's + * current PTY. The lifecycle's xterm CSI ?2031h observer consults this to + * stay silent — main's '2031-subscribe' fact is the sole responder for + * gate-managed PTYs. */ + isHiddenDeliveryGateManagedPty: () => boolean } function isAgentTaskCompleteNotificationEnabled(): boolean { - return isAgentTaskCompleteNotificationEnabledFromState(useAppStore.getState()) -} - -function isAgentTaskCompleteNotificationEnabledFromState( - state: ReturnType -): boolean { - const notifications = state.settings?.notifications - return notifications?.enabled !== false && notifications?.agentTaskComplete !== false -} - -function isTerminalAttentionEnabledFromState( - state: ReturnType -): boolean { - return state.settings?.experimentalTerminalAttention === true + return isAgentTaskCompleteOsNotificationEnabledFromState(useAppStore.getState()) } function isAgentTaskCompleteTrackingEnabled(): boolean { - const state = useAppStore.getState() - return ( - isAgentTaskCompleteNotificationEnabledFromState(state) || - isTerminalAttentionEnabledFromState(state) - ) -} - -function isAgentTaskCompleteTrackingEnabledFromState( - state: ReturnType -): boolean { - return ( - isAgentTaskCompleteNotificationEnabledFromState(state) || - isTerminalAttentionEnabledFromState(state) - ) + return isAgentTaskCompleteTrackingEnabledFromState(useAppStore.getState()) } const agentTaskCompleteTrackingEnabledListeners = new Set<() => void>() @@ -734,7 +614,7 @@ let agentTaskCompleteTrackingSettingsSnapshot: string | null = null function getAgentTaskCompleteTrackingSettingsSnapshot( state: ReturnType ): string { - return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteNotificationEnabledFromState(state)}` + return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteOsNotificationEnabledFromState(state)}` } function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () => void { @@ -768,27 +648,6 @@ function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () => } } -function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean { - return Boolean( - entry && - Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS && - (entry.lastAssistantMessage || entry.toolName || entry.toolInput) - ) -} - -function canDispatchAgentNotificationAfterGrace( - entry: AgentStatusEntry | undefined, - options: { allowDoneDetailAfterGrace?: boolean } = {} -): boolean { - // Why: hook-backed goal/mission loops can report `done` between milestones. - // User-input states may notify as soon as detail arrives, but `done` waits - // for the max quiet window so resumed work can cancel the pending banner. - return ( - hasAgentNotificationDetail(entry) && - (entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true) - ) -} - function recordPtyConnectDiagnostic(message: string): void { if (!e2eConfig.exposeStore) { return @@ -936,7 +795,13 @@ function shouldWritePtyOutputForeground(isPaneVisible: boolean): boolean { // Why: Electron can keep visible panes mounted while the whole app is // backgrounded. Treat hidden documents like background tabs so Chromium // timer throttling cannot pin terminal writes on the renderer foreground path. - return document.visibilityState === 'visible' + if (document.visibilityState === 'visible') { + return true + } + // Why: macOS occlusion tracking can wedge visibilityState at 'hidden' after + // display sleep; proven-stale means real user input contradicted it, so the + // hidden-delivery gate must not keep dropping a watched pane's bytes. + return isDocumentVisibilityProvenStale() } function containsSynchronizedOutputStart(data: string): boolean { @@ -1074,6 +939,7 @@ export function connectPanePty( let unregisterDocumentVisibilityRecovery: (() => void) | null = null let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {} + let cleanupHiddenOutputRestoreFloodRepaint = (): void => {} let resetRendererOrderedSeqForPtyExit: (exitedPtyId: string) => void = () => {} let cleanupStartupDraftPasteTimers = (): void => {} let unregisterE2ePtyDataInjection = (): void => {} @@ -1096,6 +962,11 @@ export function connectPanePty( // window still drains on the fast path instead of the 1s coalesce fallback. let synchronizedForegroundFrameInteractive = false let suppressSnapshotReplayPtyResize = false + // Why: hidden-delivery gate sync is wired up alongside the deferred PTY + // output plumbing inside the connect frame; lifecycle hooks (visibility + // flips, exit, dispose) run before/after it exists, so start with no-ops. + let syncHiddenRendererPtyDelivery: () => void = () => {} + let releaseHiddenRendererPtyDelivery: () => void = () => {} // Why: idle callbacks are registered before the deferred PTY output plumbing // exists. Start with the shared scheduler, then switch to the PTY writer // below so hidden-tab resets keep backlog-recovery callbacks and byte order. @@ -1863,6 +1734,40 @@ export function connectPanePty( hasKnownAgentIdentity: paneHasKnownAgentIdentity, onConfirmedShellForeground: clearStaleAgentTabTitleOnConfirmedShell }) + // Why: one command-finished policy whether the signal arrives as bytes + // (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact — + // routing both through this handler keeps the drop/interrupt semantics + // identical across authority modes. + const handleCommandFinished = (_bestEffortExitCode: number | null): void => { + clearCommandInferredPaneAgentAfterPtySideEffects() + paneForegroundAgentTracker.onCommandFinished() + // Why: the finished command may have moved HEAD or the index (e.g. + // `git checkout`); nudge git UI now instead of waiting for a poll. + dispatchTerminalCommandFinishedEvent(deps.worktreeId) + const state = useAppStore.getState() + const entry = state.agentStatusByPaneKey[cacheKey] + const inferenceResult = flushPendingInterruptInference() + if (inferenceResult === true) { + // Why: OSC 133 D means the foreground shell command exited. If an + // interrupt was inferred first, drop only when the current interrupted + // row is still the same turn; otherwise a killed OpenCode CLI leaves a + // stale "interrupted" row even though the process is gone. + dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true }) + return + } + if (inferenceResult instanceof Promise) { + void inferenceResult.then((applied) => { + dropCommandFinishedStatusIfSameTurn(entry, { + allowInferredInterrupt: applied === true + }) + }) + return + } + // Why: OSC 133 D marks the foreground shell command exiting. Remove the + // row without retaining a done snapshot; this section represents a live + // agent process, and the shell prompt means that process is gone. + dropCommandFinishedStatusIfSameTurn(entry) + } const sampleVisiblePaneForegroundAgent = (): void => { if (!deps.isVisibleRef.current) { return @@ -1895,37 +1800,10 @@ export function connectPanePty( } const commandLifecycle = createTerminalCommandLifecycle({ onCommandStarted: () => paneForegroundAgentTracker.onCommandStarted(), - onCommandFinished: () => { - clearCommandInferredPaneAgentAfterPtySideEffects() - paneForegroundAgentTracker.onCommandFinished() - // Why: the finished command may have moved HEAD or the index (e.g. - // `git checkout`); nudge git UI now instead of waiting for a poll. - dispatchTerminalCommandFinishedEvent(deps.worktreeId) - const state = useAppStore.getState() - const entry = state.agentStatusByPaneKey[cacheKey] - const inferenceResult = flushPendingInterruptInference() - if (inferenceResult === true) { - // Why: OSC 133 D means the foreground shell command exited. If an - // interrupt was inferred first, drop only when the current interrupted - // row is still the same turn; otherwise a killed OpenCode CLI leaves a - // stale "interrupted" row even though the process is gone. - dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true }) - return - } - if (inferenceResult instanceof Promise) { - void inferenceResult.then((applied) => { - dropCommandFinishedStatusIfSameTurn(entry, { - allowInferredInterrupt: applied === true - }) - }) - return - } - // Why: OSC 133 D marks the foreground shell command exiting. Remove the - // row without retaining a done snapshot; this section represents a live - // agent process, and the shell prompt means that process is gone. - dropCommandFinishedStatusIfSameTurn(entry) - } + onCommandFinished: handleCommandFinished }) + // Why: the xterm OSC 133 swallow is rendering hygiene, not a side effect — + // it stays attached in every authority mode. commandLifecycle.attachXtermConsumer(pane.terminal) const onTerminalKeyDown = (event: KeyboardEvent): void => { if (isPlainEscapeKeyEvent(event)) { @@ -2001,6 +1879,47 @@ export function connectPanePty( // Why: bind time lets async liveness reconcile ignore a request started // before this PTY bound (newborn race). Null disables the guard (fail-safe). let activePanePtyBindingBoundAt: number | null = null + + // Why: with main side-effect authority on, the pane's title/bell/agent + // policy callbacks consume pty:sideEffect facts instead of transport byte + // parsers (which stay unregistered) — same policy code, single consumer. + // restoreTitleOnRegister replaces the eager-replay title restore: main's + // title-only snapshot carries the no-attention-replay rule. + let unregisterSideEffectFactConsumer: (() => void) | null = null + const registerSideEffectFactConsumerForPty = (ptyId: string): void => { + if (!mainSideEffectAuthority || disposed) { + return + } + unregisterSideEffectFactConsumer?.() + unregisterSideEffectFactConsumer = registerTerminalSideEffectFactConsumer({ + ptyId, + callbacks: { + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onCommandFinished: handleCommandFinished, + onPrLink: (link) => + useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link), + // Why: the Command Code settle policy stays here — the done settle + // timer must consult the live store row (which hook events and + // renderer seeds also write), so main only emits scrape facts. + onCommandCodeWorking: seedCommandCodeOutputWorkingStatus, + onCommandCodeDone: scheduleCommandCodeOutputDoneStatus, + // Why: gated hidden panes never see the subscribe bytes; the fact + // replaces the byte scan (and the old post-latch subscribe drop). + ...(hiddenDeliveryGateActive + ? { onMode2031Subscribe: handleHiddenMode2031SubscribeFact } + : {}) + }, + restoreTitleOnRegister: true + }) + } + const dropSideEffectFactConsumer = (): void => { + unregisterSideEffectFactConsumer?.() + unregisterSideEffectFactConsumer = null + } const clearPanePtyFitBinding = (): void => { // Why: fit bindings live in a module-level map, so pane teardown must // clear them explicitly instead of relying on DOM removal. @@ -2191,6 +2110,10 @@ export function connectPanePty( } handledExitPtyId = ptyId agentCompletionCoordinator.dispose() + dropSideEffectFactConsumer() + // Why: main clears gate state on PTY exit too; this only resets the + // pane-local marker so a reused pane cannot skip re-marking a new PTY. + releaseHiddenRendererPtyDelivery() clearPanePtyFitBinding() // Why: the negotiating application died with its PTY; any replacement // session starts with kitty keyboard flags at zero. @@ -2273,6 +2196,7 @@ export function connectPanePty( return } if ( + deps.isVisibleRef.current && hadExistingPaneTransportAtConnect && !restoredPtyIdForTransport && !Number.isFinite(lastTerminalInputAt) && @@ -2280,6 +2204,10 @@ export function connectPanePty( ) { // Why: a freshly split pane can lose its newborn PTY during setup; keep // the split visible so the failed session does not immediately collapse. + // Hidden panes must close instead: the hidden-delivery gate withholds + // their bytes, so "no output" is meaningless there, and keeping one + // strands a binding-less pane the exit path never revisits — it remounts + // as a permanently blank ghost on reveal. focusSurvivingPtyPaneAfterKeptExit() return } @@ -2294,7 +2222,11 @@ export function connectPanePty( let hasConsideredInitialCacheTimerSeed = false let allowInitialIdleCacheSeed = false - const onTitleChange = (title: string, rawTitle: string): void => { + const onTitleChange = ( + title: string, + rawTitle: string, + meta?: { staleWorkingTitleClear?: boolean } + ): void => { // Why: one owner-aware decision drives the display label, the runtime/tab // title, task-completion tracking, and the renderer gate, so raw title text // can no longer disable GPU behind stronger owner evidence (#7428/#7447). @@ -2317,7 +2249,11 @@ export function connectPanePty( } manager.setPaneGpuRendering(pane.id, decision.rendererPolicy.gpuEnabled) deps.setRuntimePaneTitle(deps.tabId, pane.id, paneTitle) - if (syncAgentTaskCompleteTrackingEnabled()) { + // Why: a stale-derived cleared title comes from main's unthrottled 3s + // timer, not agent output. It must update the visible title but never + // feed completion tracking — observeTitle would classify the cleared + // title as idle and mint a task-complete for a merely-paused agent. + if (!meta?.staleWorkingTitleClear && syncAgentTaskCompleteTrackingEnabled()) { agentCompletionCoordinator.observeTitle(decision.rawTitle) } // Why: only the focused pane should drive the tab title — otherwise two @@ -2436,11 +2372,6 @@ export function connectPanePty( }, COMMAND_CODE_OUTPUT_DONE_SETTLE_MS) } - const commandCodeOutputStatusDetector = createCommandCodeOutputStatusDetector({ - startupCommand: paneStartup?.command, - onWorking: seedCommandCodeOutputWorkingStatus, - onDone: scheduleCommandCodeOutputDoneStatus - }) const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector() const reportPanePtyVisibility = (ptyId: string | null | undefined, visible: boolean): void => { if (!ptyId || isRemoteRuntimePtyId(ptyId)) { @@ -2448,7 +2379,7 @@ export function connectPanePty( // renderer-visibility registry, so reporting them here is misleading. return } - window.api.pty.setRendererPtyVisible?.(ptyId, visible) + setRendererPtyVisibilityClaim(transport, ptyId, visible) } const bindActivePanePty = ( ptyId: string, @@ -2467,6 +2398,8 @@ export function connectPanePty( // Why: record bind time on the spawn/attach chokepoint so the reconcile // guard knows this binding is newer than any pre-bind snapshot. activePanePtyBindingBoundAt = performance.now() + registerSideEffectFactConsumerForPty(ptyId) + syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, ptyId) const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[deps.tabId] ?? [] if (options.updateTabPtyId !== 'if-missing' || !tabPtyIds.includes(ptyId)) { @@ -2686,7 +2619,16 @@ export function connectPanePty( // findable after the OS banner is gone. Double-firing with a concurrent BEL // is handled by delaying the BEL OS notification below; main still keeps a // 5 s per-worktree dedupe as the final guard. - const onAgentBecameIdle = (title: string): void => { + const onAgentBecameIdle = (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => { + // Why: a stale-derived idle comes from main's UNTHROTTLED 3s timer, not + // observed bytes — a merely-paused agent (>3s silent mid-task, window + // minimized) would otherwise mint a false task-complete OS notification + // that renderer timer throttling previously damped. Clear session-tied + // state only; never schedule completion attention from it. + if (meta?.staleWorkingTitleClear) { + deps.setCacheTimerStartedAt(cacheKey, null) + return + } // Why: only start the prompt-cache countdown for Claude agents — other // agents have different (or no) prompt-caching semantics and showing a // timer for them would be misleading. @@ -2775,7 +2717,10 @@ export function connectPanePty( userAgent: navigator.userAgent, connectionId, cwd: deps.cwd, - shellOverride, + // Why: main folds the global Windows shell into its spawn classification + // (pty.ts effectiveShellOverride); fold it here too so both sides treat + // a global-WSL default identically (terminal-query-authority.md ConPTY). + shellOverride: resolveWindowsShellOverride(shellOverride, state.settings?.terminalWindowsShell), executionHostId }) if (isNativeWindowsConpty) { @@ -2819,6 +2764,37 @@ export function connectPanePty( }) : undefined const shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null + // Why: when main holds side-effect authority for this PTY's bytes, the + // transport must NOT register title/bell/agent byte parsers — the + // pty:sideEffect fact consumer below is the single policy consumer. + // Decided once at transport creation so a fact never has two consumers. + const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({ + settings: state.settings, + runtimeEnvironmentId + }) + // Why: Phase-4 hidden-delivery gate — only meaningful under main authority + // (renderer byte parsers need bytes otherwise). Decided once at pane + // creation: it picks the mode-2031 answer path (fact reply vs byte scan), + // which must have exactly one owner. + const hiddenDeliveryGateActive = + mainSideEffectAuthority && isRendererHiddenPtyDeliveryGateEnabled(state.settings) + // Why: structural per-PTY gate predicate (authority on + gate on + bytes + // transit local main, which implies snapshot-backed). Shared by the hidden + // mark sync and mode-2031 reply ownership so reply ownership can never + // disagree with what main may drop — and never depends on the racy hidden + // mark (a fact can outrun the pty:data task that sets it). + const isHiddenDeliveryGateManagedPty = (ptyId: string | null): ptyId is string => + hiddenDeliveryGateActive && Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) + // Why (byte-parser mode only): with main authority the Command Code scrape + // runs in main's per-PTY tracker and arrives as command-code facts; running + // the byte detector too would double-drive the seed/settle policy above. + const commandCodeOutputStatusDetector = mainSideEffectAuthority + ? null + : createCommandCodeOutputStatusDetector({ + startupCommand: paneStartup?.command, + onWorking: seedCommandCodeOutputWorkingStatus, + onDone: scheduleCommandCodeOutputDoneStatus + }) const shouldDeliverStartupViaTerminalPaste = paneStartup?.delivery === 'terminal-paste' const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 let lastTerminalInputAt = Number.NEGATIVE_INFINITY @@ -2880,12 +2856,16 @@ export function connectPanePty( ...(paneStartup?.launchAgent ? { launchAgent: paneStartup.launchAgent } : {}), ...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}), onPtyExit: onExit, - onTitleChange, onPtySpawn, - onBell, - onAgentBecameIdle, - onAgentBecameWorking, - onAgentExited, + ...(mainSideEffectAuthority + ? {} + : { + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited + }), // Why: local IPC terminals are now model-owned in main: OrcaRuntimeService // parses OSC 9999 before renderer delivery and forwards through the hook // server with local/SSH identity. Remote-runtime streams do not pass through @@ -2955,6 +2935,30 @@ export function connectPanePty( const transport = runtimeEnvironmentId ? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions) : createIpcPtyTransport(transportOptions) + // Why (gate mode only): for gate-managed PTYs this fact is the SOLE 2031 + // responder — visible, hidden, marked or not. Conditioning the reply on the + // hidden mark double-fired (mark set + bytes delivered live via interest → + // fact AND xterm both replied) or dropped the reply entirely (fact outran + // the pty:data task that set the mark). The xterm-side CSI reply and the + // skipped-byte scan are disabled for these panes (same structural + // predicate), so exactly one reply goes out. + const handleHiddenMode2031SubscribeFact = (): void => { + if (disposed || !isHiddenDeliveryGateManagedPty(transport.getPtyId())) { + return + } + const mode = resolveTerminalColorSchemeMode( + useAppStore.getState().settings, + getSystemPrefersDark() + ) + // Why immediate: a mode-2031 query reply must beat the remote input debounce + // or it can miss the querying program's read window (#7329). + transport.sendInputImmediate(mode2031SequenceFor(mode)) + // Why: register the subscription exactly like the xterm CSI handler + // would — without the registry entry, later theme flips never push the + // CSI 997 update and the TUI keeps a stale theme after reveal. + deps.recordPaneMode2031Subscription?.(pane.id, mode) + recordHiddenMode2031Reply() + } deps.paneTransportsRef.current.set(pane.id, transport) const terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({ terminal: pane.terminal, @@ -3439,10 +3443,15 @@ export function connectPanePty( // into the seed when the user is mid-TUI; the read-fallback path // omits it because it wants the user's currently-visible content. const alt = pane.terminal.buffer.active.type === 'alternate' + // Why serializeWithAbsoluteCursor: SerializeAddon's relative + // cursor restore lands one column short when replay of a + // margin-filling final row leaves the target wrap-pending. const data = opts?.altScreenForcesZeroRows && alt - ? pane.serializeAddon.serialize({ scrollback: 0 }) - : pane.serializeAddon.serialize({ scrollback: opts?.scrollbackRows }) + ? serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { scrollback: 0 }) + : serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { + scrollback: opts?.scrollbackRows + }) return { data, cols: pane.terminal.cols, @@ -3873,6 +3882,7 @@ export function connectPanePty( ...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}), ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), + ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { onData: dataCallback, onReplayData: replayDataCallback, @@ -4283,6 +4293,57 @@ export function connectPanePty( // can reuse the pane object for a different session before visibility. let hiddenOutputRestorePtyId: string | null = null let hiddenOutputRestoreGeneration = 0 + // Flood-backpressure suppression (HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS). + let hiddenOutputRestoreFloodSuppressedUntil = 0 + let hiddenOutputRestoreFloodRepaintTimer: ReturnType | null = null + // Why: after a snapshot restore, main can still drain ACK-backlog chunks + // whose bytes the snapshot already covers — writing them unguarded + // duplicates visible output. Track the restored baseline seq (per PTY) + // and the expected next chunk start so dataCallback can drop/slice + // overlaps and detect seq gaps from main-side pending-cap trims whose + // one-shot marker was already consumed. + let restoredSnapshotBaselineSeq: number | null = null + let restoredSnapshotBaselinePtyId: string | null = null + let restoredSnapshotExpectedStartSeq: number | null = null + // Why: main samples its pending renderer-delivery queue with the snapshot. + // Chunks at or below this seq can never be backlog duplicates (delivery is + // once-and-in-order), so the dedupe window is (windowStart, baseline]. + let restoredSnapshotDeliveryWindowStartSeq: number | null = null + + function setRestoredSnapshotBaseline( + ptyId: string, + snapshot: { seq?: number; pendingDeliveryStartSeq?: number } + ): void { + if (typeof snapshot.seq !== 'number') { + clearRestoredSnapshotBaseline() + return + } + const windowStartSeq = + typeof snapshot.pendingDeliveryStartSeq === 'number' + ? Math.min(snapshot.pendingDeliveryStartSeq, snapshot.seq) + : null + if (windowStartSeq !== null && windowStartSeq >= snapshot.seq) { + // Why: main reported an empty undelivered backlog — no chunk at or + // below the snapshot seq can ever arrive again (delivery is once and + // in order) and a future pending-cap trim re-arms the out-of-band + // marker. Arming a baseline anyway would misread live chunks from a + // foreign seq domain (restarted counter / synthetic injection) as + // duplicates or trim gaps and silently drop genuinely-new output. + clearRestoredSnapshotBaseline() + return + } + restoredSnapshotBaselineSeq = snapshot.seq + restoredSnapshotBaselinePtyId = ptyId + restoredSnapshotExpectedStartSeq = snapshot.seq + restoredSnapshotDeliveryWindowStartSeq = windowStartSeq + } + + function clearRestoredSnapshotBaseline(): void { + restoredSnapshotBaselineSeq = null + restoredSnapshotBaselinePtyId = null + restoredSnapshotExpectedStartSeq = null + restoredSnapshotDeliveryWindowStartSeq = null + } let foregroundImmediateBudgetChars = 0 let foregroundImmediateBudgetWindowStart = 0 let foregroundRewriteChunkEndedWithCarriageReturn = false @@ -4332,28 +4393,170 @@ export function connectPanePty( return transport.serializeBuffer(opts) } - function respondToSkippedMode2031Subscribe(data: string): void { - const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data) - hiddenMode2031ScanTail = scan.tail - if (scan.finalState === 'unsubscribed') { - deps.paneMode2031Ref.current.delete(pane.id) - deps.paneLastThemeModeRef.current.delete(pane.id) - } - if (scan.finalState !== 'subscribed') { + // Why: hidden/parked panes used to mark hidden only at the first + // dataCallback sync, leaving a spawn-time window where neither side + // answered queries (the spawn-time DA1 loss). Declaring hidden on the + // spawn IPC lets main mark the PTY before its first byte — including + // codex spawns: the model responder answers their startup probes from + // byte zero now that the 10s renderer query window is gone. + // Remote-runtime PTYs are never gate-markable (no local main transit). + function shouldDeclareHiddenAtSpawn(): boolean { + return ( + hiddenDeliveryGateActive && + !runtimeEnvironmentId && + !disposed && + !shouldWritePtyOutputForeground(deps.isVisibleRef.current) + ) + } + + // ── Hidden-delivery gate sync (Phase 4) ───────────────────────────── + // Why: marks this pane's PTY hidden in main while no visible view needs + // its bytes; main then drops delivery after model ingestion and reveal + // restores from the snapshot. The marked id is tracked locally so PTY + // changes (reattach/restart) can never leave a stale id gated. + let hiddenDeliverySyncedPtyId: string | null = null + let releaseHiddenDeliveryClaim: (() => void) | null = null + let modelRestoreSubscribedPtyId: string | null = null + let unregisterModelRestoreNeeded: (() => void) | null = null + + function isHiddenOutputRestoreFloodSuppressed(): boolean { + return Date.now() < hiddenOutputRestoreFloodSuppressedUntil + } + + // True when a drop/gap signal on a visible pane is attributable to this + // pane's OWN restore backpressure (a restore is replaying right now, or + // one was just cut off for outrunning the stream). Such signals must not + // re-arm restores — that is the rc.7.perf feedback loop. + function isForegroundRestoreBackpressureContext(): boolean { + return ( + shouldWritePtyOutputForeground(deps.isVisibleRef.current) && + (hiddenOutputRestoreInFlight !== null || isHiddenOutputRestoreFloodSuppressed()) + ) + } + + function clearHiddenOutputRestoreFloodRepaintTimer(): void { + if (hiddenOutputRestoreFloodRepaintTimer === null) { return } - const settings = useAppStore.getState().settings - const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()) - // Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer - // mode 2031 out-of-band so TUIs still render the snapshot with the same - // theme-dependent styling they would have used in a visible pane. - deps.paneMode2031Ref.current.set(pane.id, true) - // Why: a query reply — send immediately so the remote input debounce - // cannot delay it past the querying program's read window (#7329). - transport.sendInputImmediate(mode2031SequenceFor(mode)) - deps.paneLastThemeModeRef.current.set(pane.id, mode) - recordHiddenMode2031Reply() + clearTimeout(hiddenOutputRestoreFloodRepaintTimer) + hiddenOutputRestoreFloodRepaintTimer = null } + cleanupHiddenOutputRestoreFloodRepaint = clearHiddenOutputRestoreFloodRepaintTimer + + function resetHiddenOutputRestoreFloodSuppression(): void { + hiddenOutputRestoreFloodSuppressedUntil = 0 + clearHiddenOutputRestoreFloodRepaintTimer() + } + + // Extends the suppression window and (re)schedules the single deferred + // repaint for when the flood goes quiet. Every backpressure signal resets + // the timer, so it fires exactly once, SUPPRESS_MS after the last signal. + function noteHiddenOutputRestoreFloodBackpressure(): void { + hiddenOutputRestoreFloodSuppressedUntil = Date.now() + HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS + const ptyId = transport.getPtyId() + if (ptyId === null) { + return + } + clearHiddenOutputRestoreFloodRepaintTimer() + hiddenOutputRestoreFloodRepaintTimer = setTimeout(() => { + hiddenOutputRestoreFloodRepaintTimer = null + if (disposed || transport.getPtyId() !== ptyId) { + return + } + // Why one repaint: bytes were dropped during the flood, so the screen + // has a gap the live stream cannot heal. Now that the flood is quiet, + // a single snapshot restore repaints from main's authoritative buffer. + markHiddenOutputRestoreNeeded() + }, HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS) + } + + // Why: main reports dropped renderer-bound bytes (hidden gate / pending + // cap) out-of-band — routed per PTY by pty-model-restore-channel.ts. + function handleModelRestoreNeededMarker(): void { + if (disposed) { + return + } + recordTerminalFreezeBreadcrumb('restore-marker', { + id: redactPtyIdForDiagnostics(transport.getPtyId() ?? '') + }) + // Why: dropped bytes invalidate every cross-chunk carry — a partial + // OSC-9999 prefix spanning the gap would corrupt the next live chunk. + transport.resetCrossChunkParserState?.() + // Why gated (rc.7.perf loop): on a visible pane these markers are the + // product of our own restore starving ACKs. Re-arming per marker kept + // the snapshot-fetch loop alive for the whole flood; defer to one + // post-flood repaint instead and let live bytes flow. + if (isForegroundRestoreBackpressureContext()) { + noteHiddenOutputRestoreFloodBackpressure() + return + } + // Why: parity with the hidden skip path — a marker landing while a + // restore is in flight means the in-flight snapshot may predate the + // drop, so a fresh snapshot must follow. Captured BEFORE the mark: on a + // visible pane the mark starts a restore synchronously, which must not + // count as "already in flight". + const restoreWasInFlight = hiddenOutputRestoreInFlight !== null + markHiddenOutputRestoreNeeded() + if (restoreWasInFlight) { + hiddenOutputRestoreFreshSnapshotNeeded = true + } + } + + function syncModelRestoreNeededSubscription(ptyId: string | null): void { + if (modelRestoreSubscribedPtyId === ptyId) { + return + } + unregisterModelRestoreNeeded?.() + unregisterModelRestoreNeeded = null + modelRestoreSubscribedPtyId = ptyId + // Why: markers exist only for PTYs whose bytes transit local main; + // remote-runtime transports are structurally unaffected. + if (!ptyId || isRemoteRuntimePtyId(ptyId)) { + return + } + unregisterModelRestoreNeeded = registerPtyModelRestoreNeededHandler( + ptyId, + handleModelRestoreNeededMarker + ) + } + + syncHiddenRendererPtyDelivery = (): void => { + const ptyId = transport.getPtyId() + syncModelRestoreNeededSubscription(ptyId) + if (hiddenDeliverySyncedPtyId !== null && hiddenDeliverySyncedPtyId !== ptyId) { + releaseHiddenDeliveryClaim?.() + releaseHiddenDeliveryClaim = null + hiddenDeliverySyncedPtyId = null + } + if (!isHiddenDeliveryGateManagedPty(ptyId) || !canUseHiddenOutputSnapshot(ptyId)) { + return + } + const shouldHide = !disposed && !shouldWritePtyOutputForeground(deps.isVisibleRef.current) + const isFirstSyncForPty = hiddenDeliverySyncedPtyId !== ptyId + hiddenDeliverySyncedPtyId = ptyId + if (shouldHide) { + if (!releaseHiddenDeliveryClaim) { + releaseHiddenDeliveryClaim = acquireHiddenRendererPtyDeliveryClaim(ptyId) + } + } else if (releaseHiddenDeliveryClaim) { + releaseHiddenDeliveryClaim() + releaseHiddenDeliveryClaim = null + } else if (isFirstSyncForPty) { + // Why: clear unconditionally on the first sync for a PTY — a stale + // main-side hidden bit can survive a renderer reload for + // daemon-backed PTYs that keep their session id. + declareRendererPtyDeliveryVisible(ptyId) + } + } + releaseHiddenRendererPtyDelivery = (): void => { + releaseHiddenDeliveryClaim?.() + releaseHiddenDeliveryClaim = null + hiddenDeliverySyncedPtyId = null + unregisterModelRestoreNeeded?.() + unregisterModelRestoreNeeded = null + modelRestoreSubscribedPtyId = null + } + function beforeTerminalOutputWrite(): void { recordTerminalOutput(pane.terminal) } @@ -4488,6 +4691,27 @@ export function connectPanePty( } } + function respondToSkippedMode2031Subscribe(data: string): void { + const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data) + hiddenMode2031ScanTail = scan.tail + if (scan.finalState === 'unsubscribed') { + deps.paneMode2031Ref.current.delete(pane.id) + deps.paneLastThemeModeRef.current.delete(pane.id) + } + if (scan.finalState !== 'subscribed') { + return + } + const settings = useAppStore.getState().settings + const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()) + // Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer + // mode 2031 out-of-band so TUIs still render the snapshot with the same + // theme-dependent styling they would have used in a visible pane. + deps.paneMode2031Ref.current.set(pane.id, true) + transport.sendInput(mode2031SequenceFor(mode)) + deps.paneLastThemeModeRef.current.set(pane.id, mode) + recordHiddenMode2031Reply() + } + function writePtyOutputToXterm( data: string, foreground: boolean, @@ -4578,6 +4802,11 @@ export function connectPanePty( writeTerminalOutput(pane.terminal, data, { foreground: foregroundOutput, beforeWrite: beforeTerminalOutputWrite, + // Why: claims the in-progress pty:data delivery's parse-deferred ACK + // (null outside a delivery, e.g. snapshot replays / synthetic writes). + // The FIRST scheduler write of a delivery carries the whole credit; + // the scheduler fires it when the bytes are consumed. + ackCredit: takeCurrentPtyDeliveryAckCredit() ?? undefined, onBackgroundBacklogDropped: markHiddenOutputRestoreNeeded, latencySensitive: !foreground || parseHiddenStartupOutput @@ -4751,6 +4980,64 @@ export function connectPanePty( recordHiddenRendererSkip(data.length) } + // Why: discarding queued flood bytes must never swallow terminal queries — + // a lost DSR/CPR (or color/DA) reply hangs the querying program (the bench + // DSR timeout). The discarded CONTENT is owned by the snapshot repaint. + // Replies are SYNTHESIZED directly (transport.sendInput) instead of + // replaying the queries into xterm: a drop always triggers a snapshot + // restore, whose replay guard swallows xterm auto-replies and whose + // discardTerminalOutput races away queued query writes — both killed the + // salvaged reply in practice. Only called for bytes being thrown away, so + // replies cannot double-fire against a later queue drain. + function salvageRendererQueriesFromDiscardedRestoreData(data: string): void { + if (!data || !data.includes('\x1b')) { + return + } + const extracted = extractHiddenStartupRendererQueryData(data, '') + if (extracted.oscColorQueryData) { + sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) => + transport.sendInput(reply) + ) + } + let unansweredQueryData = '' + for (const sequence of splitCsiSequences( + extracted.statefulQueryData + extracted.statelessQueryData + )) { + if (sequence === '\x1b[6n') { + // CPR from the live buffer. Position may be mid-repaint stale — in a + // drop scenario positional accuracy is already forfeit; liveness is + // the contract (a blocked reader must unblock). + const buffer = pane.terminal.buffer.active + const row = Math.min(buffer.cursorY + 1, pane.terminal.rows) + const col = Math.min(buffer.cursorX + 1, pane.terminal.cols) + transport.sendInput(`\x1b[${row};${col}R`) + } else if (sequence === '\x1b[c' || sequence === '\x1b[0c') { + transport.sendInput(DEFAULT_DA1_RESPONSE) + } else { + unansweredQueryData += sequence + } + } + if (unansweredQueryData) { + // Best-effort for the rarer queries (DECRQM, DA2, XTVERSION): replay + // into xterm and let its handlers answer when no replay is active. + writePtyOutputToXterm(unansweredQueryData, true, { hiddenStartupRendererQuery: true }) + } + } + + function splitCsiSequences(queryData: string): string[] { + const sequences: string[] = [] + let offset = queryData.indexOf('\x1b[') + while (offset !== -1) { + const finalByteIndex = findCsiFinalByteIndex(queryData, offset + 2) + if (finalByteIndex === -1) { + break + } + sequences.push(queryData.slice(offset, finalByteIndex + 1)) + offset = queryData.indexOf('\x1b[', finalByteIndex + 1) + } + return sequences + } + function queueLiveChunkDuringRestore(data: string, meta?: PtyDataMeta): void { if (!data) { return @@ -4764,10 +5051,23 @@ export function connectPanePty( } hiddenOutputRestorePtyId = ptyId hiddenOutputRestoreNeeded = true + if (hiddenOutputRestorePendingOverflow) { + // Why: the overflow latch means everything queued gets discarded at the + // next drain — queueing more only grows the discard. Salvage queries, + // drop the content. + salvageRendererQueriesFromDiscardedRestoreData(data) + armHiddenOutputRestoreForegroundDeadline() + return + } if (hiddenOutputRestorePendingChars + data.length > HIDDEN_OUTPUT_RESTORE_PENDING_CHARS) { + const discardedChunks = hiddenOutputRestorePendingChunks hiddenOutputRestorePendingChunks = [] hiddenOutputRestorePendingChars = 0 hiddenOutputRestorePendingOverflow = true + for (const chunk of discardedChunks) { + salvageRendererQueriesFromDiscardedRestoreData(chunk.data) + } + salvageRendererQueriesFromDiscardedRestoreData(data) armHiddenOutputRestoreForegroundDeadline() return } @@ -4805,6 +5105,75 @@ export function connectPanePty( return chunk.data.slice(offset) } + type RestoredSnapshotReconciliation = + | { action: 'write'; data: string; meta: PtyDataMeta | undefined } + | { action: 'drop-duplicate' } + | { action: 'force-fresh-restore' } + + // Why: same slicing rules as getChunkDataAfterSnapshot, applied to LIVE + // chunks after a restore completed — main's ACK backlog keeps draining + // chunks at or before the snapshot seq, and pending-cap trims can drop + // seq ranges silently once the one-shot overflow marker was consumed. + function reconcileChunkAgainstRestoredSnapshot( + data: string, + meta: PtyDataMeta | undefined + ): RestoredSnapshotReconciliation { + if (restoredSnapshotBaselineSeq === null) { + return { action: 'write', data, meta } + } + if (transport.getPtyId() !== restoredSnapshotBaselinePtyId) { + clearRestoredSnapshotBaseline() + return { action: 'write', data, meta } + } + if (typeof meta?.seq !== 'number') { + // Why: seq-less chunks (no runtime metering) cannot be reconciled; + // mirror getChunkDataAfterSnapshot and pass them through. + return { action: 'write', data, meta } + } + if ( + restoredSnapshotDeliveryWindowStartSeq !== null && + meta.seq <= restoredSnapshotDeliveryWindowStartSeq + ) { + // Why: every byte main could still deliver at snapshot time started + // AFTER this seq, and delivery is once-and-in-order — so this chunk + // cannot be a backlog duplicate. It is a new seq domain (restarted + // counter / synthetic source); retire the stale baseline and write + // instead of silently dropping genuinely-new live output. + clearRestoredSnapshotBaseline() + return { action: 'write', data, meta } + } + const rawLength = meta.rawLength ?? data.length + const startSeq = meta.seq - rawLength + const expectedStartSeq = restoredSnapshotExpectedStartSeq + restoredSnapshotExpectedStartSeq = Math.max(expectedStartSeq ?? meta.seq, meta.seq) + if (expectedStartSeq !== null && startSeq > expectedStartSeq) { + // Why: the chunk starts past the continuity point — bytes between + // were dropped (pending-cap trim after the marker fired). Only the + // model snapshot can heal the gap. + return { action: 'force-fresh-restore' } + } + if (meta.seq <= restoredSnapshotBaselineSeq) { + return { action: 'drop-duplicate' } + } + if (startSeq >= restoredSnapshotBaselineSeq) { + return { action: 'write', data, meta } + } + if (rawLength !== data.length) { + // Why: renderer-only OSC stripping makes raw sequence offsets + // impossible to map onto cleaned text — fetch a fresh snapshot + // instead of risking duplicate visible output. + return { action: 'force-fresh-restore' } + } + const sliced = data.slice(restoredSnapshotBaselineSeq - startSeq) + return { + action: 'write', + data: sliced, + // Why: keep seq metadata consistent with the sliced payload so a + // later restore queue drain slices against accurate offsets. + meta: { ...meta, rawLength: sliced.length } + } + } + function recordRendererOrderedSeq(meta?: Pick): void { if (typeof meta?.seq !== 'number') { return @@ -4825,6 +5194,12 @@ export function connectPanePty( // Why: an exit ends this ptyId's seq domain. A revived session can reuse // the id with a restarted main-side counter, and a stale high-water mark // would wrongly cover — and silently drop — every hidden byte it emits. + // The restored-snapshot baseline is a seq high-water mark too and must + // die at the same boundary, or reconcile drops revived chunks as + // duplicates. + if (restoredSnapshotBaselinePtyId === exitedPtyId) { + clearRestoredSnapshotBaseline() + } if (rendererOrderedPtyId === exitedPtyId) { rendererOrderedPtyId = null rendererOrderedSeq = null @@ -4878,26 +5253,39 @@ export function connectPanePty( ) } - function drainPendingLiveChunksAfterSnapshot(snapshotSeq: number | undefined): boolean { + // 'drained' painted every queued live byte; 'overflow' means the queue + // blew its cap during this restore (the stream is outrunning snapshot + // fetch+replay); 'refetch' means offsets were unmappable and only a + // fresher snapshot can realign. + function drainPendingLiveChunksAfterSnapshot( + snapshotSeq: number | undefined + ): 'drained' | 'overflow' | 'refetch' { if (hiddenOutputRestorePendingOverflow) { hiddenOutputRestorePendingOverflow = false - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - return false + discardPendingLiveChunksSalvagingQueries() + return 'overflow' } while (hiddenOutputRestorePendingChunks.length > 0) { const chunks = hiddenOutputRestorePendingChunks hiddenOutputRestorePendingChunks = [] hiddenOutputRestorePendingChars = 0 - for (const chunk of chunks) { + for (const [index, chunk] of chunks.entries()) { const data = getChunkDataAfterSnapshot(chunk, snapshotSeq) if (data === null) { // Why: renderer-only OSC stripping makes raw sequence offsets // impossible to map onto cleaned text. Fetch a fresher main // snapshot instead of risking duplicate visible output. - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - return false + for (const discarded of chunks.slice(index)) { + salvageRendererQueriesFromDiscardedRestoreData(discarded.data) + } + discardPendingLiveChunksSalvagingQueries() + return 'refetch' + } + // Why: drained chunks advance the post-restore continuity point so + // the live-chunk reconciliation neither re-drops them as duplicates + // nor misreads the next live chunk as a gap. + if (typeof chunk.seq === 'number' && restoredSnapshotExpectedStartSeq !== null) { + restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq) } if (data) { writePtyOutputToXterm(data, true) @@ -4906,12 +5294,20 @@ export function connectPanePty( } if (hiddenOutputRestorePendingOverflow) { hiddenOutputRestorePendingOverflow = false - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - return false + discardPendingLiveChunksSalvagingQueries() + return 'overflow' } } - return true + return 'drained' + } + + function discardPendingLiveChunksSalvagingQueries(): void { + const discarded = hiddenOutputRestorePendingChunks + hiddenOutputRestorePendingChunks = [] + hiddenOutputRestorePendingChars = 0 + for (const chunk of discarded) { + salvageRendererQueriesFromDiscardedRestoreData(chunk.data) + } } function clearPendingLiveChunksDuringRestore(): void { @@ -4975,7 +5371,10 @@ export function connectPanePty( }, HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS) } - function abandonHiddenOutputRestoreAndDrainPendingForeground(expectedPtyId: string): void { + function abandonHiddenOutputRestoreAndDrainPendingForeground( + expectedPtyId: string, + opts: { quiet?: boolean } = {} + ): void { if (transport.getPtyId() !== expectedPtyId || hiddenOutputRestorePtyId !== expectedPtyId) { resetHiddenOutputRestoreIfPtyChanged() return @@ -5002,7 +5401,12 @@ export function connectPanePty( clearHiddenOutputRestoreForegroundDeadlineTimer() hiddenOutputRestoreDeferredRetryAttempts = 0 - writeRestoreUnavailableWarning() + // Why quiet exists: flood cuts abandon deliberately and schedule a + // post-flood repaint — the "restore unavailable" warning would be + // misleading noise the repaint immediately wipes. + if (!opts.quiet) { + writeRestoreUnavailableWarning() + } if (hadPendingOverflow) { return } @@ -5101,7 +5505,10 @@ export function connectPanePty( // Why: renderer backlog is tied to the old PTY stream; after reattach, // queued hidden bytes must not delay or replay before the new PTY. clearHiddenOutputRestoreState() + clearRestoredSnapshotBaseline() clearPaneMode2031State() + // Why: flood-backpressure evidence is per PTY stream too. + resetHiddenOutputRestoreFloodSuppression() discardTerminalOutput(pane.terminal) } } @@ -5122,6 +5529,7 @@ export function connectPanePty( rows: number seq?: number alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string }): void { const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) @@ -5153,13 +5561,19 @@ export function connectPanePty( // clearing on restore loses scroll-up after a hidden->visible return. // Mirrors the attach-time guard in pty-transport.ts. writeReplayData('\x1b[2J\x1b[3J\x1b[H') + } else if (snapshot.scrollbackAnsi !== undefined) { + // Why: SerializeAddon captures normal and alternate buffers together. + // Rebuild normal while it is active, then return to a clean alt frame. + writeReplayData('\x1b[?1049l\x1b[2J\x1b[3J\x1b[H') + writeReplayData(snapshot.scrollbackAnsi) + writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') } else { // Why: the snapshot's own ?1049h is a no-op when the pane is already on // the alternate screen, and the serialized frame skips blank cells — so // without clearing the alt screen the pre-hide frame bleeds through // every cell the final frame leaves blank. \x1b[2J on the alt buffer // does not touch the normal buffer's scrollback the TUI returns to. - writeReplayData('\x1b[?1049h\x1b[2J\x1b[H') + writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') } writeReplayData(snapshot.data) // Why: status/title-corroborated live agents own ?25l/?1004h (a forced @@ -5171,10 +5585,11 @@ export function connectPanePty( : POST_REPLAY_LIVE_SNAPSHOT_RESET ) if (snapshot.pendingEscapeTailAnsi) { - // Why last: the snapshot was serialized while the emulator sat mid-escape; - // re-arm the dangling sequence as the FINAL replay write (any later ESC — - // including the reset above — would abort it) so the racing live tail's - // continuation completes it instead of rendering literally (#7329). + // Why last: the snapshot was taken with main's emulator mid-escape; + // re-arming the dangling sequence must be the FINAL replay write (any + // later ESC — including the reset above — aborts it) so the racing + // live tail's continuation completes it exactly as live, instead of + // rendering literally (Bug E fix / #7329). writeReplayData(snapshot.pendingEscapeTailAnsi) } hiddenRendererStateDirty = false @@ -5255,6 +5670,10 @@ export function connectPanePty( hiddenOutputRestoreRetryDeferred = false hiddenOutputRestoreInFlight = (async () => { + // Backstop for the rc.7.perf feedback loop: bound how many snapshot + // fetch+replay rounds one task may burn before it must yield to the + // live stream. + let restoreIterations = 0 while (!disposed) { const currentPtyId = hiddenOutputRestorePtyId if (currentPtyId === null) { @@ -5279,7 +5698,7 @@ export function connectPanePty( let snapshot: PtyBufferSnapshot | null = null try { snapshot = await serializeHiddenOutputSnapshot(currentPtyId, { - scrollbackRows: HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS + scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback) }) } catch { snapshot = null @@ -5308,10 +5727,16 @@ export function connectPanePty( return } hiddenOutputRestoreDeferredRetryAttempts = 0 + restoreIterations += 1 applyMainBufferSnapshot(snapshot) + // Why: everything at or before snapshot.seq is now painted; chunks + // still draining from main's ACK backlog below that point are + // duplicates the dataCallback reconciliation must suppress. + setRestoredSnapshotBaseline(currentPtyId, snapshot) const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded hiddenOutputRestoreFreshSnapshotNeeded = false - if (drainPendingLiveChunksAfterSnapshot(snapshot.seq) && !needsFreshSnapshot) { + const drainOutcome = drainPendingLiveChunksAfterSnapshot(snapshot.seq) + if (drainOutcome === 'drained' && !needsFreshSnapshot) { hiddenOutputRestoreNeeded = false hiddenOutputRestorePtyId = null clearHiddenOutputRestoreForegroundDeadlineTimer() @@ -5324,6 +5749,31 @@ export function connectPanePty( hiddenOutputRestoreNeeded = true return } + if (drainOutcome === 'overflow') { + // Cut 1 of the rc.7.perf feedback loop: a FOREGROUND queue + // overflow means the live stream outruns snapshot fetch+replay. + // Re-fetching would starve ACK processing again and feed the + // drop/re-arm cycle — abandon now, let bytes write through, and + // heal with one repaint after the flood. + noteHiddenOutputRestoreFloodBackpressure() + abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true }) + return + } + if (restoreIterations >= HIDDEN_OUTPUT_RESTORE_MAX_LOOP_ITERATIONS) { + // Backstop: fresh-snapshot marks / unmappable slices re-looping + // this many times means the stream is winning the race. + warnTerminalLifecycleAnomaly('hidden output restore hit its iteration cap', { + tabId: deps.tabId, + worktreeId: deps.worktreeId, + leafId: pane.leafId, + paneId: pane.id, + ptyId: currentPtyId, + reason: drainOutcome + }) + noteHiddenOutputRestoreFloodBackpressure() + abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true }) + return + } hiddenOutputRestoreNeeded = true } })() @@ -5349,23 +5799,38 @@ export function connectPanePty( return true } - unregisterBacklogRecovery = registerTerminalBacklogRecovery( - pane.terminal, - requestHiddenOutputRestoreIfNeeded - ) + unregisterBacklogRecovery = registerTerminalBacklogRecovery(pane.terminal, () => { + // Why: clear the hidden-delivery bit BEFORE the restore snapshot + // request — bytes arriving between the unhide IPC and the snapshot + // are reconciled by the existing seq guard. + syncHiddenRendererPtyDelivery() + return requestHiddenOutputRestoreIfNeeded() + }) if ( typeof document !== 'undefined' && typeof document.addEventListener === 'function' && typeof document.removeEventListener === 'function' ) { const onDocumentVisibilityChange = (): void => { + // Why: document hide/show flips the foreground predicate without any + // pane lifecycle event — re-sync the hidden-delivery gate both ways. + syncHiddenRendererPtyDelivery() if (shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { requestHiddenOutputRestoreIfNeeded() } } document.addEventListener('visibilitychange', onDocumentVisibilityChange) - unregisterDocumentVisibilityRecovery = () => + // Why: when user input proves visibilityState is wedged at 'hidden' + // (stale macOS occlusion), run the same resync — no visibilitychange + // will ever fire in that state, and the gate would drop watched bytes + // forever. + const unregisterStaleVisibilityRecovery = registerStaleDocumentVisibilityRecovery( + onDocumentVisibilityChange + ) + unregisterDocumentVisibilityRecovery = () => { document.removeEventListener('visibilitychange', onDocumentVisibilityChange) + unregisterStaleVisibilityRecovery() + } } const dataCallback = (data: string, meta?: PtyDataMeta): void => { @@ -5385,28 +5850,86 @@ export function connectPanePty( } observeStartupDraftPasteReadiness(data) resetHiddenOutputRestoreIfPtyChanged() - if (meta?.droppedBacklog === true) { - // Why: main trimmed this pty's unsent backlog (the renderer was - // background-throttled/frozen and stopped ACKing). Rebuild the dropped - // span from the main headless snapshot — same recovery the renderer's - // own 2 MB scheduler overflow uses. No-op cost when already visible+synced. - markHiddenOutputRestoreNeeded() + if (meta?.droppedOutput === true) { + // Why gated (rc.7.perf loop): a visible pane's cap-drop during its own + // restore is backpressure the restore itself caused — re-arming per + // sentinel kept the snapshot-fetch loop alive for the whole flood. + // Defer to one post-flood repaint; any carved-out query bytes riding + // the sentinel still flow through the normal write path below. + if (meta?.background !== true && isForegroundRestoreBackpressureContext()) { + noteHiddenOutputRestoreFloodBackpressure() + } else { + // Why: main dropped this PTY's buffered output at the pending cap + // (renderer was not receiving). The stream has a gap, so repaint the + // pane from the main-owned buffer snapshot instead of writing on. + markHiddenOutputRestoreNeeded() + if (data) { + // The sentinel can carry query bytes carved out of the bulk drop + // (extractDroppedPtyQueryBytes in main) — replies must still flow. + salvageRendererQueriesFromDiscardedRestoreData(data) + } + return + } } respondToTerminalPixelSizeQueries(data) observeTerminalBracketedPasteModeOutput(pane.terminal, data) - for (const link of observeTerminalGitHubPRLink(data)) { - useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link) + // Why: with main side-effect authority, command-finished, pr-link, and + // the Command Code scrape arrive as pty:sideEffect facts — + // byte-scanning here too would double-fire the same policy. + // Remote-runtime PTYs (and the kill switch off) keep this byte path as + // their only parser. + if (!mainSideEffectAuthority) { + for (const link of observeTerminalGitHubPRLink(data)) { + useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link) + } + commandLifecycle.handlePtyData(data) } - commandCodeOutputStatusDetector.observe(data) - commandLifecycle.handlePtyData(data) + commandCodeOutputStatusDetector?.observe(data) // Why: split-pane layouts have multiple visible-but-inactive panes whose // output the user is watching. Throttle only when the pane or whole // Electron document is hidden. const foreground = shouldWritePtyOutputForeground(deps.isVisibleRef.current) && meta?.background !== true + // Why: latch the hidden-delivery gate from the byte path too — covers a + // PTY id arriving after the initial sync. No-op when state is current. + if (!foreground) { + syncHiddenRendererPtyDelivery() + } if (foreground && hiddenMode2031ScanTail) { respondToSkippedMode2031Subscribe(data) } + // Why: post-restore reconciliation — drop/slice backlog chunks the + // restored snapshot already covers, and force a fresh restore for seq + // gaps or overlaps whose offsets cannot be mapped. Runs after the byte + // observers above (those bytes were never delivered before; their side + // effects are still real) but before any xterm write decision. + const reconciliation = reconcileChunkAgainstRestoredSnapshot(data, meta) + if (reconciliation.action === 'drop-duplicate') { + return + } + if (reconciliation.action === 'force-fresh-restore') { + // Why gated (rc.7.perf loop): during a foreground flood the seq gaps + // come from our own backpressure drops — fetching a snapshot per gap + // IS the feedback loop. Retire the stale baseline, write the post-gap + // bytes through, and heal with one repaint after the flood. + if (foreground && isForegroundRestoreBackpressureContext()) { + noteHiddenOutputRestoreFloodBackpressure() + clearRestoredSnapshotBaseline() + // fall through with the ORIGINAL data/meta — post-gap bytes are new + } else { + // Why: in-flight captured BEFORE the mark — on a visible pane the + // mark starts the restore synchronously and must not flag itself. + const restoreWasInFlight = hiddenOutputRestoreInFlight !== null + markHiddenOutputRestoreNeeded() + if (restoreWasInFlight) { + hiddenOutputRestoreFreshSnapshotNeeded = true + } + return + } + } else { + data = reconciliation.data + meta = reconciliation.meta + } // Why: a hidden Codex query can be split just before visibility changes; // xterm needs the completed query, while other bytes still follow restore. const pendingForegroundQuery = foreground @@ -5472,7 +5995,15 @@ export function connectPanePty( hiddenOutputRestoreNeeded = true hiddenOutputRestoreFreshSnapshotNeeded = true } + // Why: hidden chunks with a restore already latched are dropped here — + // the model snapshot fetched on reveal covers their bytes. } else { + // Why: gate-managed hidden panes normally receive no bytes (main + // drops after model ingestion). Any hidden chunk that still arrives + // (kill switch off, interest-held delivery) rides the bounded + // background scheduler queue; on overflow the scheduler latches the + // model restore. The kill-switch-off startup-query grammar above is + // the byte-identical fallback. if (pendingForegroundQuery?.statefulQueryData) { writePtyOutputToXterm(pendingForegroundQuery.statefulQueryData, true, { hiddenStartupRendererQuery: true @@ -5551,6 +6082,8 @@ export function connectPanePty( } setPanePtyFitBinding(ptyId) reportPanePtyVisibility(ptyId, deps.isVisibleRef.current) + registerSideEffectFactConsumerForPty(ptyId) + syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, ptyId) deps.updateTabPtyId(deps.tabId, ptyId) agentCompletionCoordinator.startProcessTracking() @@ -5905,6 +6438,7 @@ export function connectPanePty( ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), + ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { onData: dataCallback, onReplayData: replayDataCallback, @@ -6088,6 +6622,7 @@ export function connectPanePty( : {}), ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), + ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { onData: dataCallback, onReplayData: replayDataCallback, @@ -6348,6 +6883,12 @@ export function connectPanePty( return { syncProcessTracking() { agentCompletionCoordinator.startProcessTracking() + // Why: the lifecycle hook calls this on every pane visibility flip — + // the hidden-delivery gate must follow the same transitions. + syncHiddenRendererPtyDelivery() + }, + isHiddenDeliveryGateManagedPty() { + return isHiddenDeliveryGateManagedPty(transport.getPtyId()) }, // Why: called from the lifecycle visibility effect so the visible-resume // size readback can repair dropped hidden resizes without refitting against @@ -6416,6 +6957,9 @@ export function connectPanePty( cancelAnimationFrame(pendingForegroundGridDriftCheckRaf) pendingForegroundGridDriftCheckRaf = null } + // Why: a pane unmount (tab move, parking teardown) must never leave its + // PTY gated — the parked watcher or the remounted pane re-decides. + releaseHiddenRendererPtyDelivery() if (terminalKeyTargetSupportsEvents) { terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true }) } @@ -6451,11 +6995,15 @@ export function connectPanePty( } cleanupHiddenOutputRestoreDeferredRetry() cleanupHiddenOutputRestoreForegroundDeadline() + cleanupHiddenOutputRestoreFloodRepaint() unregisterBacklogRecovery?.() unregisterBacklogRecovery = null unregisterDocumentVisibilityRecovery?.() unregisterDocumentVisibilityRecovery = null - reportPanePtyVisibility(activePanePtyBinding ?? transport.getPtyId(), false) + releaseRendererPtyVisibilityClaim(transport) + // Why: a parked-tab watcher may take over this PTY's facts in the same + // effect flush; the pane's consumer must be gone before that handoff. + dropSideEffectFactConsumer() clearPanePtyFitBinding() discardTerminalOutput(pane.terminal) unregisterE2ePtyDataInjection() diff --git a/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts b/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts index af06e1b4a26..3625a8e6007 100644 --- a/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts +++ b/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts @@ -1,9 +1,14 @@ +import { acquirePtyDeliveryInterest } from './pty-delivery-interest' import { ensurePtyDispatcher, ptyDataSidecars } from './pty-dispatcher' /** Register a side-channel data watcher for a PTY without taking ownership * of the primary handler. Returns an unsubscribe fn. */ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => void): () => void { ensurePtyDispatcher() + // Why: a sidecar is, by definition, a raw-byte consumer — its registration + // doubles as the delivery-interest signal that suppresses main's + // hidden-delivery gate (terminal-side-effect-authority.md, Open Items). + const releaseDeliveryInterest = acquirePtyDeliveryInterest(ptyId) let set = ptyDataSidecars.get(ptyId) if (!set) { set = new Set() @@ -11,6 +16,7 @@ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => voi } set.add(watcher) return () => { + releaseDeliveryInterest() const current = ptyDataSidecars.get(ptyId) if (!current) { return diff --git a/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts b/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts new file mode 100644 index 00000000000..a695b77d82f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts @@ -0,0 +1,42 @@ +/** + * Renderer-side delivery-interest registry for the Phase-4 hidden-delivery + * gate (docs/reference/terminal-side-effect-authority.md, Open Items). + * + * Why: main only drops hidden PTY byte delivery while NO renderer party needs + * raw bytes. Dispatcher sidecars and eager pre-mount buffers register + * interest here; ref-counted so main sees only the 0↔1 transitions. + */ +const ptyDeliveryInterestRefCounts = new Map() + +function sendPtyDeliveryInterest(ptyId: string, interested: boolean): void { + ;(globalThis as { window?: Window }).window?.api?.pty?.setPtyDeliveryInterest?.(ptyId, interested) +} + +/** Acquire a delivery-interest hold for a PTY. Returns a release fn that is + * safe to call more than once (only the first call decrements). */ +export function acquirePtyDeliveryInterest(ptyId: string): () => void { + const next = (ptyDeliveryInterestRefCounts.get(ptyId) ?? 0) + 1 + ptyDeliveryInterestRefCounts.set(ptyId, next) + if (next === 1) { + sendPtyDeliveryInterest(ptyId, true) + } + let released = false + return () => { + if (released) { + return + } + released = true + const current = ptyDeliveryInterestRefCounts.get(ptyId) ?? 0 + if (current <= 1) { + ptyDeliveryInterestRefCounts.delete(ptyId) + sendPtyDeliveryInterest(ptyId, false) + } else { + ptyDeliveryInterestRefCounts.set(ptyId, current - 1) + } + } +} + +/** Test seam: drop ref counts between tests (no IPC is sent). */ +export function _resetPtyDeliveryInterestForTest(): void { + ptyDeliveryInterestRefCounts.clear() +} diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts new file mode 100644 index 00000000000..c8f84865fed --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts @@ -0,0 +1,107 @@ +// Why: the Phase-4 hidden-delivery gate only drops bytes while NO renderer +// party needs them. These tests pin the dispatcher-side interest signal: every +// subscribeToPtyData sidecar must surface a ref-counted delivery-interest hold +// to main. Eager buffers are model-recoverable and must not defeat hidden gating. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('pty dispatcher delivery interest', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let setPtyDeliveryInterest: ReturnType + let exitCallback: ((payload: { id: string; code: number }) => void) | null = null + + beforeEach(() => { + vi.resetModules() + exitCallback = null + setPtyDeliveryInterest = vi.fn() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + setPtyDeliveryInterest, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((cb: (payload: { id: string; code: number }) => void) => { + exitCallback ??= cb + return () => {} + }), + ackData: vi.fn() + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('registers interest on the first sidecar and releases on the last unsubscribe', async () => { + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + + const unsubscribeFirst = subscribeToPtyData('pty-1', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + expect(setPtyDeliveryInterest).toHaveBeenCalledWith('pty-1', true) + + // Why: ref-counted — main only sees the 0↔1 transitions. + const unsubscribeSecond = subscribeToPtyData('pty-1', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + + unsubscribeFirst() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + unsubscribeSecond() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2) + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false) + }) + + it('releases sidecar interest only once for repeated unsubscribes', async () => { + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + + const unsubscribe = subscribeToPtyData('pty-1', vi.fn()) + unsubscribe() + unsubscribe() + + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2) + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false) + }) + + it('does not let an eager pre-mount buffer defeat hidden delivery gating', async () => { + const { registerEagerPtyBuffer } = await import('./pty-dispatcher') + + const handle = registerEagerPtyBuffer('pty-eager', vi.fn()) + expect(setPtyDeliveryInterest).not.toHaveBeenCalled() + + handle.dispose() + expect(setPtyDeliveryInterest).not.toHaveBeenCalled() + }) + + it('keeps eager buffers outside delivery interest when the PTY exits before mount', async () => { + const { registerEagerPtyBuffer } = await import('./pty-dispatcher') + + registerEagerPtyBuffer('pty-eager', vi.fn()) + expect(setPtyDeliveryInterest).not.toHaveBeenCalled() + + exitCallback?.({ id: 'pty-eager', code: 0 }) + expect(setPtyDeliveryInterest).not.toHaveBeenCalled() + }) + + it('lets a sidecar exclusively own interest while an eager buffer overlaps', async () => { + const { registerEagerPtyBuffer } = await import('./pty-dispatcher') + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + + const handle = registerEagerPtyBuffer('pty-1', vi.fn()) + const unsubscribe = subscribeToPtyData('pty-1', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + + handle.dispose() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + + unsubscribe() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2) + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts new file mode 100644 index 00000000000..7febefe5126 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts @@ -0,0 +1,92 @@ +// Why: pins the renderer half of the delivery-resync protocol — the singleton +// dispatcher must answer main's probe with the cumulative processed totals +// that back its ACKs, and must drop a PTY's total once it exits. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('pty dispatcher delivery resync', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + let dataCallback: + | ((payload: { id: string; data: string; rawLength?: number; background?: boolean }) => void) + | null = null + let exitCallback: ((payload: { id: string; code: number }) => void) | null = null + let resyncRequestCallback: ((payload: { requestId: number }) => void) | null = null + const ackDataMock = vi.fn() + const respondDeliveryResyncMock = vi.fn() + + beforeEach(() => { + vi.resetModules() + dataCallback = null + exitCallback = null + resyncRequestCallback = null + ackDataMock.mockClear() + respondDeliveryResyncMock.mockClear() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + ackData: ackDataMock, + onData: vi.fn( + ( + cb: (payload: { + id: string + data: string + rawLength?: number + background?: boolean + }) => void + ) => { + dataCallback ??= cb + return () => {} + } + ), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((cb: (payload: { id: string; code: number }) => void) => { + exitCallback ??= cb + return () => {} + }), + onDeliveryResyncRequest: vi.fn((cb: (payload: { requestId: number }) => void) => { + resyncRequestCallback ??= cb + return () => {} + }), + respondDeliveryResync: respondDeliveryResyncMock + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('answers delivery resync probes with cumulative totals and drops exited PTYs', async () => { + const { ensurePtyDispatcher } = await import('./pty-dispatcher') + ensurePtyDispatcher() + + dataCallback?.({ id: 'pty-1', data: 'hello' }) + dataCallback?.({ id: 'pty-1', data: 'world!!', rawLength: 7 }) + dataCallback?.({ id: 'pty-2', data: 'abc' }) + + expect(ackDataMock).toHaveBeenNthCalledWith(1, 'pty-1', 5, 5) + expect(ackDataMock).toHaveBeenNthCalledWith(2, 'pty-1', 7, 12) + expect(ackDataMock).toHaveBeenNthCalledWith(3, 'pty-2', 3, 3) + + resyncRequestCallback?.({ requestId: 7 }) + expect(respondDeliveryResyncMock).toHaveBeenCalledWith({ + requestId: 7, + processedCharsByPty: { 'pty-1': 12, 'pty-2': 3 } + }) + + exitCallback?.({ id: 'pty-1', code: 0 }) + resyncRequestCallback?.({ requestId: 8 }) + expect(respondDeliveryResyncMock).toHaveBeenLastCalledWith({ + requestId: 8, + processedCharsByPty: { 'pty-2': 3 } + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts index bce9767bd85..2df88ac2973 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts @@ -92,7 +92,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { dispatcherCallback?.({ id: 'pty-pi', data: 'chunk', rawLength: 10, background: true } as never) expect(handler).toHaveBeenCalledWith('chunk', { rawLength: 10, background: true }) - expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-pi', 10) + expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-pi', 10, 10) ptyDataHandlers.delete('pty-pi') }) @@ -299,7 +299,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { dispatcherCallback?.({ id: 'pty-early', data: 'setup starts here\r\n', rawLength: 19 }) expect(onData).not.toHaveBeenCalled() - expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-early', 19) + expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-early', 19, 19) resolveSpawn({ id: 'pty-early' }) await connectPromise diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts new file mode 100644 index 00000000000..7430231bf04 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts @@ -0,0 +1,111 @@ +// Why: unit-level repro of the field wedge (v1.4.121-rc.0) — push events +// vanish before the dispatcher sees them, so no handler runs and no ACK is +// ever produced — plus the recovery seam: reattachPtyDispatcherPushListeners +// must drop the stale subscriptions and bind fresh ones. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: true } })) + +type DataCallback = (payload: { id: string; data: string; rawLength?: number }) => void + +describe('pty dispatcher push-listener reattach and delivery blackhole', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + let dataCallbacks: DataCallback[] = [] + let dataUnsubscribes: ReturnType[] = [] + const ackDataMock = vi.fn() + const reportMock = vi.fn(() => + Promise.resolve({ + inFlightTotalChars: 0, + inFlightPtyCount: 0, + msSinceLastAck: null + }) + ) + + beforeEach(() => { + vi.resetModules() + dataCallbacks = [] + dataUnsubscribes = [] + ackDataMock.mockClear() + reportMock.mockClear() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + ackData: ackDataMock, + reportRendererDeliveryState: reportMock, + getPtyDataListenerCount: () => dataCallbacks.length, + onData: vi.fn((cb: DataCallback) => { + dataCallbacks.push(cb) + const unsubscribe = vi.fn() + dataUnsubscribes.push(unsubscribe) + return unsubscribe + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + onDeliveryResyncRequest: vi.fn(() => () => {}), + respondDeliveryResync: vi.fn() + } + } + } as unknown as typeof window + }) + + afterEach(async () => { + // Why: ensurePtyDispatcher started the watchdog's real-timer interval; + // stop it so no tick outlives this file's mocked window. + const { stopTerminalDeliveryWatchdog } = await import('./terminal-delivery-watchdog') + stopTerminalDeliveryWatchdog() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('blackholed delivery reproduces the wedge: no handler dispatch, no ACK ever', async () => { + const { ensurePtyDispatcher, ptyDataHandlers } = await import('./pty-dispatcher') + ensurePtyDispatcher() + const received: string[] = [] + ptyDataHandlers.set('pty-1', (data) => received.push(data)) + + dataCallbacks[0]?.({ id: 'pty-1', data: 'before-wedge' }) + expect(received).toEqual(['before-wedge']) + expect(ackDataMock).toHaveBeenCalledTimes(1) + + const blackhole = ( + window as Window & { + __terminalDeliveryWatchdog?: { blackhole: (on: boolean) => void } + } + ).__terminalDeliveryWatchdog + expect(blackhole).toBeDefined() + blackhole!.blackhole(true) + + // The field failure in miniature: the chunk vanishes with no receive + // count and no ACK — main's in-flight debt for it can never be repaid. + dataCallbacks[0]?.({ id: 'pty-1', data: 'lost-in-wedge' }) + expect(received).toEqual(['before-wedge']) + expect(ackDataMock).toHaveBeenCalledTimes(1) + + blackhole!.blackhole(false) + }) + + it('reattach drops stale push subscriptions and binds fresh ones', async () => { + const { ensurePtyDispatcher, ptyDataHandlers, reattachPtyDispatcherPushListeners } = + await import('./pty-dispatcher') + ensurePtyDispatcher() + const received: string[] = [] + ptyDataHandlers.set('pty-1', (data) => received.push(data)) + expect(dataCallbacks).toHaveLength(1) + + reattachPtyDispatcherPushListeners() + + expect(dataUnsubscribes[0]).toHaveBeenCalledTimes(1) + expect(dataCallbacks).toHaveLength(2) + + dataCallbacks[1]?.({ id: 'pty-1', data: 'after-reattach' }) + expect(received).toEqual(['after-reattach']) + expect(ackDataMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 4c3857d9a09..b563443d1ee 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -6,7 +6,12 @@ * and the eager-buffer reconnection logic share. */ import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' -import { ackPtyData, exposeE2eTerminalPtyAckGate } from './terminal-pty-ack-gate' +import { + clearProcessedPtyCharTotal, + deliverPtyDataWithDeferredAck, + exposeE2eTerminalPtyAckGate, + getProcessedPtyCharTotals +} from './terminal-pty-ack-gate' import { clampUtf8Tail, type EagerBufferChunk } from './pty-eager-buffer-clamp' import { bufferPreHandlerPtyData, @@ -15,6 +20,14 @@ import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer' +import { + clearReceivedPtyCharTotal, + isPtyPushDeliveryBlackholed, + recordPtyDataReceived, + startTerminalDeliveryWatchdog +} from './terminal-delivery-watchdog' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' +import { installTerminalFreezeReport } from './terminal-freeze-report' // ── Singleton PTY event dispatcher ─────────────────────────────────── // One global IPC listener per channel, routes events to transports by @@ -25,9 +38,9 @@ export type PtyDataMeta = { seq?: number rawLength?: number background?: boolean - /** Main trimmed this pty's unsent backlog past its cap; the handler should - * rebuild the dropped span from the main headless snapshot. */ - droppedBacklog?: boolean + /** Main dropped this PTY's buffered output at the pending cap; the pane + * must repaint from the main-owned snapshot instead of the live stream. */ + droppedOutput?: boolean } export const ptyDataHandlers = new Map void>() @@ -102,78 +115,151 @@ export function restorePtyDataHandlersAfterFailedShutdown( } } +let pushListenerUnsubscribes: (() => void)[] = [] + +/** Detach and freshly re-subscribe every push-channel listener. Called by the + * delivery watchdog on a confirmed wedge: if the listener was somehow + * detached this restores delivery outright; if the channel itself is dead + * it is a safe no-op (removeListener on a gone listener does nothing). */ +export function reattachPtyDispatcherPushListeners(): void { + recordTerminalFreezeBreadcrumb('push-listeners-reattach', { + staleListenerCount: pushListenerUnsubscribes.length + }) + const stale = pushListenerUnsubscribes + pushListenerUnsubscribes = [] + for (const unsubscribe of stale) { + unsubscribe() + } + attachPtyPushListeners() +} + export function ensurePtyDispatcher(): void { if (ptyDispatcherAttached) { return } ptyDispatcherAttached = true exposeE2eTerminalPtyAckGate() - window.api.pty.onData((payload) => { - try { - let meta: PtyDataMeta | undefined - if (typeof payload.seq === 'number') { - meta ??= {} - meta.seq = payload.seq + installTerminalFreezeReport() + attachPtyPushListeners() + startTerminalDeliveryWatchdog({ + reattachPushListeners: reattachPtyDispatcherPushListeners, + hasAttachedPtys: () => ptyDataHandlers.size > 0 || eagerPtyHandles.size > 0 + }) +} + +function attachPtyPushListeners(): void { + const unsubscribes = pushListenerUnsubscribes + unsubscribes.push( + window.api.pty.onData((payload) => { + // Why: e2e-only wedge simulation — the chunk vanishes exactly as in the + // field failure: no receive count, no ACK credit, no handler dispatch. + if (isPtyPushDeliveryBlackholed()) { + return } - if (typeof payload.rawLength === 'number') { - meta ??= {} - meta.rawLength = payload.rawLength + handleDispatchedPtyData(payload) + }) + ) + attachPtySecondaryPushListeners(unsubscribes) +} + +function handleDispatchedPtyData(payload: { + id: string + data: string + seq?: number + rawLength?: number + background?: boolean + droppedOutput?: boolean +}): void { + let meta: PtyDataMeta | undefined + if (typeof payload.seq === 'number') { + meta ??= {} + meta.seq = payload.seq + } + if (typeof payload.rawLength === 'number') { + meta ??= {} + meta.rawLength = payload.rawLength + } + if (payload.background === true) { + meta ??= {} + meta.background = true + } + if (payload.droppedOutput === true) { + meta ??= {} + meta.droppedOutput = true + } + const chars = payload.rawLength ?? payload.data.length + const dispatch = (): void => { + const handler = ptyDataHandlers.get(payload.id) + if (handler) { + handler(payload.data, meta) + } else { + bufferPreHandlerPtyData(payload.id, payload.data, meta) + } + const sidecars = ptyDataSidecars.get(payload.id) + if (sidecars && sidecars.size > 0) { + // Why: snapshot the Set before iterating because watchers commonly + // unsubscribe themselves on the very chunk that satisfies them + // (e.g. agent-paste-draft resolves on DECSET 2004 and immediately + // tears down). Iterating the live Set in that case can skip a + // watcher or — if a watcher synchronously subscribes a sibling — + // double-fire. The Set is never large (one watcher per active + // ready-wait), so the array allocation is cheap. + const snapshot = Array.from(sidecars) + for (const watcher of snapshot) { + watcher(payload.data) } - if (payload.background === true) { - meta ??= {} - meta.background = true - } - if (payload.droppedBacklog === true) { - meta ??= {} - meta.droppedBacklog = true - } - const handler = ptyDataHandlers.get(payload.id) + } + } + recordPtyDataReceived(payload.id, chars) + // Why deferred: main budgets renderer-bound output by bytes PARSED, not + // bytes received. The handler's scheduler write claims this delivery's + // credit and fires it when xterm consumes the bytes; deliveries that never + // reach the scheduler (dropped, pre-mount eager buffer) settle at return — + // a bad sidecar still cannot leave a PTY permanently backpressured. + deliverPtyDataWithDeferredAck(payload.id, chars, dispatch) +} + +function attachPtySecondaryPushListeners(unsubscribes: (() => void)[]): void { + unsubscribes.push( + window.api.pty.onReplay((payload) => { + ptyReplayHandlers.get(payload.id)?.(payload.data) + }) + ) + unsubscribes.push( + window.api.pty.onExit((payload) => { + // Why: main drops its delivery accounting for this pty on exit; drop the + // cumulative totals too so a reused id restarts at zero on both sides. + clearProcessedPtyCharTotal(payload.id) + clearReceivedPtyCharTotal(payload.id) + const handler = ptyExitHandlers.get(payload.id) if (handler) { - handler(payload.data, meta) + clearPreHandlerPtyState(payload.id) + handler(payload.code) } else { - bufferPreHandlerPtyData(payload.id, payload.data, meta) + bufferPreHandlerPtyExit(payload.id, payload.code) } - const sidecars = ptyDataSidecars.get(payload.id) + const sidecars = ptyExitSidecars.get(payload.id) if (sidecars && sidecars.size > 0) { - // Why: snapshot the Set before iterating because watchers commonly - // unsubscribe themselves on the very chunk that satisfies them - // (e.g. agent-paste-draft resolves on DECSET 2004 and immediately - // tears down). Iterating the live Set in that case can skip a - // watcher or — if a watcher synchronously subscribes a sibling — - // double-fire. The Set is never large (one watcher per active - // ready-wait), so the array allocation is cheap. const snapshot = Array.from(sidecars) - for (const watcher of snapshot) { - watcher(payload.data) + ptyExitSidecars.delete(payload.id) + for (const sidecar of snapshot) { + sidecar(payload.code) } } - } finally { - // Why: main budgets renderer-bound terminal output by bytes accepted - // into this dispatcher. ACK in finally so a bad sidecar cannot leave - // a PTY permanently backpressured. - ackPtyData(payload.id, payload.rawLength ?? payload.data.length) - } - }) - window.api.pty.onReplay((payload) => { - ptyReplayHandlers.get(payload.id)?.(payload.data) - }) - window.api.pty.onExit((payload) => { - const handler = ptyExitHandlers.get(payload.id) - if (handler) { - clearPreHandlerPtyState(payload.id) - handler(payload.code) - } else { - bufferPreHandlerPtyExit(payload.id, payload.code) - } - const sidecars = ptyExitSidecars.get(payload.id) - if (sidecars && sidecars.size > 0) { - const snapshot = Array.from(sidecars) - ptyExitSidecars.delete(payload.id) - for (const sidecar of snapshot) { - sidecar(payload.code) - } - } + }) + ) + // Why: main probes when delivery looks stuck on lost ACKs (data arriving + // for a fully gated PTY). Replying with the cumulative processed totals + // lets main reconcile verified state instead of resetting blindly. + const unsubscribeResync = window.api.pty.onDeliveryResyncRequest?.((payload) => { + window.api.pty.respondDeliveryResync?.({ + requestId: payload.requestId, + processedCharsByPty: getProcessedPtyCharTotals() + }) }) + if (unsubscribeResync) { + unsubscribes.push(unsubscribeResync) + } // Why: tell main the pty:data listener is live now. Before this fires (fresh // load or post-reload boot window) main holds all sends — bytes sent into a // listener-less page are silently dropped yet counted in-flight, which @@ -223,7 +309,6 @@ export function registerEagerPtyBuffer( onExit: (ptyId: string, code: number) => void ): EagerPtyHandle { ensurePtyDispatcher() - // Why: a head index instead of Array.shift() — shift() is O(n), making // pre-attach buffering quadratic under many small chunks. Compaction is deferred. const chunks: EagerBufferChunk[] = [] @@ -252,7 +337,7 @@ export function registerEagerPtyBuffer( // Shell died before TerminalPane attached — clean up and notify the store // so the tab's ptyId is cleared and connectPanePty falls through to connect(). // Identity-guarded like dispose(): never delete a handler a transport has - // since registered for this id. + // since registered for this id (#7894 detach/attach remount race). if (ptyDataHandlers.get(ptyId) === dataHandler) { ptyDataHandlers.delete(ptyId) ptyReplayHandlers.delete(ptyId) @@ -277,6 +362,8 @@ export function registerEagerPtyBuffer( return data }, dispose() { + // Why: dispose runs at pane attach (mount completed) — the pane's own + // visibility sync now owns the hidden-delivery decision for this PTY. // Only remove if the current handler is still the temp one (compare by // reference). After attach() replaces the handler this becomes a no-op. if (ptyDataHandlers.get(ptyId) === dataHandler) { diff --git a/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts new file mode 100644 index 00000000000..289192d13d2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts @@ -0,0 +1,86 @@ +// Why: the out-of-band pty:modelRestoreNeeded channel replaces the in-band +// empty-chunk sentinel (ambiguous with chunks fully consumed by OSC-9999 +// stripping). These tests pin the channel routing: one channel subscription, +// handlers keyed by PTY id, replace-on-reregister semantics. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('pty model-restore channel routing', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let onModelRestoreNeeded: ReturnType + let channelCallback: ((event: { id: string; reason: string; markerSeq?: number }) => void) | null + + beforeEach(() => { + vi.resetModules() + channelCallback = null + onModelRestoreNeeded = vi.fn( + (callback: (event: { id: string; reason: string; markerSeq?: number }) => void) => { + channelCallback ??= callback + return () => {} + } + ) + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + onModelRestoreNeeded + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('attaches the channel once and routes markers to the registered PTY handler', async () => { + const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel') + const handlerA = vi.fn() + const handlerB = vi.fn() + + registerPtyModelRestoreNeededHandler('pty-a', handlerA) + registerPtyModelRestoreNeededHandler('pty-b', handlerB) + expect(onModelRestoreNeeded).toHaveBeenCalledTimes(1) + + channelCallback?.({ id: 'pty-a', reason: 'hidden-drop', markerSeq: 42 }) + expect(handlerA).toHaveBeenCalledWith({ id: 'pty-a', reason: 'hidden-drop', markerSeq: 42 }) + expect(handlerB).not.toHaveBeenCalled() + + // Markers for PTYs without a registered handler are dropped silently. + channelCallback?.({ id: 'pty-unknown', reason: 'pending-cap' }) + expect(handlerA).toHaveBeenCalledTimes(1) + expect(handlerB).not.toHaveBeenCalled() + }) + + it('lets a new registration replace a stale one without the stale unregister clobbering it', async () => { + const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel') + const staleHandler = vi.fn() + const liveHandler = vi.fn() + + const unregisterStale = registerPtyModelRestoreNeededHandler('pty-a', staleHandler) + registerPtyModelRestoreNeededHandler('pty-a', liveHandler) + // Why: a reattaching pane can re-register before the old connection's + // teardown runs — the stale unregister must not remove the live handler. + unregisterStale() + + channelCallback?.({ id: 'pty-a', reason: 'unhide' }) + expect(staleHandler).not.toHaveBeenCalled() + expect(liveHandler).toHaveBeenCalledTimes(1) + }) + + it('stops routing after the live handler unregisters', async () => { + const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel') + const handler = vi.fn() + + const unregister = registerPtyModelRestoreNeededHandler('pty-a', handler) + unregister() + + channelCallback?.({ id: 'pty-a', reason: 'hidden-drop' }) + expect(handler).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts new file mode 100644 index 00000000000..350deb25422 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts @@ -0,0 +1,66 @@ +/** + * Singleton router for the out-of-band `pty:modelRestoreNeeded` channel + * (sibling of the pty-dispatcher's data/exit routing — split out to keep the + * dispatcher under the line limit). + * + * Why a dedicated channel + registry: the marker means "main dropped + * renderer-bound bytes (hidden gate / pending cap); restore from the model + * snapshot". It must NOT ride the transport data path — an in-band empty + * chunk is ambiguous with chunks fully consumed by OSC-9999 stripping, and + * remote-runtime transports (which never see main's gate) must stay + * structurally unaffected. + */ +import type { PtyModelRestoreNeededEvent } from '../../../../shared/pty-model-restore-marker' + +const ptyModelRestoreNeededHandlers = new Map void>() +let modelRestoreNeededChannelAttached = false + +function dispatchPtyModelRestoreNeeded(event: PtyModelRestoreNeededEvent): void { + ptyModelRestoreNeededHandlers.get(event.id)?.(event) +} + +function ensureModelRestoreNeededChannel(): void { + if (modelRestoreNeededChannelAttached) { + return + } + // Why optional-chained: unit tests and the web remote client expose a + // partial pty API; missing channel means "no markers", never a throw. + const onModelRestoreNeeded = (globalThis as { window?: Window }).window?.api?.pty + ?.onModelRestoreNeeded + if (typeof onModelRestoreNeeded !== 'function') { + return + } + modelRestoreNeededChannelAttached = true + onModelRestoreNeeded(dispatchPtyModelRestoreNeeded) +} + +/** Register the single model-restore-needed handler for a PTY (the pane + * connection that owns its view). A new registration replaces a stale one. */ +export function registerPtyModelRestoreNeededHandler( + ptyId: string, + handler: (event: PtyModelRestoreNeededEvent) => void +): () => void { + ensureModelRestoreNeededChannel() + ptyModelRestoreNeededHandlers.set(ptyId, handler) + return () => { + if (ptyModelRestoreNeededHandlers.get(ptyId) === handler) { + ptyModelRestoreNeededHandlers.delete(ptyId) + } + } +} + +/** Deliver markers fetched over invoke by the delivery watchdog. Same routing + * as the push channel — needed because a delivery-heal fires precisely when + * `pty:modelRestoreNeeded` push events cannot reach this renderer. */ +export function deliverPulledPtyModelRestoreMarkers( + events: readonly PtyModelRestoreNeededEvent[] +): void { + for (const event of events) { + dispatchPtyModelRestoreNeeded(event) + } +} + +/** Test seam: deliver a marker as if it arrived on the channel. */ +export function _dispatchPtyModelRestoreNeededForTest(event: PtyModelRestoreNeededEvent): void { + dispatchPtyModelRestoreNeeded(event) +} diff --git a/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts new file mode 100644 index 00000000000..5da2177f1b2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + _resetPtyRendererDeliveryClaimsForTest, + acquireHiddenRendererPtyDeliveryClaim, + declareRendererPtyDeliveryVisible, + releaseRendererPtyVisibilityClaim, + setRendererPtyVisibilityClaim +} from './pty-renderer-delivery-claims' + +const PTY_ID = 'workspace@@pty-1' + +describe('renderer PTY delivery claims', () => { + const setHiddenRendererPty = vi.fn() + const setRendererPtyVisible = vi.fn() + + beforeEach(() => { + _resetPtyRendererDeliveryClaimsForTest() + setHiddenRendererPty.mockReset() + setRendererPtyVisible.mockReset() + ;(globalThis as { window: Window }).window = { + api: { pty: { setHiddenRendererPty, setRendererPtyVisible } } + } as unknown as Window + }) + + it('keeps a PTY hidden across an overlapping pane-to-watcher handoff', () => { + const releasePane = acquireHiddenRendererPtyDeliveryClaim(PTY_ID) + const releaseWatcher = acquireHiddenRendererPtyDeliveryClaim(PTY_ID) + + expect(setHiddenRendererPty).toHaveBeenCalledTimes(1) + expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, true) + + releasePane() + expect(setHiddenRendererPty).toHaveBeenCalledTimes(1) + + declareRendererPtyDeliveryVisible(PTY_ID) + expect(setHiddenRendererPty).toHaveBeenCalledTimes(1) + + releaseWatcher() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false) + expect(setHiddenRendererPty).toHaveBeenCalledTimes(2) + }) + + it('does not let a retiring visible pane hide its replacement', () => { + const oldPane = {} + const newPane = {} + setRendererPtyVisibilityClaim(oldPane, PTY_ID, true) + setRendererPtyVisibilityClaim(newPane, PTY_ID, true) + + expect(setRendererPtyVisible).toHaveBeenCalledTimes(1) + releaseRendererPtyVisibilityClaim(oldPane) + expect(setRendererPtyVisible).toHaveBeenCalledTimes(1) + + releaseRendererPtyVisibilityClaim(newPane) + expect(setRendererPtyVisible).toHaveBeenLastCalledWith(PTY_ID, false) + expect(setRendererPtyVisible).toHaveBeenCalledTimes(2) + }) + + it('reports a never-visible mounted pane as known hidden', () => { + setRendererPtyVisibilityClaim({}, PTY_ID, false) + expect(setRendererPtyVisible).toHaveBeenCalledWith(PTY_ID, false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts new file mode 100644 index 00000000000..57644da8a83 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts @@ -0,0 +1,122 @@ +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' + +const hiddenClaimCounts = new Map() + +type VisibilityClaim = { ptyId: string; visible: boolean } + +const visibilityClaimsByOwner = new Map() +const visibleClaimCounts = new Map() + +function sendHiddenState(ptyId: string, hidden: boolean): void { + recordTerminalFreezeBreadcrumb(hidden ? 'renderer-gate-mark' : 'renderer-gate-unmark', { + id: redactPtyIdForDiagnostics(ptyId) + }) + ;(globalThis as { window?: Window }).window?.api?.pty?.setHiddenRendererPty?.(ptyId, hidden) +} + +function sendVisibility(ptyId: string, visible: boolean): void { + ;(globalThis as { window?: Window }).window?.api?.pty?.setRendererPtyVisible?.(ptyId, visible) +} + +/** + * Holds a hidden-delivery claim until the returned release function runs. + * Main sees only the first-acquire/last-release transitions for each PTY. + */ +export function acquireHiddenRendererPtyDeliveryClaim(ptyId: string): () => void { + const nextCount = (hiddenClaimCounts.get(ptyId) ?? 0) + 1 + hiddenClaimCounts.set(ptyId, nextCount) + if (nextCount === 1) { + sendHiddenState(ptyId, true) + } + + let released = false + return () => { + if (released) { + return + } + released = true + const currentCount = hiddenClaimCounts.get(ptyId) ?? 0 + if (currentCount <= 1) { + hiddenClaimCounts.delete(ptyId) + sendHiddenState(ptyId, false) + return + } + hiddenClaimCounts.set(ptyId, currentCount - 1) + } +} + +/** Clears stale main state for a visible PTY without overriding another live + * hidden owner that is still completing a pane-to-watcher handoff. */ +export function declareRendererPtyDeliveryVisible(ptyId: string): void { + if (!hiddenClaimCounts.has(ptyId)) { + sendHiddenState(ptyId, false) + } +} + +function removeVisibleClaim(claim: VisibilityClaim): boolean { + if (!claim.visible) { + return false + } + const currentCount = visibleClaimCounts.get(claim.ptyId) ?? 0 + if (currentCount <= 1) { + visibleClaimCounts.delete(claim.ptyId) + return true + } + visibleClaimCounts.set(claim.ptyId, currentCount - 1) + return false +} + +/** + * Reports one mounted transport's visibility. Ref-counting by owner prevents + * a retiring pane from hiding a PTY after its replacement has already bound. + */ +export function setRendererPtyVisibilityClaim( + owner: object, + ptyId: string, + visible: boolean +): void { + const previous = visibilityClaimsByOwner.get(owner) + if (previous?.ptyId === ptyId && previous.visible === visible) { + return + } + + if (previous) { + const becameHidden = removeVisibleClaim(previous) + if (becameHidden && previous.ptyId !== ptyId) { + sendVisibility(previous.ptyId, false) + } + } + + visibilityClaimsByOwner.set(owner, { ptyId, visible }) + if (visible) { + const nextCount = (visibleClaimCounts.get(ptyId) ?? 0) + 1 + visibleClaimCounts.set(ptyId, nextCount) + if (nextCount === 1) { + sendVisibility(ptyId, true) + } + return + } + + if (!visibleClaimCounts.has(ptyId)) { + sendVisibility(ptyId, false) + } +} + +export function releaseRendererPtyVisibilityClaim(owner: object): void { + const previous = visibilityClaimsByOwner.get(owner) + if (!previous) { + return + } + visibilityClaimsByOwner.delete(owner) + if (removeVisibleClaim(previous)) { + sendVisibility(previous.ptyId, false) + } +} + +/** Test seam: renderer reload naturally clears these module-scoped claims. */ +export function _resetPtyRendererDeliveryClaimsForTest(): void { + hiddenClaimCounts.clear() + visibilityClaimsByOwner.clear() + visibleClaimCounts.clear() +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 61320e60f56..e5ab8c38cf3 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -12,16 +12,23 @@ export type PtyBufferSnapshot = { cols: number rows: number seq?: number + /** Lowest seq main could still deliver when the snapshot was taken (start + * of its pending renderer-delivery queue; equals `seq` when empty). Bytes + * are delivered once and in order, so a post-restore chunk at or below + * this seq can never be a duplicate the snapshot already covers. */ + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' /** True when the snapshot captures an alternate-screen TUI (Claude Code, * vim). Restore must NOT clear xterm's buffer in that case — the TUI's * scrollback lives in xterm and a clear destroys scroll-up after a tab * return. Mirrors the attach-time guard in pty-transport.ts. */ alternateScreen?: boolean - /** Trailing partial escape sequence the source emulator held mid-parse when - * the snapshot was taken. The restorer writes it LAST (after the reset) so a - * racing live continuation completes it instead of rendering literally - * (#7329). */ + /** Authoritative normal buffer paired with an alternate-screen frame. */ + scrollbackAnsi?: string + /** Trailing incomplete escape sequence main's emulator ingested (a PTY read + * ended mid-escape). Must be written LAST — after post-replay resets, right + * before post-snapshot live chunks — so the continuation completes it + * exactly as live instead of rendering literal (Bug E / #7329). */ pendingEscapeTailAnsi?: string } @@ -63,6 +70,11 @@ export type PtyTransport = { cols?: number rows?: number sessionId?: string + /** Hidden-at-spawn declaration (terminal-query-authority.md): no visible + * view will consume this PTY's bytes, so main marks it hidden BEFORE the + * first byte and the gate + model responder own spawn-time queries. + * Ignored by remote-runtime transports (not gate-markable). */ + initiallyHidden?: boolean command?: string env?: Record launchConfig?: SleepingAgentLaunchConfig @@ -97,6 +109,10 @@ export type PtyTransport = { getPtyId: () => string | null getConnectionId?: () => string | null | undefined getLocalSessionMetadata?: () => LocalPtySessionMetadata | null + /** Drop cross-chunk parser carries (partial OSC-9999 prefix). Called when a + * model-restore marker reports dropped bytes — a carry spanning the gap + * would corrupt the next live chunk. IPC transports only. */ + resetCrossChunkParserState?: () => void serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise preserve?: () => void detach?: () => void diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index e68a5f9a2db..b676fd2fdfe 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -82,6 +82,37 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('leaves the transport silently unbound after a failed connect — sendInput drops with no write IPC (frozen-terminal repro)', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const write = window.api.pty.write as unknown as ReturnType + const transport = createIpcPtyTransport({}) + + // Generic spawn failure (e.g. daemon not ready during a startup restore): + // the error IS surfaced via onError, but the transport stays unbound and + // every later keystroke is dropped with no further signal. + spawn.mockRejectedValueOnce(new Error('daemon socket not ready')) + const onError = vi.fn() + await transport.connect({ url: '', callbacks: { onError } }) + expect(onError).toHaveBeenCalled() + expect(transport.isConnected()).toBe(false) + expect(transport.sendInput('echo hello\r')).toBe(false) + await flushPtySideEffects() + expect(write).not.toHaveBeenCalled() + + // The tombstoned-session rejection is swallowed with NO callback at all — + // a restored pane that hits it renders persisted content while eating + // keystrokes with zero user-visible signal (Discord #performance / #2836). + spawn.mockRejectedValueOnce(new Error('TerminalKilledError: session xyz was explicitly killed')) + const onErrorKilled = vi.fn() + await transport.connect({ url: '', callbacks: { onError: onErrorKilled } }) + expect(onErrorKilled).not.toHaveBeenCalled() + expect(transport.isConnected()).toBe(false) + expect(transport.sendInput('echo hello\r')).toBe(false) + await flushPtySideEffects() + expect(write).not.toHaveBeenCalled() + }) + it('ignores a stale exit for a previous PTY after reconnecting the same transport', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType @@ -293,6 +324,42 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('runs title side effects even when the data callback does not render the chunk', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const onTitleChange = vi.fn() + const onDataCallback = vi.fn() + const transport = createIpcPtyTransport({ onTitleChange }) + + await transport.connect({ url: '', callbacks: { onData: onDataCallback } }) + + onData?.({ id: 'pty-1', data: '\u001b]0;hidden-title\u0007' }) + + expect(onDataCallback).toHaveBeenCalledWith('\u001b]0;hidden-title\u0007') + expect(onTitleChange).not.toHaveBeenCalled() + + await flushPtySideEffects() + + expect(onTitleChange).toHaveBeenCalledWith('hidden-title', 'hidden-title') + transport.disconnect() + }) + + it('drops the OSC-9999 cross-chunk carry on resetAgentStatusCarry', async () => { + // Why: a model-restore marker means bytes were dropped between chunks — + // a partial OSC-9999 prefix carried across that gap would swallow the + // next live chunk's head as bogus status payload. + const { createPtyOutputProcessor } = await import('./pty-transport') + const processor = createPtyOutputProcessor({}) + const callbacks = { onData: vi.fn() } + + processor.processData('\x1b]9999;', callbacks) + expect(callbacks.onData).toHaveBeenLastCalledWith('') + + processor.resetAgentStatusCarry() + processor.processData('plain output after the gap', callbacks) + + expect(callbacks.onData).toHaveBeenLastCalledWith('plain output after the gap') + }) + it('does not schedule PTY side-effect drains for ordinary output with no working title', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 7df168d7611..f2c107e441b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -26,7 +26,7 @@ import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handle import { createPtyInputWriteQueue } from './pty-input-write-queue' import type { PtyDataMeta } from './pty-dispatcher' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' -import { createBellDetector } from './bell-detector' +import { createBellDetector } from '../../../../shared/terminal-bell-detector' import { hasTerminalDisplayContent, trimIncompleteTerminalControlTail @@ -79,7 +79,12 @@ type PtyOutputProcessorOptions = Pick< | 'onAgentBecameWorking' | 'onAgentExited' | 'onAgentStatus' -> +> & { + /** Seed for processors that start mid-session (parked-tab byte watchers): + * the pane's last known title, so a working agent that finishes while the + * processor owns the stream still yields a working→idle transition. */ + initialAgentTitle?: string +} type ProcessPtyOutputOptions = { replayingBufferedData?: boolean @@ -105,7 +110,8 @@ export function createPtyOutputProcessor({ onAgentBecameIdle, onAgentBecameWorking, onAgentExited, - onAgentStatus + onAgentStatus, + initialAgentTitle }: PtyOutputProcessorOptions): { processData: ( data: string, @@ -117,10 +123,18 @@ export function createPtyOutputProcessor({ clearStaleTitleTimer: () => void flushPendingSideEffects: () => void resetBellDetector: () => void + resetAgentStatusCarry: () => void } { const bellDetector = createBellDetector() - const processAgentStatusChunk = createAgentStatusOscProcessor() - let lastEmittedTitle: string | null = null + // Why `let`: a model-restore marker means bytes were dropped between + // chunks; a partial OSC-9999 prefix carried across that gap would swallow + // the next live chunk's head as bogus payload. Reset recreates the parser. + let processAgentStatusChunk = createAgentStatusOscProcessor() + // Why: seed both the emitted-title memory (stale-title probe) and the agent + // tracker so a mid-session processor behaves as if it had observed the + // pane's last live title — full parity with the live path it replaces. + let lastEmittedTitle: string | null = + initialAgentTitle !== undefined ? normalizeTerminalTitle(initialAgentTitle) : null let staleTitleTimer: ReturnType | null = null let sideEffectDrainTimer: ReturnType | null = null let pendingSideEffects: PendingPtySideEffect[] = [] @@ -133,7 +147,8 @@ export function createPtyOutputProcessor({ onAgentBecameIdle?.(title) }, onAgentBecameWorking, - onAgentExited + onAgentExited, + initialAgentTitle ) : null @@ -444,7 +459,10 @@ export function createPtyOutputProcessor({ clearAccumulatedState, clearStaleTitleTimer, flushPendingSideEffects, - resetBellDetector: () => bellDetector.reset() + resetBellDetector: () => bellDetector.reset(), + resetAgentStatusCarry: () => { + processAgentStatusChunk = createAgentStatusOscProcessor() + } } } @@ -673,6 +691,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra : {}), ...(connectionId ? { connectionId } : {}), ...(options.sessionId ? { sessionId: options.sessionId } : {}), + // Why: hidden-at-spawn mark must land in main before the PTY's + // first byte, so it rides the spawn IPC instead of the pane's + // first visibility sync (terminal-query-authority.md). + ...(options.initiallyHidden ? { initiallyHidden: true } : {}), worktreeId, ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}), @@ -959,6 +981,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } }, + resetCrossChunkParserState() { + // Why: only the OSC-9999 carry spans the dropped-byte gap a + // model-restore marker reports; title/bell trackers re-sync from the + // snapshot's side-effect replay and must not be reset here. + outputProcessor.resetAgentStatusCarry() + }, + destroy() { destroyed = true this.disconnect() diff --git a/src/renderer/src/components/terminal-pane/replay-guard.test.ts b/src/renderer/src/components/terminal-pane/replay-guard.test.ts index f870f518c0a..c26acbfca7c 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { isPaneReplaying, @@ -7,6 +7,22 @@ import { type ReplayingPanesRef } from './replay-guard' +const mocks = vi.hoisted(() => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb +})) + +beforeEach(() => { + mocks.recordRendererCrashBreadcrumb.mockClear() +}) + +afterEach(() => { + vi.useRealTimers() +}) + function makeRef(): ReplayingPanesRef { return { current: new Map() } as ReplayingPanesRef } @@ -142,21 +158,23 @@ describe('replay-guard', () => { }) it('auto-releases the guard when xterm never fires the parse callback', () => { - // Repro of the cold-restore reattach lockout: handleReattachResult replays - // three chunks into a just-mounted / offscreen pane whose terminal never - // flushes, so xterm's parse callback never runs and the counter would stay - // pinned at 3 — isPaneReplaying() stuck true drops EVERY keystroke. The - // fallback must release the guard so input is not swallowed forever. + // Repro of the cold-restore reattach lockout (main #7661): + // handleReattachResult replays three chunks into a just-mounted / + // offscreen pane whose terminal never flushes, so xterm's parse callback + // never runs and the counter would stay pinned at 3 — isPaneReplaying() + // stuck true drops EVERY keystroke. The probe-certified stall path (probe + // never parses either => wedged release) must free the guard. vi.useFakeTimers() try { const ref = makeRef() const { pane } = makeFakePane(1) - replayIntoTerminal(pane, ref, '\x1b[2J\x1b[3J\x1b[H') - replayIntoTerminal(pane, ref, 'scrollback bytes') - replayIntoTerminal(pane, ref, '--- session restored ---') + replayIntoTerminal(pane, ref, '\x1b[2J\x1b[3J\x1b[H', 400) + replayIntoTerminal(pane, ref, 'scrollback bytes', 400) + replayIntoTerminal(pane, ref, '--- session restored ---', 400) expect(isPaneReplaying(ref, 1)).toBe(true) - // Never flush — simulate the missing parse callback for an unflushed pane. + // Never flush — the probe write never parses; the wedged release fires + // one stall window after the probe (400 + 400). vi.advanceTimersByTime(1000) expect(isPaneReplaying(ref, 1)).toBe(false) @@ -166,18 +184,18 @@ describe('replay-guard', () => { } }) - it('parse completion cancels the fallback without over-releasing', () => { + it('parse completion cancels the stall probe without over-releasing', () => { vi.useFakeTimers() try { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'a') - replayIntoTerminal(pane, ref, 'b') + replayIntoTerminal(pane, ref, 'a', 400) + replayIntoTerminal(pane, ref, 'b', 400) terminal.flush() expect(isPaneReplaying(ref, 1)).toBe(false) - // The already-cancelled fallback must not fire and underflow the counter. + // The already-cancelled stall timer must not fire and underflow the counter. vi.advanceTimersByTime(1000) expect(isPaneReplaying(ref, 1)).toBe(false) expect(ref.current.has(1)).toBe(false) @@ -192,7 +210,7 @@ describe('replay-guard', () => { const ref = makeRef() const { pane } = makeFakePane(1) let resolved = false - const promise = replayIntoTerminalAsync(pane, ref, 'x').then(() => { + const promise = replayIntoTerminalAsync(pane, ref, 'x', 400).then(() => { resolved = true }) expect(isPaneReplaying(ref, 1)).toBe(true) @@ -240,3 +258,164 @@ describe('replay-guard', () => { } }) }) + +describe('replay-guard stall handling (probe-certified release)', () => { + it('HOLDS the guard while a slow replay is still parsing — a probe is queued, never a blind release', () => { + // Why this is the load-bearing safety test: a time-based release here + // would leak xterm auto-replies into the shell (and a leaked ESC into an + // agent TUI reads as the user pressing Escape). The guard must only + // release when the pipeline itself proves the replay parsed. + vi.useFakeTimers() + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'slow but alive', 1_000) + expect(isPaneReplaying(ref, 1)).toBe(true) + + // Stall check fires: an empty probe write is enqueued behind the replay. + vi.advanceTimersByTime(1_000) + expect(terminal.lastData).toEqual(['slow but alive', '']) + // Probe is pending → replay genuinely still parsing → guard holds. + expect(isPaneReplaying(ref, 1)).toBe(true) + vi.advanceTimersByTime(999) + expect(isPaneReplaying(ref, 1)).toBe(true) + + // Parsing finishes: FIFO runs the replay completion first (normal + // release), then the probe completion as a no-op. + terminal.flush() + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(ref.current.has(1)).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + + vi.advanceTimersByTime(120_000) + expect(ref.current.has(1)).toBe(false) + }) + + it('releases when the probe parses but the replay completion was lost, and reports it', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + terminal.pendingCallbacks.shift() // xterm lost the replay's completion + vi.advanceTimersByTime(1_000) // stall check → probe enqueued + + // The probe's completion firing certifies every earlier replay byte + // parsed — releasing now cannot leak auto-replies. + terminal.flush() + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_replay_guard_lost_completion', + { paneId: 1 } + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('releases after the probe itself never parses (wedged pipeline) and reports it', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + vi.advanceTimersByTime(1_000) // stall check → probe enqueued + expect(isPaneReplaying(ref, 1)).toBe(true) + + // A wedged parser will never run the probe callback — and can never + // emit auto-replies either, so this bounded release cannot leak input. + vi.advanceTimersByTime(1_000) + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_replay_guard_wedged_release', + { paneId: 1 } + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('releases immediately when the probe write throws (terminal disposed mid-replay)', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + terminal.write = () => { + throw new Error('terminal disposed') + } + vi.advanceTimersByTime(1_000) + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_replay_guard_wedged_release', + { paneId: 1 } + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('keeps overlapping engagements independent through a lost completion', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'lost completion', 1_000) + replayIntoTerminal(pane, ref, 'healthy completion', 60_000) + terminal.pendingCallbacks.shift() // drop only the first completion + vi.advanceTimersByTime(1_000) // first engagement's probe enqueued + + terminal.flush() // healthy completion + probe both parse + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(ref.current.has(1)).toBe(false) + + vi.advanceTimersByTime(120_000) + expect(ref.current.has(1)).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) + + it('never probes after a normal completion', () => { + vi.useFakeTimers() + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'healthy', 1_000) + terminal.flush() + expect(isPaneReplaying(ref, 1)).toBe(false) + + vi.advanceTimersByTime(60_000) + expect(terminal.lastData).toEqual(['healthy']) + expect(mocks.recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + }) + + it('resolves replayIntoTerminalAsync via the wedged path so restore chains cannot hang', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane } = makeFakePane(1) + + const replayDone = replayIntoTerminalAsync(pane, ref, 'restored bytes', 1_000) + let resolved = false + void replayDone.then(() => { + resolved = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + expect(resolved).toBe(true) + expect(isPaneReplaying(ref, 1)).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts index e6c733d668c..6bf821ea62d 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -1,5 +1,6 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { writeForegroundTerminalChunk } from '@/lib/pane-manager/pane-terminal-foreground-render-settle' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' // Why: xterm.js auto-responds to terminal query sequences (DA1 `CSI c`, // DECRQM `CSI ? Ps $ p`, OSC 10/11 color queries, focus events, CPR) by @@ -27,55 +28,98 @@ import { writeForegroundTerminalChunk } from '@/lib/pane-manager/pane-terminal-f export type ReplayingPanesRef = React.RefObject> -// Why: the guard normally releases in xterm's write-completion callback, but -// that callback never fires for a pane whose terminal has not been flushed — -// e.g. a cold-restore reattach that replays into a just-mounted / offscreen -// pane. Without a ceiling the counter leaks, isPaneReplaying() stays true, and -// the onData handler silently drops EVERY keystroke (the pane looks alive but -// ignores input). Release deterministically after this bound so the guard -// always clears; in the normal path onParsed fires within milliseconds and -// cancels it first. Tradeoff: if the callback is lost and the pane parses the -// replayed buffer only after this bound elapses (e.g. rendering resumes long -// after restore), xterm's auto-replies to any device queries in that buffer can -// leak to the shell as input. Accepted as strictly preferable to a permanent -// input lockout, and bounded by the ~100 KB replay cap. -const REPLAY_GUARD_RELEASE_FALLBACK_MS = 1000 +// Why stall handling exists: the decrement above only runs when xterm +// completes the write. A wedged WriteBuffer (sync throw escaping a parse +// handler or a write-completion callback — see +// xterm-write-buffer-stall.repro.test.ts) or a disposed-terminal race can +// drop that completion forever, leaving the guard latched on a live pane — +// which silently eats every keystroke (Discord #performance / issue #2836). +// +// Why release is probe-certified, never time-based: a blind timeout release +// while a slow replay is still parsing would let xterm's auto-replies leak +// into the shell — and into agent TUIs, where a leaked ESC reads as the user +// pressing Escape. Instead, when a completion looks overdue we enqueue an +// empty probe write. xterm parses writes in order, so only three states are +// possible, and release is provably safe in every state that releases: +// 1. probe completes, replay callback already ran → normal release won. +// 2. probe completes, replay callback never ran → every replay byte has +// parsed (FIFO), so no further auto-replies can exist; the completion +// was genuinely lost. Release. +// 3. probe never completes → the pipeline is +// wedged; a dead parser can never emit auto-replies, so releasing after +// a bounded wait cannot leak anything — and the pane needs recovery, +// which the breadcrumb reports. +// While the probe is pending (slow-but-alive replay), the guard HOLDS. +const REPLAY_GUARD_STALL_CHECK_MS = 10_000 export function isPaneReplaying(ref: ReplayingPanesRef, paneId: number): boolean { return (ref.current.get(paneId) ?? 0) > 0 } -/** Engage the per-pane replay guard and return a `finish` callback that - * releases it exactly once. The guard also auto-releases after - * REPLAY_GUARD_RELEASE_FALLBACK_MS so a missing xterm parse callback can never - * strand it engaged. `onReleased` runs once when the guard actually releases - * (via `finish` or the fallback), so async callers can settle either way. */ +type ReplayGuardWriteTarget = Pick + +/** + * Engage the replay counter for one write and return the release function. + * Release runs exactly once — from xterm's write completion or, failing + * that, from the probe-certified stall path — so a lost completion cannot + * latch the guard. + */ function engageReplayGuard( - replayingPanesRef: ReplayingPanesRef, + map: Map, paneId: number, - onReleased?: () => void + terminal: ReplayGuardWriteTarget, + stallCheckMs: number, + onRelease?: () => void ): () => void { - const map = replayingPanesRef.current map.set(paneId, (map.get(paneId) ?? 0) + 1) let released = false - const release = (): void => { + let timer: ReturnType | null = null + const release = (reason: 'parsed' | 'lost-completion' | 'wedged'): void => { if (released) { return } released = true + if (timer !== null) { + clearTimeout(timer) + timer = null + } const remaining = (map.get(paneId) ?? 1) - 1 if (remaining <= 0) { map.delete(paneId) } else { map.set(paneId, remaining) } - onReleased?.() + if (reason === 'lost-completion') { + console.error( + `[terminal] replay guard released for pane ${paneId} — the probe write parsed but the replay completion never arrived (lost write callback)` + ) + recordRendererCrashBreadcrumb('terminal_replay_guard_lost_completion', { paneId }) + } else if (reason === 'wedged') { + console.error( + `[terminal] replay guard released for pane ${paneId} — the probe write never parsed (wedged xterm write pipeline; pane likely needs recovery)` + ) + recordRendererCrashBreadcrumb('terminal_replay_guard_wedged_release', { paneId }) + } + onRelease?.() } - const fallback = setTimeout(release, REPLAY_GUARD_RELEASE_FALLBACK_MS) - return () => { - clearTimeout(fallback) - release() + const probeForStall = (): void => { + if (released) { + return + } + try { + // FIFO certification: this callback can only run after every replay + // byte queued before it has parsed (state 2 above). + terminal.write('', () => release('lost-completion')) + } catch { + // write threw (terminal disposed mid-replay): nothing will ever parse, + // so no auto-replies can leak. + release('wedged') + return + } + timer = setTimeout(() => release('wedged'), stallCheckMs) } + timer = setTimeout(probeForStall, stallCheckMs) + return () => release('parsed') } /** Writes `data` into the pane's terminal with the replay guard engaged, @@ -85,38 +129,50 @@ function engageReplayGuard( export function replayIntoTerminal( pane: ManagedPane, replayingPanesRef: ReplayingPanesRef, - data: string + data: string, + stallCheckMs: number = REPLAY_GUARD_STALL_CHECK_MS ): void { if (!data) { return } - const finishReplay = engageReplayGuard(replayingPanesRef, pane.id) + const releaseParsed = engageReplayGuard( + replayingPanesRef.current, + pane.id, + pane.terminal, + stallCheckMs + ) // Why: hidden/snapshot replay bypasses the live foreground write path, but // WebGL/canvas renderers still need a post-parse repaint to drop stale cells. writeForegroundTerminalChunk(pane.terminal, data, { forceViewportRefresh: true, followupViewportRefresh: true, - onParsed: finishReplay + onParsed: releaseParsed }) } export function replayIntoTerminalAsync( pane: ManagedPane, replayingPanesRef: ReplayingPanesRef, - data: string + data: string, + stallCheckMs: number = REPLAY_GUARD_STALL_CHECK_MS ): Promise { if (!data) { return Promise.resolve() } return new Promise((resolve) => { - // Why: settle the promise when the guard releases — via parse completion or - // the fallback — so an awaiting caller never hangs if xterm's callback for a - // just-mounted/offscreen pane never arrives. - const finishReplay = engageReplayGuard(replayingPanesRef, pane.id, resolve) + // Why resolve on either release path: callers await this to sequence + // restore steps; a lost write completion must not hang the restore chain. + const releaseParsed = engageReplayGuard( + replayingPanesRef.current, + pane.id, + pane.terminal, + stallCheckMs, + resolve + ) writeForegroundTerminalChunk(pane.terminal, data, { forceViewportRefresh: true, followupViewportRefresh: true, - onParsed: finishReplay + onParsed: releaseParsed }) }) } diff --git a/src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts b/src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts new file mode 100644 index 00000000000..7007995bd0d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts @@ -0,0 +1,157 @@ +// Why: pins the stale-occlusion proof — user input while +// document.visibilityState claims 'hidden' must latch the override and run +// each pane's recovery exactly once, and a genuine visibilitychange must hand +// authority back to the occlusion tracker. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as StaleDocumentVisibility from './stale-document-visibility' + +type Handler = () => void + +type EventTargetStub = { + addEventListener: (type: string, handler: Handler, options?: unknown) => void + removeEventListener: (type: string, handler: Handler, options?: unknown) => void + emit: (type: string) => void + listenerCount: (type: string) => number +} + +function createEventTargetStub(): EventTargetStub { + const listeners = new Map>() + return { + addEventListener(type, handler) { + const set = listeners.get(type) ?? new Set() + set.add(handler) + listeners.set(type, set) + }, + removeEventListener(type, handler) { + listeners.get(type)?.delete(handler) + }, + emit(type) { + for (const handler of listeners.get(type) ?? []) { + handler() + } + }, + listenerCount(type) { + return listeners.get(type)?.size ?? 0 + } + } +} + +type DocumentStub = EventTargetStub & { visibilityState: string } + +describe('stale document visibility', () => { + const originalDocument = (globalThis as { document?: unknown }).document + const originalWindow = (globalThis as { window?: unknown }).window + let documentStub: DocumentStub + let windowStub: EventTargetStub + let warnSpy: ReturnType + let mod: typeof StaleDocumentVisibility + + beforeEach(async () => { + vi.resetModules() + documentStub = { ...createEventTargetStub(), visibilityState: 'visible' } + windowStub = createEventTargetStub() + ;(globalThis as { document: unknown }).document = documentStub + ;(globalThis as { window: unknown }).window = windowStub + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mod = await import('./stale-document-visibility') + }) + + afterEach(() => { + mod.resetStaleDocumentVisibilityForTesting() + warnSpy.mockRestore() + ;(globalThis as { document: unknown }).document = originalDocument + ;(globalThis as { window: unknown }).window = originalWindow + }) + + it('latches the override and runs recovery once when input arrives while hidden', () => { + const recovery = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(recovery) + documentStub.visibilityState = 'hidden' + + documentStub.emit('keydown') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + expect(recovery).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledTimes(1) + + // Further input during the same stuck episode must not re-run recovery. + documentStub.emit('keydown') + documentStub.emit('pointerdown') + expect(recovery).toHaveBeenCalledTimes(1) + }) + + it('does not latch while the document is genuinely visible', () => { + const recovery = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(recovery) + + documentStub.emit('keydown') + documentStub.emit('pointerdown') + windowStub.emit('focus') + expect(mod.isDocumentVisibilityProvenStale()).toBe(false) + expect(recovery).not.toHaveBeenCalled() + }) + + it('treats pointerdown and window focus as staleness proof too', () => { + const recovery = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(recovery) + documentStub.visibilityState = 'hidden' + + documentStub.emit('pointerdown') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + + mod.resetStaleDocumentVisibilityForTesting() + mod.registerStaleDocumentVisibilityRecovery(recovery) + documentStub.visibilityState = 'hidden' + windowStub.emit('focus') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + }) + + it('hands authority back to the occlusion tracker on a genuine visibilitychange', () => { + const recovery = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(recovery) + documentStub.visibilityState = 'hidden' + documentStub.emit('keydown') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + + documentStub.visibilityState = 'visible' + documentStub.emit('visibilitychange') + expect(mod.isDocumentVisibilityProvenStale()).toBe(false) + + // A fresh wedge after trust was restored must latch (and recover) again. + documentStub.visibilityState = 'hidden' + documentStub.emit('keydown') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + expect(recovery).toHaveBeenCalledTimes(2) + }) + + it('keeps running other panes when one recovery listener throws', () => { + const throwing = vi.fn(() => { + throw new Error('pane exploded') + }) + const healthy = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(throwing) + mod.registerStaleDocumentVisibilityRecovery(healthy) + documentStub.visibilityState = 'hidden' + + documentStub.emit('keydown') + expect(throwing).toHaveBeenCalledTimes(1) + expect(healthy).toHaveBeenCalledTimes(1) + }) + + it('removes global listeners when the last pane unregisters', () => { + const unregisterA = mod.registerStaleDocumentVisibilityRecovery(vi.fn()) + const unregisterB = mod.registerStaleDocumentVisibilityRecovery(vi.fn()) + expect(documentStub.listenerCount('keydown')).toBe(1) + + unregisterA() + expect(documentStub.listenerCount('keydown')).toBe(1) + unregisterB() + expect(documentStub.listenerCount('keydown')).toBe(0) + expect(documentStub.listenerCount('pointerdown')).toBe(0) + expect(documentStub.listenerCount('visibilitychange')).toBe(0) + expect(windowStub.listenerCount('focus')).toBe(0) + + // Re-registering must reinstall for a later pane. + mod.registerStaleDocumentVisibilityRecovery(vi.fn()) + expect(documentStub.listenerCount('keydown')).toBe(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/stale-document-visibility.ts b/src/renderer/src/components/terminal-pane/stale-document-visibility.ts new file mode 100644 index 00000000000..8d54cbe94c6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/stale-document-visibility.ts @@ -0,0 +1,109 @@ +// Chromium's macOS window-occlusion tracker can wedge document.visibilityState +// at 'hidden' after display sleep, never firing another visibilitychange. Real +// user input reaching this window while the document claims hidden is a +// physical contradiction — keystrokes and clicks only reach a focused, +// on-screen window. This module latches that proof so terminal delivery can +// treat the document as visible until the next genuine visibilitychange +// restores trust in the occlusion tracker. Without it, the hidden-delivery +// gate stays latched for panes the user is looking at and main drops their +// bytes indefinitely (field: 78MB dropped on 2 visible ptys, v1.4.124-rc.2.perf). + +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' + +type StaleVisibilityRecoveryListener = () => void + +const recoveryListeners = new Set() +let visibilityProvenStale = false +let globalListenersInstalled = false + +export function isDocumentVisibilityProvenStale(): boolean { + return visibilityProvenStale +} + +function onUserInteractionWithDocument(): void { + if (visibilityProvenStale || document.visibilityState !== 'hidden') { + return + } + visibilityProvenStale = true + recordTerminalFreezeBreadcrumb('stale-visibility-latch', { + recoveryListenerCount: recoveryListeners.size + }) + console.warn( + '[terminal] user input arrived while document.visibilityState is hidden — treating occlusion state as stale and re-syncing terminal delivery', + { recoveryListenerCount: recoveryListeners.size } + ) + for (const listener of recoveryListeners) { + try { + listener() + } catch { + // Why: one pane's recovery failure must not starve the other panes'. + } + } +} + +function onDocumentVisibilityChange(): void { + recordTerminalFreezeBreadcrumb('visibilitychange', { + state: document.visibilityState, + clearedStaleOverride: visibilityProvenStale + }) + // A genuine visibilitychange means the occlusion tracker is reporting + // again — hand authority back to document.visibilityState. + visibilityProvenStale = false +} + +function installGlobalListeners(): void { + if ( + globalListenersInstalled || + typeof document === 'undefined' || + typeof window === 'undefined' || + typeof document.addEventListener !== 'function' + ) { + return + } + globalListenersInstalled = true + // Capture phase so no stopPropagation in the app can hide the proof; the + // handler is a single property read when visibility is healthy. + document.addEventListener('keydown', onUserInteractionWithDocument, { + capture: true, + passive: true + }) + document.addEventListener('pointerdown', onUserInteractionWithDocument, { + capture: true, + passive: true + }) + window.addEventListener('focus', onUserInteractionWithDocument) + document.addEventListener('visibilitychange', onDocumentVisibilityChange) +} + +function removeGlobalListeners(): void { + if (!globalListenersInstalled) { + return + } + globalListenersInstalled = false + document.removeEventListener('keydown', onUserInteractionWithDocument, { capture: true }) + document.removeEventListener('pointerdown', onUserInteractionWithDocument, { capture: true }) + window.removeEventListener('focus', onUserInteractionWithDocument) + document.removeEventListener('visibilitychange', onDocumentVisibilityChange) +} + +// The listener runs when staleness is first proven; register the same handler +// used for document visibilitychange so recovery reuses the pane's existing +// gate-resync + hidden-output-restore path. +export function registerStaleDocumentVisibilityRecovery( + listener: StaleVisibilityRecoveryListener +): () => void { + installGlobalListeners() + recoveryListeners.add(listener) + return () => { + recoveryListeners.delete(listener) + if (recoveryListeners.size === 0) { + removeGlobalListeners() + } + } +} + +export function resetStaleDocumentVisibilityForTesting(): void { + visibilityProvenStale = false + recoveryListeners.clear() + removeGlobalListeners() +} diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 863d4cc7725..b7e088ff4aa 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it, vi } from 'vitest' import { Terminal } from '@xterm/headless' -import type { ManagedPane } from '@/lib/pane-manager/pane-manager' +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import { getDefaultSettings } from '../../../../shared/constants' import { + applyTerminalAppearance, hexToRgba, installMode2031Handlers, maybePushMode2031Flip, - mode2031SequenceFor + mode2031SequenceFor, + publishTerminalViewAttributesAtAppStart } from './terminal-appearance' import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' +import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' function fakeTransport(overrides?: { connected?: boolean; sendOk?: boolean }): { isConnected: () => boolean @@ -373,6 +378,133 @@ describe('installMode2031Handlers', () => { }) }) +describe('applyTerminalAppearance theme assignment', () => { + // xterm's OptionsService fires the theme change on object IDENTITY, and + // ThemeService._setTheme then rebuilds the palette, discarding OSC + // 4/10/11/12 SET mutations. Attribute-neutral applies (font size, padding, + // zoom) compose a fresh-but-value-identical theme; assigning it anyway + // wipes TUI color mutations on visible panes while the deduped publisher + // keeps hidden overlays — so the assignment must be value-gated. + function makePane(id: number): ManagedPane { + return { id, terminal: { options: {}, cols: 80, rows: 24 } } as unknown as ManagedPane + } + + function makeManager(panes: ManagedPane[]): PaneManager { + return { + getPanes: () => panes, + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + } + + function apply(pane: ManagedPane, settings: ReturnType): void { + applyTerminalAppearance( + makeManager([pane]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + } + + it('keeps options.theme identity across attribute-neutral applies (font size tweak)', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, settings) + const firstTheme = pane.terminal.options.theme + expect(firstTheme).toBeDefined() + + apply(pane, { ...settings, terminalFontSize: settings.terminalFontSize + 2 }) + + // Identity-stable theme means xterm never re-runs _setTheme, so a TUI's + // modifyColors mutation survives the font tweak. + expect(pane.terminal.options.theme).toBe(firstTheme) + expect(pane.terminal.options.fontSize).toBe(settings.terminalFontSize + 2) + }) + + it('still assigns a fresh theme when composed values actually change', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, settings) + const firstTheme = pane.terminal.options.theme + + apply(pane, { ...settings, terminalColorOverrides: { background: '#102030' } }) + + expect(pane.terminal.options.theme).not.toBe(firstTheme) + expect(pane.terminal.options.theme?.background).toBe('#102030') + }) +}) + +describe('publishTerminalViewAttributesAtAppStart', () => { + // Phase 6 prerequisite (terminal-query-authority.md): hidden-at-launch + // PTYs can query OSC 10/11 before any terminal pane mounts; the app-start + // publication must go out with no pane manager involved at all. + it('publishes composed attributes without any pane mount and dedupes repeats', () => { + _resetTerminalViewAttributesPublisherForTest() + const sent: TerminalViewAttributes[] = [] + const send = (attributes: TerminalViewAttributes): boolean => { + sent.push(attributes) + return true + } + const settings = getDefaultSettings('/tmp') + + expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(true) + expect(sent).toHaveLength(1) + expect(sent[0]!.ansi).toHaveLength(256) + expect(sent[0]!.cursorStyle).toBe(settings.terminalCursorStyle ?? 'block') + + expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(false) + expect(sent).toHaveLength(1) + }) + + it('makes the later pane-mount applyTerminalAppearance a deduped no-op re-push', () => { + _resetTerminalViewAttributesPublisherForTest() + const publishMock = vi.fn() + ;(globalThis as unknown as { window: unknown }).window = { + api: { pty: { publishTerminalViewAttributes: publishMock } } + } + try { + const settings = getDefaultSettings('/tmp') + publishTerminalViewAttributesAtAppStart(settings, true) + expect(publishMock).toHaveBeenCalledTimes(1) + + // The first pane mount composes the identical app-global snapshot, so + // the publisher dedupe keeps it a single push. + const manager = { + getPanes: () => [], + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + applyTerminalAppearance( + manager, + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publishMock).toHaveBeenCalledTimes(1) + } finally { + delete (globalThis as { window?: unknown }).window + _resetTerminalViewAttributesPublisherForTest() + } + }) + + it('publishes nothing before settings are loaded', () => { + _resetTerminalViewAttributesPublisherForTest() + const send = vi.fn(() => true) + expect(publishTerminalViewAttributesAtAppStart(null, true, send)).toBe(false) + expect(send).not.toHaveBeenCalled() + }) +}) + describe('hexToRgba', () => { it('converts 6-char hex to rgba', () => { expect(hexToRgba('#1a1a1a', 0.72)).toBe('rgba(26, 26, 26, 0.72)') diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index 2df2f261506..a34ac215e66 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -10,6 +10,7 @@ import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' +import { guardParserHandler } from './terminal-parser-handler-guard' import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' import { normalizeTerminalFastScrollSensitivity, @@ -20,6 +21,8 @@ import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' import type { PtyTransport } from './pty-transport' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { HEX_COLOR_RE } from '../../../../shared/color-validation' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher' import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' export { mode2031SequenceFor } @@ -56,50 +59,56 @@ export function installMode2031Handlers(deps: Mode2031HandlerDeps): IDisposable[ // continue processing the same sequence, so compound sequences like // `CSI ?25;2031h` still update cursor visibility correctly. return [ - deps.parser.registerCsiHandler({ prefix: '?', final: 'h' }, (params) => { - if (hasMode2031(params)) { - // Why: a restored xterm buffer may contain `CSI ?2031h` emitted by - // the previous session's TUI (e.g. Claude Code). Replaying that - // buffer runs this handler, and without the guard we'd push - // `CSI ?997;1n` via transport.sendInput into a fresh shell that has - // no TUI consuming it — zsh then echoes the literal escape sequence - // onto the prompt. The replay guard in pty-connection.ts only covers - // xterm's own onData auto-replies, not handler-triggered sends, so - // gate explicitly here. We also skip recording the subscribe bit: - // the fresh shell is not actually subscribed, so a later theme flip - // must not push either. A real TUI that starts up after restore will - // re-emit `?2031h` itself and register normally. - // - // Why this broad guard is safe across all replay sources: the only - // replay path that can carry raw `?2031h` is cold-restore scrollback - // (pty-connection.ts), which is disk-replayed PTY output against a - // fresh shell — the case this guard targets. Daemon snapshot payloads - // (`rehydrateSequences + SerializeAddon.serialize()`) and persisted - // scrollback (`SerializeAddon.serialize()`) never contain `?2031`: - // SerializeAddon's _serializeModes whitelists only ?1h/?66h/?2004h/ - // [4h/?6h/?45h/?1004h/?7l/mouse modes/?25l, and buildRehydrateSequences - // emits only ?1049h/?2004h/?1h/mouse reporting modes. If xterm ever - // adds ?2031 to that whitelist, this guard would start suppressing - // legitimate subscribes during snapshot reattach — revisit then. - if (deps.isReplaying()) { - return false + deps.parser.registerCsiHandler( + { prefix: '?', final: 'h' }, + guardParserHandler('csi-mode2031-subscribe', (params) => { + if (hasMode2031(params)) { + // Why: a restored xterm buffer may contain `CSI ?2031h` emitted by + // the previous session's TUI (e.g. Claude Code). Replaying that + // buffer runs this handler, and without the guard we'd push + // `CSI ?997;1n` via transport.sendInput into a fresh shell that has + // no TUI consuming it — zsh then echoes the literal escape sequence + // onto the prompt. The replay guard in pty-connection.ts only covers + // xterm's own onData auto-replies, not handler-triggered sends, so + // gate explicitly here. We also skip recording the subscribe bit: + // the fresh shell is not actually subscribed, so a later theme flip + // must not push either. A real TUI that starts up after restore will + // re-emit `?2031h` itself and register normally. + // + // Why this broad guard is safe across all replay sources: the only + // replay path that can carry raw `?2031h` is cold-restore scrollback + // (pty-connection.ts), which is disk-replayed PTY output against a + // fresh shell — the case this guard targets. Daemon snapshot payloads + // (`rehydrateSequences + SerializeAddon.serialize()`) and persisted + // scrollback (`SerializeAddon.serialize()`) never contain `?2031`: + // SerializeAddon's _serializeModes whitelists only ?1h/?66h/?2004h/ + // [4h/?6h/?45h/?1004h/?7l/mouse modes/?25l, and buildRehydrateSequences + // emits only ?1049h/?2004h/?1h/mouse reporting modes. If xterm ever + // adds ?2031 to that whitelist, this guard would start suppressing + // legitimate subscribes during snapshot reattach — revisit then. + if (deps.isReplaying()) { + return false + } + deps.paneMode2031.set(deps.paneId, true) + deps.onSubscribe() } - deps.paneMode2031.set(deps.paneId, true) - deps.onSubscribe() - } - return false - }), + return false + }) + ), // Why no replay guard on the unsubscribe branch: clearing stale bookkeeping // is harmless. We only push CSI 997 on subscribe, never on unsubscribe, so // even if a cold-restore replay carries `?2031l`, this handler just deletes // map entries that a later real `?2031h` will re-populate normally. - deps.parser.registerCsiHandler({ prefix: '?', final: 'l' }, (params) => { - if (hasMode2031(params)) { - deps.paneMode2031.delete(deps.paneId) - deps.paneLastThemeMode.delete(deps.paneId) - } - return false - }) + deps.parser.registerCsiHandler( + { prefix: '?', final: 'l' }, + guardParserHandler('csi-mode2031-unsubscribe', (params) => { + if (hasMode2031(params)) { + deps.paneMode2031.delete(deps.paneId) + deps.paneLastThemeMode.delete(deps.paneId) + } + return false + }) + ) ] } @@ -200,6 +209,54 @@ export function composeActiveTerminalTheme( return theme } +/** App-start publication (terminal-query-authority.md §Phase 6 + * prerequisites): hidden-at-launch PTYs can query OSC 10/11 before any + * terminal pane mounts, and main's responder is silent-until-first-push. + * Composes the same theme applyTerminalAppearance would and publishes it + * through the same deduped publisher, so the later pane-mount apply is a + * no-op re-push. Returns whether a publish actually went out. */ +export function publishTerminalViewAttributesAtAppStart( + settings: GlobalSettings | null | undefined, + systemPrefersDark: boolean, + send?: (attributes: TerminalViewAttributes) => boolean +): boolean { + if (!settings) { + return false + } + const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark) + const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName) + const theme = composeActiveTerminalTheme(baseTheme, settings) + return send !== undefined + ? publishTerminalViewAttributes(theme, appearance.mode, settings, send) + : publishTerminalViewAttributes(theme, appearance.mode, settings) +} + +// Value equality over composed ITheme objects (flat string slots plus the +// extendedAnsi string array), used to gate the per-pane options.theme write. +function composedTerminalThemesEqual(a: ITheme | undefined, b: ITheme): boolean { + if (!a) { + return false + } + if (a === b) { + return true + } + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) + for (const key of keys) { + if (key === 'extendedAnsi') { + continue + } + if (a[key as keyof ITheme] !== b[key as keyof ITheme]) { + return false + } + } + const extA = a.extendedAnsi + const extB = b.extendedAnsi + if (!extA || !extB) { + return extA === extB + } + return extA.length === extB.length && extA.every((value, i) => value === extB[i]) +} + export function applyTerminalAppearance( manager: PaneManager, settings: GlobalSettings, @@ -214,6 +271,11 @@ export function applyTerminalAppearance( const paneStyles = resolvePaneStyleOptions(settings) const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName) const theme = composeActiveTerminalTheme(baseTheme, settings) + // View-attribute bridge (Phase 5 slice 2): this is the single point where + // the composed app-global terminal appearance exists, so publish it to + // main's hidden-PTY query responder here. Deduped inside the publisher — + // per-pane re-applies and attribute-neutral tweaks do not re-push. + publishTerminalViewAttributes(theme, appearance.mode, settings) const paneBackground = theme?.background ?? '#000000' const terminalFontWeights = resolveTerminalFontWeights(settings.terminalFontWeight) @@ -223,7 +285,14 @@ export function applyTerminalAppearance( ) for (const pane of manager.getPanes()) { - if (theme) { + // Why value-gated: xterm's OptionsService fires on object identity, and + // ThemeService._setTheme rebuilds the palette, discarding TUI OSC + // 4/10/11/12 SET mutations. Attribute-neutral applies (font size/family, + // line height, padding, per-pane zoom) compose a fresh-but-identical + // theme; skipping the write keeps visible-pane mutations alive (a + // pre-existing loss this also fixes) and matches the hidden responder's + // deduped overlay behavior, so hidden and visible no longer drift. + if (theme && !composedTerminalThemesEqual(pane.terminal.options.theme, theme)) { pane.terminal.options.theme = theme } // Why: xterm's allowTransparency has measurable rendering cost, so clear diff --git a/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts b/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts index 8308fc8b210..37353c7727c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts +++ b/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts @@ -5,6 +5,7 @@ import { terminalOscColorQuerySlotsForBody, type TerminalOscColorQuerySlot } from '../../../../shared/terminal-osc-color-reply' +import { guardParserHandler } from './terminal-parser-handler-guard' export const DEFAULT_DA1_RESPONSE = '\x1b[?1;2c' export const CONPTY_DA1_RESPONSE = '\x1b[?61;4c' @@ -118,37 +119,46 @@ export function installTerminalCapabilityReplyHandlers( deps: TerminalCapabilityRepliesDeps ): IDisposable { const disposables = [ - deps.parser.registerCsiHandler({ final: 'c' }, (params) => { - if (!isPrimaryDeviceAttributesQuery(params)) { - return false - } - // Why: restored scrollback may contain old DA1 queries; answering those - // into the fresh shell recreates the stray-input leak this handler fixes. - if (!deps.isReplaying()) { - deps.sendInput(deps.da1Response ?? DEFAULT_DA1_RESPONSE) - } - return true - }), - deps.parser.registerOscHandler(10, (data) => { - const slots = terminalOscColorQuerySlotsForBody(10, data.trim()) - if (!slots) { - return false - } - if (deps.isReplaying()) { + deps.parser.registerCsiHandler( + { final: 'c' }, + guardParserHandler('csi-da1', (params) => { + if (!isPrimaryDeviceAttributesQuery(params)) { + return false + } + // Why: restored scrollback may contain old DA1 queries; answering those + // into the fresh shell recreates the stray-input leak this handler fixes. + if (!deps.isReplaying()) { + deps.sendInput(deps.da1Response ?? DEFAULT_DA1_RESPONSE) + } return true - } - return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) - }), - deps.parser.registerOscHandler(11, (data) => { - const slots = terminalOscColorQuerySlotsForBody(11, data.trim()) - if (!slots) { - return false - } - if (deps.isReplaying()) { - return true - } - return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) - }) + }) + ), + deps.parser.registerOscHandler( + 10, + guardParserHandler('osc-10-color-query', (data) => { + const slots = terminalOscColorQuerySlotsForBody(10, data.trim()) + if (!slots) { + return false + } + if (deps.isReplaying()) { + return true + } + return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) + }) + ), + deps.parser.registerOscHandler( + 11, + guardParserHandler('osc-11-color-query', (data) => { + const slots = terminalOscColorQuerySlotsForBody(11, data.trim()) + if (!slots) { + return false + } + if (deps.isReplaying()) { + return true + } + return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) + }) + ) ] return { diff --git a/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts index 41873fa4fd9..61ad0891fa6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts @@ -1,4 +1,5 @@ import type { Terminal, IDisposable } from '@xterm/xterm' +import { createOsc133CommandFinishedScanner } from '../../../../shared/terminal-osc133-command-finished' type TerminalCommandLifecycleOptions = { onCommandFinished: (bestEffortExitCode: number | null) => void @@ -6,100 +7,31 @@ type TerminalCommandLifecycleOptions = { onCommandStarted?: () => void } -type OscTerminator = { - index: number - length: number -} - -const OSC_133_PREFIX = '\x1b]133;' -const MAX_OSC_CARRY_LENGTH = 4096 - -function findOscTerminator(data: string, startIndex: number): OscTerminator | null { - const bel = data.indexOf('\x07', startIndex) - const st = data.indexOf('\x1b\\', startIndex) - - if (bel === -1 && st === -1) { - return null - } - if (bel !== -1 && (st === -1 || bel < st)) { - return { index: bel, length: 1 } - } - return { index: st, length: 2 } -} - -function parseBestEffortExitCode(value: string | undefined): number | null { - if (!value) { - return null - } - const parsed = Number.parseInt(value, 10) - return Number.isNaN(parsed) ? null : parsed -} - -function findPrefixCarry(data: string): string { - const maxCarryLength = Math.min(data.length, OSC_133_PREFIX.length - 1) - for (let length = maxCarryLength; length > 0; length -= 1) { - const suffix = data.slice(data.length - length) - if (OSC_133_PREFIX.startsWith(suffix)) { - return suffix - } - } - return '' -} - export function createTerminalCommandLifecycle(options: TerminalCommandLifecycleOptions): { handlePtyData: (data: string) => void attachXtermConsumer: (terminal: Terminal) => IDisposable dispose: () => void } { - let carry = '' + // Why: the byte parsing lives in shared so main's side-effect tracker emits + // identical command-finished facts for local/SSH PTYs; this renderer wrapper + // remains the byte path for remote-runtime PTYs and the kill-switch-off mode. + const scanner = createOsc133CommandFinishedScanner( + options.onCommandFinished, + options.onCommandStarted + ) const disposables: IDisposable[] = [] - const handleOsc133 = (payload: string): void => { - const [sequence, exitCode] = payload.split(';') - if (sequence === 'C') { - options.onCommandStarted?.() - return - } - if (sequence === 'D') { - options.onCommandFinished(parseBestEffortExitCode(exitCode)) - } - } - - const handlePtyData = (data: string): void => { - let combined = carry + data - carry = '' - - while (combined.length > 0) { - const start = combined.indexOf(OSC_133_PREFIX) - if (start === -1) { - carry = findPrefixCarry(combined) - return - } - - const payloadStart = start + OSC_133_PREFIX.length - const terminator = findOscTerminator(combined, payloadStart) - if (!terminator) { - carry = combined.slice(start) - if (carry.length > MAX_OSC_CARRY_LENGTH) { - carry = carry.slice(carry.length - MAX_OSC_CARRY_LENGTH) - } - return - } - - handleOsc133(combined.slice(payloadStart, terminator.index)) - combined = combined.slice(terminator.index + terminator.length) - } - } - return { - handlePtyData, + handlePtyData: scanner.scan, attachXtermConsumer(terminal) { + // Why: swallow OSC 133 so shell-integration markers never paint — + // rendering hygiene that applies regardless of side-effect authority. const disposable = terminal.parser.registerOscHandler(133, () => true) disposables.push(disposable) return disposable }, dispose() { - carry = '' + scanner.reset() for (const disposable of disposables.splice(0)) { disposable.dispose() } diff --git a/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts b/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts new file mode 100644 index 00000000000..8db2051fa35 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts @@ -0,0 +1,201 @@ +// Why: pins the renderer half of the dead-push-delivery recovery — the +// watchdog must cost nothing while output flows, confirm a wedge across two +// silent ticks against main's invoke-reported state, then heal exactly once +// per cooldown: re-attach push listeners, request the write-off, and route +// the pulled restore markers to pane handlers locally. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PtyRendererDeliveryHealthReply } from '../../../../shared/pty-renderer-delivery-health' + +vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: false } })) + +const INTERVAL_MS = 15_000 + +const HEALTHY: PtyRendererDeliveryHealthReply = { + inFlightTotalChars: 0, + inFlightPtyCount: 0, + msSinceLastAck: 1_000 +} + +const STALLED: PtyRendererDeliveryHealthReply = { + inFlightTotalChars: 512 * 1024, + inFlightPtyCount: 2, + msSinceLastAck: null +} + +describe('terminal delivery watchdog', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + const reportMock = vi.fn<(args: unknown) => Promise>() + const listenerCountMock = vi.fn(() => 1) + const reattachMock = vi.fn() + let warnSpy: ReturnType + + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + reportMock.mockReset() + listenerCountMock.mockClear() + reattachMock.mockClear() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + reportRendererDeliveryState: reportMock, + getPtyDataListenerCount: listenerCountMock + } + } + } as unknown as typeof window + }) + + afterEach(() => { + warnSpy.mockRestore() + vi.useRealTimers() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + async function startWatchdog(): Promise<{ + recordPtyDataReceived: (ptyId: string, chars: number) => void + registerRestoreHandler: ( + ptyId: string, + handler: (event: { id: string; reason: string; markerSeq?: number }) => void + ) => void + }> { + const watchdog = await import('./terminal-delivery-watchdog') + const restoreChannel = await import('./pty-model-restore-channel') + watchdog.startTerminalDeliveryWatchdog({ + reattachPushListeners: reattachMock, + hasAttachedPtys: () => true + }) + return { + recordPtyDataReceived: watchdog.recordPtyDataReceived, + registerRestoreHandler: (ptyId, handler) => { + restoreChannel.registerPtyModelRestoreNeededHandler(ptyId, handler) + } + } + } + + it('does zero IPC while pty output is flowing', async () => { + const { recordPtyDataReceived } = await startWatchdog() + + for (let tick = 0; tick < 8; tick++) { + recordPtyDataReceived('pty-1', 64) + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + } + + expect(reportMock).not.toHaveBeenCalled() + }) + + it('reports during silence but never heals a healthy-idle main', async () => { + reportMock.mockResolvedValue(HEALTHY) + await startWatchdog() + + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 4) + + expect(reportMock).toHaveBeenCalledTimes(4) + for (const call of reportMock.mock.calls) { + expect((call[0] as { heal?: boolean }).heal).toBeUndefined() + } + expect(reattachMock).not.toHaveBeenCalled() + }) + + it('confirms a wedge across two silent ticks, re-attaches, and routes pulled restore markers', async () => { + reportMock.mockImplementation((args) => + Promise.resolve( + (args as { heal?: boolean }).heal + ? { + ...STALLED, + inFlightTotalChars: 0, + inFlightPtyCount: 0, + writtenOff: [{ id: 'pty-1', markerSeq: 42, writtenOffChars: 512 * 1024 }] + } + : STALLED + ) + ) + const { recordPtyDataReceived, registerRestoreHandler } = await startWatchdog() + const restoreEvents: { id: string; reason: string; markerSeq?: number }[] = [] + registerRestoreHandler('pty-1', (event) => restoreEvents.push(event)) + + // Bytes flowed once, then the push channel died: the field shape. + recordPtyDataReceived('pty-1', 128) + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(reportMock).not.toHaveBeenCalled() + + // First silent tick: report only, no heal yet. + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(reportMock).toHaveBeenCalledTimes(1) + expect(reattachMock).not.toHaveBeenCalled() + + // Second silent tick confirms: re-attach precedes the heal report, the + // listener count is captured for field discrimination, and the pulled + // marker reaches the pane handler without any push event. + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(reattachMock).toHaveBeenCalledTimes(1) + const healCalls = reportMock.mock.calls.filter( + (call) => (call[0] as { heal?: boolean }).heal === true + ) + expect(healCalls).toHaveLength(1) + expect(healCalls[0]![0]).toMatchObject({ + heal: true, + rendererPtyDataListenerCount: 1, + receivedCharsByPty: { 'pty-1': 128 } + }) + expect(restoreEvents).toEqual([{ id: 'pty-1', reason: 'delivery-heal', markerSeq: 42 }]) + }) + + it('rate-limits heals to the cooldown while the wedge persists', async () => { + reportMock.mockImplementation((args) => + Promise.resolve((args as { heal?: boolean }).heal ? { ...STALLED, writtenOff: [] } : STALLED) + ) + await startWatchdog() + + const countHeals = (): number => + reportMock.mock.calls.filter((call) => (call[0] as { heal?: boolean }).heal === true).length + + // Ticks at 15s/30s: confirm + first heal. + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 2) + expect(countHeals()).toBe(1) + + // 45s/60s/75s: streak rebuilds but the 60s cooldown holds. + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 3) + expect(countHeals()).toBe(1) + + // 90s: cooldown elapsed — one more heal, not one per tick. + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(countHeals()).toBe(2) + expect(reattachMock).toHaveBeenCalledTimes(2) + }) + + it('stays off when no PTY expects push delivery', async () => { + reportMock.mockResolvedValue(STALLED) + const watchdog = await import('./terminal-delivery-watchdog') + watchdog.startTerminalDeliveryWatchdog({ + reattachPushListeners: reattachMock, + hasAttachedPtys: () => false + }) + + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 3) + + expect(reportMock).not.toHaveBeenCalled() + }) + + it('never starts without the invoke heal lane (web client, partial mocks)', async () => { + ;(window.api.pty as { reportRendererDeliveryState?: unknown }).reportRendererDeliveryState = + undefined + const watchdog = await import('./terminal-delivery-watchdog') + watchdog.startTerminalDeliveryWatchdog({ + reattachPushListeners: reattachMock, + hasAttachedPtys: () => true + }) + + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 3) + + expect(reattachMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.ts b/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.ts new file mode 100644 index 00000000000..c9b0d8c29e5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.ts @@ -0,0 +1,278 @@ +/** + * Renderer-initiated watchdog for dead main→renderer push delivery. + * + * Field evidence (v1.4.121-rc.0 snapshot, 2026-07-06): all `pty:data` push + * events stop reaching the renderer (a 245-char shell prompt sat un-ACKed; + * every terminal blank) while invoke IPC keeps working — the wedged window + * answered `getRendererDeliveryDebugSnapshot` live. The prior recovery layers + * cannot reach that state: the xterm write-pipeline guards and replay-guard + * release (#7150) run only after bytes arrive, and the cumulative-ACK + + * solicited-resync protocol heals lost ACKs but probes over the same push + * channel that is dead (upstream precedent: electron#37067, one-directional + * Mojo IPC death). This watchdog is the missing lane: it detects the wedge + * and heals over invoke — the direction proven alive — with zero cost on the + * data hot path (one Map upsert per received chunk; a tick does no IPC while + * output flows or while no PTY delivery is expected). + */ +import { e2eConfig } from '@/lib/e2e-config' +import type { PtyRendererDeliveryHealthReply } from '../../../../shared/pty-renderer-delivery-health' +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import { deliverPulledPtyModelRestoreMarkers } from './pty-model-restore-channel' +import { getProcessedPtyCharTotals } from './terminal-pty-ack-gate' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' + +const WATCHDOG_INTERVAL_MS = 15_000 +// Why 2 ticks: one silent interval can be a probe racing an in-transit chunk; +// two full intervals with zero received events while main reports ACK-starved +// in-flight bytes only occurs in the wedged state. +const WATCHDOG_STALL_TICKS_TO_HEAL = 2 +// Why: a heal that could not revive the push channel must not repaint-storm; +// pull-restores repeat at most once per cooldown while the wedge persists. +const WATCHDOG_HEAL_COOLDOWN_MS = 60_000 + +type TerminalDeliveryWatchdogConfig = { + intervalMs: number + stallTicksToHeal: number + healCooldownMs: number +} + +type TerminalDeliveryWatchdogDeps = { + /** Detach and re-subscribe the dispatcher's push-channel listeners. */ + reattachPushListeners: () => void + /** True while any PTY handler or eager buffer expects push delivery. */ + hasAttachedPtys: () => boolean +} + +const receivedPtyCharTotals = new Map() +let receivedPtyDataEventCount = 0 +let blackholePtyPushDelivery = false + +let watchdogDeps: TerminalDeliveryWatchdogDeps | null = null +let watchdogTimer: ReturnType | null = null +let watchdogConfig: TerminalDeliveryWatchdogConfig = { + intervalMs: WATCHDOG_INTERVAL_MS, + stallTicksToHeal: WATCHDOG_STALL_TICKS_TO_HEAL, + healCooldownMs: WATCHDOG_HEAL_COOLDOWN_MS +} +let eventCountAtLastTick = 0 +let stallStreakTicks = 0 +let lastHealAtMs: number | null = null +let healCount = 0 +let tickInFlight = false + +/** One Map upsert per received chunk — the watchdog's only hot-path cost. + * Counted at dispatcher enqueue, BEFORE parse-deferred ACK crediting, so + * main can tell "lost in the channel" from "received, parse-pending". */ +export function recordPtyDataReceived(ptyId: string, chars: number): void { + receivedPtyDataEventCount += 1 + receivedPtyCharTotals.set(ptyId, (receivedPtyCharTotals.get(ptyId) ?? 0) + chars) +} + +export function clearReceivedPtyCharTotal(ptyId: string): void { + receivedPtyCharTotals.delete(ptyId) +} + +/** E2e blackhole: simulates the field wedge (push events vanish before the + * dispatcher sees them, no receive count, no ACK). Never true in prod. */ +export function isPtyPushDeliveryBlackholed(): boolean { + return blackholePtyPushDelivery +} + +function isMainDeliveryStalled(health: PtyRendererDeliveryHealthReply): boolean { + // Why msSinceLastAck may be null: a wedged-from-first-byte session (the + // field case's brand-new terminal) never ACKs; in-flight debt alone is the + // signal then. A recent ACK means some pty still round-trips — not a wedge. + return ( + health.inFlightTotalChars > 0 && + (health.msSinceLastAck === null || health.msSinceLastAck >= watchdogConfig.intervalMs) + ) +} + +async function runWatchdogTick(): Promise { + const deps = watchdogDeps + const report = window.api?.pty?.reportRendererDeliveryState + if (!deps || typeof report !== 'function') { + stopTerminalDeliveryWatchdog() + return + } + if (receivedPtyDataEventCount !== eventCountAtLastTick) { + eventCountAtLastTick = receivedPtyDataEventCount + stallStreakTicks = 0 + return + } + if (!deps.hasAttachedPtys()) { + stallStreakTicks = 0 + return + } + const health = await report({ + receivedCharsByPty: Object.fromEntries(receivedPtyCharTotals), + processedCharsByPty: getProcessedPtyCharTotals() + }) + if (!health || !isMainDeliveryStalled(health)) { + stallStreakTicks = 0 + return + } + stallStreakTicks += 1 + recordTerminalFreezeBreadcrumb('watchdog-stall', { + stallStreakTicks, + inFlightTotalChars: health.inFlightTotalChars, + msSinceLastAck: health.msSinceLastAck + }) + if (stallStreakTicks < watchdogConfig.stallTicksToHeal) { + return + } + if (lastHealAtMs !== null && Date.now() - lastHealAtMs < watchdogConfig.healCooldownMs) { + return + } + await healDeadPushDelivery(deps, report, health) +} + +async function healDeadPushDelivery( + deps: TerminalDeliveryWatchdogDeps, + report: NonNullable, + stalled: PtyRendererDeliveryHealthReply +): Promise { + lastHealAtMs = Date.now() + stallStreakTicks = 0 + healCount += 1 + // Why read BEFORE re-attach: 0 here = the listener was detached (app-level + // bug to hunt); ≥1 = events are being dropped below the emitter (channel + // dead, platform-level). The single most valuable field discriminator. + const listenerCountBeforeReattach = window.api?.pty?.getPtyDataListenerCount?.() ?? null + deps.reattachPushListeners() + const healed = await report({ + receivedCharsByPty: Object.fromEntries(receivedPtyCharTotals), + processedCharsByPty: getProcessedPtyCharTotals(), + heal: true, + rendererPtyDataListenerCount: listenerCountBeforeReattach + }) + const writtenOff = healed?.writtenOff ?? [] + if (writtenOff.length > 0) { + deliverPulledPtyModelRestoreMarkers( + writtenOff.map((entry) => ({ + id: entry.id, + reason: 'delivery-heal' as const, + ...(typeof entry.markerSeq === 'number' ? { markerSeq: entry.markerSeq } : {}) + })) + ) + } + recordTerminalFreezeBreadcrumb('watchdog-heal', { + listenerCountBeforeReattach, + writtenOffPtyCount: writtenOff.length, + writtenOffChars: writtenOff.reduce((sum, entry) => sum + entry.writtenOffChars, 0) + }) + console.warn('[terminal] delivery watchdog healed dead push delivery', { + listenerCountBeforeReattach, + stalledInFlightChars: stalled.inFlightTotalChars, + stalledPtyCount: stalled.inFlightPtyCount, + msSinceLastAck: stalled.msSinceLastAck, + writtenOffPtyCount: writtenOff.length, + writtenOffChars: writtenOff.reduce((sum, entry) => sum + entry.writtenOffChars, 0), + healCount + }) +} + +function scheduleWatchdogTimer(): void { + if (watchdogTimer) { + clearInterval(watchdogTimer) + } + watchdogTimer = setInterval(() => { + // Why serialized: a heal awaits two invokes; overlapping ticks could + // double-heal inside one cooldown window. + if (tickInFlight) { + return + } + tickInFlight = true + void runWatchdogTick().finally(() => { + tickInFlight = false + }) + }, watchdogConfig.intervalMs) +} + +export function startTerminalDeliveryWatchdog(deps: TerminalDeliveryWatchdogDeps): void { + if (watchdogDeps) { + return + } + // Why gated on the invoke fn: the web remote client and unit tests expose a + // partial pty API; without the report lane the watchdog has no safe heal + // path and must stay off. + if (typeof window.api?.pty?.reportRendererDeliveryState !== 'function') { + return + } + watchdogDeps = deps + eventCountAtLastTick = receivedPtyDataEventCount + scheduleWatchdogTimer() + exposeE2eTerminalDeliveryWatchdog() +} + +export function stopTerminalDeliveryWatchdog(): void { + watchdogDeps = null + if (watchdogTimer) { + clearInterval(watchdogTimer) + watchdogTimer = null + } +} + +/** Prod-reachable state for the one-paste freeze report (ids redacted). */ +export function getTerminalDeliveryWatchdogDiagnostics(): { + running: boolean + receivedPtyDataEventCount: number + receivedCharsByPty: Record + stallStreakTicks: number + healCount: number + msSinceLastHeal: number | null +} { + const receivedCharsByPty: Record = {} + for (const [id, chars] of receivedPtyCharTotals) { + receivedCharsByPty[redactPtyIdForDiagnostics(id)] = chars + } + return { + running: watchdogTimer !== null, + receivedPtyDataEventCount, + receivedCharsByPty, + stallStreakTicks, + healCount, + msSinceLastHeal: lastHealAtMs === null ? null : Date.now() - lastHealAtMs + } +} + +// ─── E2e control surface ───────────────────────────────────────────── + +type E2eTerminalDeliveryWatchdogApi = { + blackhole: (on: boolean) => void + configure: (config: Partial) => void + snapshot: () => { + receivedPtyDataEventCount: number + stallStreakTicks: number + healCount: number + blackholed: boolean + } +} + +type E2eTerminalDeliveryWatchdogWindow = Window & { + __terminalDeliveryWatchdog?: E2eTerminalDeliveryWatchdogApi +} + +function exposeE2eTerminalDeliveryWatchdog(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + const target = window as E2eTerminalDeliveryWatchdogWindow + target.__terminalDeliveryWatchdog ??= { + blackhole: (on) => { + blackholePtyPushDelivery = on + }, + configure: (config) => { + watchdogConfig = { ...watchdogConfig, ...config } + if (watchdogDeps) { + scheduleWatchdogTimer() + } + }, + snapshot: () => ({ + receivedPtyDataEventCount, + stallStreakTicks, + healCount, + blackholed: blackholePtyPushDelivery + }) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts b/src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts new file mode 100644 index 00000000000..042d261450f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts @@ -0,0 +1,34 @@ +// Renderer half of the one-paste freeze report: a bounded ring of the +// delivery-affecting transitions (gate marks, visibility trust changes, +// watchdog heals, restore markers) so a field report carries the history +// that led to the frozen state, not just a point-in-time counter snapshot. +import { + type PtyDeliveryBreadcrumb, + createPtyDeliveryBreadcrumbRing +} from '../../../../shared/pty-delivery-diagnostics' +import { setTerminalWebglDiagnosticRecorder } from '../../../../shared/terminal-webgl-diagnostics' + +const rendererDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing() + +export function recordTerminalFreezeBreadcrumb( + kind: string, + detail?: PtyDeliveryBreadcrumb['detail'] +): void { + rendererDeliveryBreadcrumbs.record(kind, detail) +} + +// Why: lib-layer WebGL code (pane-webgl-renderer, the atlas registry) can't +// import this components-layer ring directly, so it records through a shared +// sink. Point that sink at the same ring here so context-loss and atlas-reset +// crumbs land in the one-paste report alongside delivery/visibility history. +setTerminalWebglDiagnosticRecorder((kind, detail) => + rendererDeliveryBreadcrumbs.record(kind, detail) +) + +export function getTerminalFreezeBreadcrumbs(): PtyDeliveryBreadcrumb[] { + return rendererDeliveryBreadcrumbs.snapshot() +} + +export function resetTerminalFreezeBreadcrumbsForTesting(): void { + rendererDeliveryBreadcrumbs.reset() +} diff --git a/src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts b/src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts new file mode 100644 index 00000000000..dd0cbc98c0d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts @@ -0,0 +1,79 @@ +// Why: pins the one-paste freeze report contract — a single console call must +// return renderer state + main snapshot + breadcrumbs, and a dead/throwing +// invoke channel must be captured as data instead of sinking the report. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: false } })) + +describe('terminal freeze report', () => { + const originalWindow = (globalThis as { window?: unknown }).window + const originalDocument = (globalThis as { document?: unknown }).document + const snapshotMock = vi.fn() + const listenerCountMock = vi.fn(() => 1) + + beforeEach(() => { + vi.resetModules() + snapshotMock.mockReset() + listenerCountMock.mockClear() + ;(globalThis as { document: unknown }).document = { + visibilityState: 'hidden', + hasFocus: () => true, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } + ;(globalThis as { window: unknown }).window = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + api: { + pty: { + getRendererDeliveryDebugSnapshot: snapshotMock, + getPtyDataListenerCount: listenerCountMock + } + } + } + }) + + afterEach(() => { + ;(globalThis as { window: unknown }).window = originalWindow + ;(globalThis as { document: unknown }).document = originalDocument + }) + + it('assembles renderer state, breadcrumbs, and the main snapshot into one blob', async () => { + snapshotMock.mockResolvedValue({ pendingPtyCount: 0, diagnostics: { perPty: [] } }) + const breadcrumbs = await import('./terminal-freeze-breadcrumbs') + breadcrumbs.recordTerminalFreezeBreadcrumb('gate-mark', { id: '…@@abc' }) + const { buildTerminalFreezeReport } = await import('./terminal-freeze-report') + + const report = await buildTerminalFreezeReport() + + expect(report.renderer.documentVisibilityState).toBe('hidden') + expect(report.renderer.documentHasFocus).toBe(true) + expect(report.renderer.ptyDataListenerCount).toBe(1) + expect(report.renderer.breadcrumbs.map((crumb) => crumb.kind)).toContain('gate-mark') + expect(report.renderer.watchdog).toMatchObject({ running: false, healCount: 0 }) + expect(report.main).toMatchObject({ pendingPtyCount: 0 }) + expect(typeof report.capturedAt).toBe('string') + }) + + it('captures a failing invoke channel as data instead of throwing', async () => { + snapshotMock.mockRejectedValue(new Error('invoke dead')) + const { buildTerminalFreezeReport } = await import('./terminal-freeze-report') + + const report = await buildTerminalFreezeReport() + + expect(report.main).toMatchObject({ snapshotError: expect.stringContaining('invoke dead') }) + }) + + it('installs the console-callable global exactly once per renderer', async () => { + snapshotMock.mockResolvedValue({ pendingPtyCount: 0 }) + const { installTerminalFreezeReport, buildTerminalFreezeReport } = + await import('./terminal-freeze-report') + installTerminalFreezeReport() + const installed = ( + globalThis.window as unknown as { + __orcaTerminalFreezeReport?: () => Promise + } + ).__orcaTerminalFreezeReport + expect(installed).toBe(buildTerminalFreezeReport) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-freeze-report.ts b/src/renderer/src/components/terminal-pane/terminal-freeze-report.ts new file mode 100644 index 00000000000..5c087827f9f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-freeze-report.ts @@ -0,0 +1,59 @@ +// One-paste freeze report: `await window.__orcaTerminalFreezeReport()` in the +// DevTools console of a frozen window returns renderer state, main state (with +// per-pty delivery table), and both processes' breadcrumb history in a single +// JSON blob. Assembled over invoke IPC — the direction proven alive in every +// field wedge observed — and installed in PROD builds, because the whole point +// is that an affected user never has to gather logs piecemeal again. +import { getTerminalFreezeBreadcrumbs } from './terminal-freeze-breadcrumbs' +import { getTerminalDeliveryWatchdogDiagnostics } from './terminal-delivery-watchdog' +import { isDocumentVisibilityProvenStale } from './stale-document-visibility' +import { getAllPaneRenderingDiagnostics } from '@/lib/pane-manager/pane-manager-registry' + +export type TerminalFreezeReport = { + capturedAt: string + renderer: { + documentVisibilityState: string | null + documentHasFocus: boolean | null + documentVisibilityProvenStale: boolean + ptyDataListenerCount: number | null + watchdog: ReturnType + // Why: per-pane WebGL state distinguishes a stale post-wake surface from a + // context-loss fallback — the missing signal for the garble-after-sleep class. + paneRendering: ReturnType + breadcrumbs: ReturnType + } + main: unknown +} + +export async function buildTerminalFreezeReport(): Promise { + const hasDocument = typeof document !== 'undefined' + // The main section must never sink the whole report — a dead invoke channel + // is itself a diagnostic worth capturing. + const main = await window.api?.pty + ?.getRendererDeliveryDebugSnapshot?.() + .catch((error: unknown) => ({ snapshotError: String(error) })) + return { + capturedAt: new Date().toISOString(), + renderer: { + documentVisibilityState: hasDocument ? document.visibilityState : null, + documentHasFocus: hasDocument ? document.hasFocus() : null, + documentVisibilityProvenStale: isDocumentVisibilityProvenStale(), + ptyDataListenerCount: window.api?.pty?.getPtyDataListenerCount?.() ?? null, + watchdog: getTerminalDeliveryWatchdogDiagnostics(), + paneRendering: getAllPaneRenderingDiagnostics(), + breadcrumbs: getTerminalFreezeBreadcrumbs() + }, + main: main ?? null + } +} + +type TerminalFreezeReportWindow = Window & { + __orcaTerminalFreezeReport?: () => Promise +} + +export function installTerminalFreezeReport(): void { + if (typeof window === 'undefined') { + return + } + ;(window as TerminalFreezeReportWindow).__orcaTerminalFreezeReport = buildTerminalFreezeReport +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts new file mode 100644 index 00000000000..8123b83a9e1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts @@ -0,0 +1,45 @@ +/** + * Renderer-side predicate for main's Phase-4 hidden PTY delivery gate. + * + * The gate only operates when main holds side-effect authority for the PTY + * (see isMainTerminalSideEffectAuthorityForPty) AND the gate-specific kill + * switch is on. Callers decide once at pane/watcher creation — the decision + * picks which mode-2031 responder is registered (byte sidecar vs fact reply), + * so it must never flip per chunk. + */ +import type { GlobalSettings } from '../../../../shared/types' + +// Why: cached once per session — the blocking read should only ever run on +// the pre-hydration startup path, never per pane bind. +let persistedGateFlagCache: boolean | null | undefined + +function readPersistedHiddenDeliveryGateFlagSync(): boolean | null { + if (persistedGateFlagCache === undefined) { + try { + const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync + persistedGateFlagCache = + typeof getSync === 'function' ? (getSync()?.terminalHiddenDeliveryGate ?? null) : null + } catch { + persistedGateFlagCache = null + } + } + return persistedGateFlagCache +} + +export function isRendererHiddenPtyDeliveryGateEnabled( + settings: Pick | null +): boolean { + if (settings !== null) { + return settings.terminalHiddenDeliveryGate !== false + } + // Why: settings hydrate asynchronously; a pane/watcher bound before + // hydration must honor the persisted kill switch — the responder-mode + // decision made here is never revisited (same rationale as the + // side-effect-authority sync read). + return readPersistedHiddenDeliveryGateFlagSync() !== false +} + +/** Test seam: reset the persisted-flag cache between tests. */ +export function _resetHiddenPtyDeliveryGateFlagCacheForTest(): void { + persistedGateFlagCache = undefined +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts new file mode 100644 index 00000000000..db76af7ec56 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-scrollback' + +describe('hidden terminal restore scrollback', () => { + it('preserves configured history depth through the supported maximum', () => { + expect(resolveHiddenRestoreScrollbackRows(10_000)).toBe(10_000) + expect(resolveHiddenRestoreScrollbackRows(50_000)).toBe(50_000) + }) + + it('clamps malformed or oversized values with the shared desktop policy', () => { + expect(resolveHiddenRestoreScrollbackRows(undefined)).toBe(5_000) + expect(resolveHiddenRestoreScrollbackRows(100_000)).toBe(50_000) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.ts new file mode 100644 index 00000000000..6767d3f55f3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.ts @@ -0,0 +1,13 @@ +import { + DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, + normalizeDesktopTerminalSnapshotRows +} from '../../../../shared/terminal-scrollback-policy' + +/** Hidden view rebuilds must preserve the same history depth the live xterm + * retained; otherwise switching tabs silently changes the user's scrollback. */ +export function resolveHiddenRestoreScrollbackRows(configuredScrollback: unknown): number { + return ( + normalizeDesktopTerminalSnapshotRows(configuredScrollback) ?? + DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts new file mode 100644 index 00000000000..40ecab2f5a5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -0,0 +1,529 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_TAB_HOT_RETAIN_MS, + TERMINAL_WORKTREE_HOT_RETAIN_MS, + TERMINAL_WORKTREE_PARK_DELAY_MS, + canParkTerminalTabRenderer, + canParkTerminalWorktreeRenderers, + getTerminalTabColdParkRecheckDelayMs, + getTerminalWorktreeColdParkRecheckDelayMs, + isSnapshotBackedTerminalPty, + selectColdParkedTerminalTabs, + selectColdParkedTerminalWorktrees +} from './terminal-hidden-view-parking' + +describe('isSnapshotBackedTerminalPty', () => { + it('allows local daemon sessions owned by the worktree', () => { + expect(isSnapshotBackedTerminalPty('repo::/worktree@@session-1', 'repo::/worktree')).toBe(true) + expect(isSnapshotBackedTerminalPty('wt-1@@session-1', 'wt-1')).toBe(true) + }) + + // Why: separator-less ids ('1', '2', 'pty-local-detached') come from the + // daemon-fail-open LocalPtyProvider and have no daemon session model — + // revealing a parked pane would silently respawn a fresh shell, so they + // must not count as snapshot-backed (changed from the ported prior art). + it('rejects separator-less local PTY ids that lack a daemon session model', () => { + expect(isSnapshotBackedTerminalPty('pty-local-detached', 'repo::/worktree')).toBe(false) + expect(isSnapshotBackedTerminalPty('1', 'wt-1')).toBe(false) + }) + + it('rejects tabs that do not have a PTY yet', () => { + expect(isSnapshotBackedTerminalPty(null, 'repo::/worktree')).toBe(false) + }) + + it('rejects daemon sessions owned by another worktree', () => { + expect(isSnapshotBackedTerminalPty('repo::/other@@session-1', 'repo::/worktree')).toBe(false) + expect(isSnapshotBackedTerminalPty('wt-2@@session-1', 'wt-1')).toBe(false) + }) + + it('rejects SSH and remote runtime PTY handles', () => { + expect(isSnapshotBackedTerminalPty('ssh:ssh-1@@pty-1', 'repo::/worktree')).toBe(false) + expect(isSnapshotBackedTerminalPty('remote:env-1@@terminal-1', 'repo::/worktree')).toBe(false) + }) +}) + +describe('canParkTerminalWorktreeRenderers', () => { + const hiddenSinceMs = 1_000 + const nowMs = hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS + const base = { + worktreeId: 'repo::/worktree', + terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }], + pendingStartupByTabId: {}, + parkingEnabled: true, + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs, + nowMs + } + + it('parks hidden local terminal renderers after the idle delay', () => { + expect(canParkTerminalWorktreeRenderers(base)).toBe(true) + }) + + it('never parks when the settings kill switch disables parking', () => { + expect(canParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + parkingEnabled: false, + nowMs: hiddenSinceMs + TERMINAL_WORKTREE_HOT_RETAIN_MS * 10 + }) + ).toBe(false) + }) + + it('keeps renderers mounted while visible, measuring, portaled, or before the delay', () => { + expect(canParkTerminalWorktreeRenderers({ ...base, isVisible: true })).toBe(false) + expect(canParkTerminalWorktreeRenderers({ ...base, shouldMeasureHiddenWorktree: true })).toBe( + false + ) + expect(canParkTerminalWorktreeRenderers({ ...base, hasActivityTerminalPortal: true })).toBe( + false + ) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS - 1 + }) + ).toBe(false) + }) + + it('honors a per-call cold-park delay override', () => { + const shortDelayArgs = { ...base, coldParkDelayMs: 100 } + expect(canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 99 })).toBe( + false + ) + expect( + canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 100 }) + ).toBe(true) + }) + + it('keeps the renderer mounted when any terminal lacks snapshot-backed restore', () => { + expect( + canParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }, + { id: 'tab-2', ptyId: 'ssh:ssh-1@@pty-1' } + ] + }) + ).toBe(false) + }) + + it('keeps renderers mounted while a tab has startup or activation work pending', () => { + expect( + canParkTerminalWorktreeRenderers({ + ...base, + pendingStartupByTabId: { 'tab-1': { command: 'echo pending' } } + }) + ).toBe(false) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: true } + ] + }) + ).toBe(false) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: 2 } + ] + }) + ).toBe(false) + }) +}) + +describe('canParkTerminalTabRenderer', () => { + const hiddenSinceMs = 1_000 + const base = { + worktreeId: 'wt-1', + terminalTab: { + id: 'tab-1', + ptyId: 'wt-1@@session-1', + isVisible: false, + hasActivityTerminalPortal: false, + hiddenSinceMs + }, + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS + } + + it('parks an idle hidden local tab and honors the kill switch', () => { + expect(canParkTerminalTabRenderer(base)).toBe(true) + expect(canParkTerminalTabRenderer({ ...base, parkingEnabled: false })).toBe(false) + }) + + it('honors a per-call cold-park delay override', () => { + expect( + canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 99 }) + ).toBe(false) + expect( + canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 100 }) + ).toBe(true) + }) +}) + +describe('selectColdParkedTerminalWorktrees', () => { + const nowMs = 500_000 + + function localCandidate(worktreeId: string, hiddenSinceMs: number) { + return { + worktreeId, + terminalTabs: [{ id: `tab-${worktreeId}`, ptyId: `${worktreeId}@@session-1` }], + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs + } + } + + it('keeps recent hidden local worktrees hot up to the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set()) + }) + + it('cold-parks the oldest hidden local worktrees beyond the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1), + localCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set(['wt-3'])) + }) + + it('cold-parks aged local worktrees even when under the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 4 + }) + + expect(selected).toEqual(new Set(['wt-1'])) + }) + + it('selects nothing when the settings kill switch disables parking', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS * 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: false, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) + + it('does not cold-park terminals without local snapshot recovery', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-local', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + { + ...localCandidate('wt-ssh', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [{ id: 'tab-ssh', ptyId: 'ssh:ssh-1@@pty-1' }] + }, + { + ...localCandidate('wt-remote', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [{ id: 'tab-remote', ptyId: 'remote:env-1@@terminal-1' }] + } + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set(['wt-local'])) + }) + + it('keeps visible, measuring, portaled, and pending terminals mounted', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + { + ...localCandidate('wt-visible', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + isVisible: true + }, + { + ...localCandidate('wt-measuring', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + shouldMeasureHiddenWorktree: true + }, + { + ...localCandidate('wt-portal', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + hasActivityTerminalPortal: true + }, + { + ...localCandidate('wt-activation', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [ + { + id: 'tab-activation', + ptyId: 'wt-activation@@session-1', + pendingActivationSpawn: true + } + ] + }, + localCandidate('wt-startup', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS) + ], + pendingStartupByTabId: { 'tab-wt-startup': { command: 'echo pending' } }, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) +}) + +describe('selectColdParkedTerminalTabs', () => { + const nowMs = 500_000 + + function localTab(id: string, hiddenSinceMs: number) { + return { + id, + ptyId: `wt-1@@session-${id}`, + pendingActivationSpawn: false, + isVisible: false, + hasActivityTerminalPortal: false, + hiddenSinceMs + } + } + + it('keeps visible and recent inactive terminal tabs mounted', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + { ...localTab('tab-visible', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), isVisible: true }, + localTab('tab-recent-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localTab('tab-recent-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set()) + }) + + it('cold-parks the oldest inactive local tabs beyond the retain limit', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + localTab('tab-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localTab('tab-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1), + localTab('tab-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set(['tab-3'])) + }) + + it('cold-parks aged inactive local tabs even when under the retain limit', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS)], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 12 + }) + + expect(selected).toEqual(new Set(['tab-1'])) + }) + + it('selects nothing when the settings kill switch disables parking', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + localTab('tab-2', nowMs - TERMINAL_TAB_HOT_RETAIN_MS * 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: false, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) + + it('does not cold-park inactive terminal tabs without local snapshot recovery', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + localTab('tab-local', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + { + ...localTab('tab-ssh', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + ptyId: 'ssh:ssh-1@@pty-1' + }, + { + ...localTab('tab-remote', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + ptyId: 'remote:env-1@@terminal-1' + } + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set(['tab-local'])) + }) + + it('keeps portaled, pending-startup, and pending-activation terminal tabs mounted', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + { + ...localTab('tab-portal', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + hasActivityTerminalPortal: true + }, + localTab('tab-startup', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + { + ...localTab('tab-activation', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + pendingActivationSpawn: true + } + ], + pendingStartupByTabId: { 'tab-startup': { command: 'echo pending' } }, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) +}) + +describe('getTerminalWorktreeColdParkRecheckDelayMs', () => { + it('returns the next cold-park policy deadline', () => { + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: null, + nowMs: 1_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(50) + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_100, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(900) + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 2_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) + + it('schedules no recheck when the settings kill switch disables parking', () => { + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: false, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) +}) + +describe('getTerminalTabColdParkRecheckDelayMs', () => { + it('returns the next terminal-tab cold-park policy deadline', () => { + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: null, + nowMs: 1_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(50) + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_100, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(900) + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 2_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) + + it('schedules no recheck when the settings kill switch disables parking', () => { + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: false, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts new file mode 100644 index 00000000000..1421e8ac53d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -0,0 +1,285 @@ +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { TerminalTab } from '../../../../shared/types' + +// Why: cold-park hysteresis keeps a hidden pane mounted for 30s so quick tab +// flips never pay a re-hydrate; hot-retain keeps a bounded recently-visible +// working set warm for 5 minutes beyond that. +export const TERMINAL_WORKTREE_COLD_PARK_DELAY_MS = 30_000 +export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 5 * 60_000 +export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 4 +export const TERMINAL_WORKTREE_PARK_DELAY_MS = TERMINAL_WORKTREE_COLD_PARK_DELAY_MS +export const TERMINAL_TAB_COLD_PARK_DELAY_MS = 30_000 +export const TERMINAL_TAB_HOT_RETAIN_MS = 5 * 60_000 +export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 12 + +// Why: tests override these per call (instead of process.env reads inside the +// module) to shrink the 30s hysteresis to test-friendly durations. +export type TerminalColdParkPolicyOverrides = { + coldParkDelayMs?: number + hotRetainMs?: number + hotRetainLimit?: number +} + +export type ColdParkableTerminalTab = Pick + +export type TerminalWorktreeColdParkCandidate = { + worktreeId: string + terminalTabs: readonly ColdParkableTerminalTab[] + isVisible: boolean + shouldMeasureHiddenWorktree: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null +} + +export type TerminalTabColdParkCandidate = ColdParkableTerminalTab & { + isVisible: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null +} + +function getPendingActivationSpawnCount(value: boolean | number | undefined): number { + if (value === true) { + return 1 + } + return typeof value === 'number' && value > 0 ? value : 0 +} + +// Why: parking relies on the daemon model snapshot to re-hydrate. Remote +// runtime and SSH PTYs have no local snapshot in this phase, and a session id +// minted for another worktree reattaches through a path parking cannot replay. +export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: string): boolean { + if (!ptyId) { + return false + } + if (isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) { + return false + } + // Why: separator-less ids come from the daemon-fail-open LocalPtyProvider; + // they have no daemon session model, so revealing a parked pane would + // silently respawn a fresh shell instead of restoring the snapshot. + const separatorIdx = ptyId.lastIndexOf(PTY_SESSION_ID_SEPARATOR) + return separatorIdx !== -1 && ptyId.slice(0, separatorIdx) === worktreeId +} + +export function canParkTerminalWorktreeRenderers(args: { + worktreeId: string + terminalTabs: readonly ColdParkableTerminalTab[] + pendingStartupByTabId: Readonly> + // Why: callers pass settings.terminalHiddenViewParking !== false — the + // design-doc kill switch that disables parking entirely. + parkingEnabled: boolean + isVisible: boolean + shouldMeasureHiddenWorktree: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number +}): boolean { + if ( + !args.parkingEnabled || + args.isVisible || + args.shouldMeasureHiddenWorktree || + args.hasActivityTerminalPortal || + args.hiddenSinceMs === null + ) { + return false + } + if ( + args.nowMs - args.hiddenSinceMs < + (args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS) + ) { + return false + } + return args.terminalTabs.every((tab) => { + if (args.pendingStartupByTabId[tab.id] !== undefined) { + return false + } + if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { + return false + } + return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId) + }) +} + +export function canParkTerminalTabRenderer(args: { + worktreeId: string + terminalTab: TerminalTabColdParkCandidate + pendingStartupByTabId: Readonly> + parkingEnabled: boolean + nowMs: number + coldParkDelayMs?: number +}): boolean { + const tab = args.terminalTab + if ( + !args.parkingEnabled || + tab.isVisible || + tab.hasActivityTerminalPortal || + tab.hiddenSinceMs === null + ) { + return false + } + if (args.nowMs - tab.hiddenSinceMs < (args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS)) { + return false + } + if (args.pendingStartupByTabId[tab.id] !== undefined) { + return false + } + if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { + return false + } + return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId) +} + +type ColdParkRetainCandidate = { id: string; hiddenSinceMs: number } + +// Why: hot-retain keeps the most recently hidden ids warm up to the limit; +// ids hidden past hotRetainMs or beyond the limit cold-park. Ties sort by id +// so the selection is deterministic. +function selectIdsBeyondHotRetain( + candidates: ColdParkRetainCandidate[], + args: { nowMs: number; hotRetainMs: number; hotRetainLimit: number } +): Set { + const coldParkedIds = new Set() + const retainedCandidates: ColdParkRetainCandidate[] = [] + for (const candidate of candidates) { + if (args.nowMs - candidate.hiddenSinceMs >= args.hotRetainMs) { + coldParkedIds.add(candidate.id) + } else { + retainedCandidates.push(candidate) + } + } + retainedCandidates.sort((a, b) => { + const recencyDelta = b.hiddenSinceMs - a.hiddenSinceMs + return recencyDelta === 0 ? a.id.localeCompare(b.id) : recencyDelta + }) + for (const candidate of retainedCandidates.slice(Math.max(0, args.hotRetainLimit))) { + coldParkedIds.add(candidate.id) + } + return coldParkedIds +} + +export function selectColdParkedTerminalWorktrees( + args: { + worktrees: readonly TerminalWorktreeColdParkCandidate[] + pendingStartupByTabId: Readonly> + parkingEnabled: boolean + nowMs: number + } & TerminalColdParkPolicyOverrides +): Set { + if (!args.parkingEnabled) { + return new Set() + } + const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS + const candidates: ColdParkRetainCandidate[] = [] + for (const worktree of args.worktrees) { + if ( + worktree.hiddenSinceMs === null || + !canParkTerminalWorktreeRenderers({ + ...worktree, + pendingStartupByTabId: args.pendingStartupByTabId, + parkingEnabled: args.parkingEnabled, + nowMs: args.nowMs, + coldParkDelayMs + }) + ) { + continue + } + candidates.push({ id: worktree.worktreeId, hiddenSinceMs: worktree.hiddenSinceMs }) + } + return selectIdsBeyondHotRetain(candidates, { + nowMs: args.nowMs, + hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS, + hotRetainLimit: args.hotRetainLimit ?? TERMINAL_WORKTREE_HOT_RETAIN_LIMIT + }) +} + +export function selectColdParkedTerminalTabs( + args: { + worktreeId: string + terminalTabs: readonly TerminalTabColdParkCandidate[] + pendingStartupByTabId: Readonly> + parkingEnabled: boolean + nowMs: number + } & TerminalColdParkPolicyOverrides +): Set { + if (!args.parkingEnabled) { + return new Set() + } + const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS + const candidates: ColdParkRetainCandidate[] = [] + for (const tab of args.terminalTabs) { + if ( + tab.hiddenSinceMs === null || + !canParkTerminalTabRenderer({ + worktreeId: args.worktreeId, + terminalTab: tab, + pendingStartupByTabId: args.pendingStartupByTabId, + parkingEnabled: args.parkingEnabled, + nowMs: args.nowMs, + coldParkDelayMs + }) + ) { + continue + } + candidates.push({ id: tab.id, hiddenSinceMs: tab.hiddenSinceMs }) + } + return selectIdsBeyondHotRetain(candidates, { + nowMs: args.nowMs, + hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS, + hotRetainLimit: args.hotRetainLimit ?? TERMINAL_TAB_HOT_RETAIN_LIMIT + }) +} + +// Why: parking decisions change only at the cold-park and hot-retain +// deadlines, so callers schedule one recheck at the next deadline instead of +// polling. +function nextColdParkDeadlineDelayMs(args: { + parkingEnabled: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs: number + hotRetainMs: number +}): number | null { + if (!args.parkingEnabled || args.hiddenSinceMs === null) { + return null + } + const pendingDeadlines = [ + args.hiddenSinceMs + args.coldParkDelayMs, + args.hiddenSinceMs + args.hotRetainMs + ].filter((deadlineMs) => deadlineMs > args.nowMs) + return pendingDeadlines.length === 0 ? null : Math.min(...pendingDeadlines) - args.nowMs +} + +export function getTerminalWorktreeColdParkRecheckDelayMs(args: { + parkingEnabled: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number + hotRetainMs?: number +}): number | null { + return nextColdParkDeadlineDelayMs({ + parkingEnabled: args.parkingEnabled, + hiddenSinceMs: args.hiddenSinceMs, + nowMs: args.nowMs, + coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS, + hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS + }) +} + +export function getTerminalTabColdParkRecheckDelayMs(args: { + parkingEnabled: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number + hotRetainMs?: number +}): number | null { + return nextColdParkDeadlineDelayMs({ + parkingEnabled: args.parkingEnabled, + hiddenSinceMs: args.hiddenSinceMs, + nowMs: args.nowMs, + coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS, + hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts new file mode 100644 index 00000000000..bfd6b988291 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -0,0 +1,564 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher' + +const WORKTREE_ID = 'repo::/worktree' +const OTHER_WORKTREE_ID = 'repo::/other-worktree' +const TAB_ID = 'tab-1' +const PTY_ID = `${WORKTREE_ID}@@session-1` +const SECOND_PTY_ID = `${WORKTREE_ID}@@session-2` +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +type StartedWatcher = { + options: ParkedTerminalByteWatcherOptions + dispose: ReturnType +} + +const startedWatchers: StartedWatcher[] = [] +const startParkedTerminalByteWatcher = vi.fn((options: ParkedTerminalByteWatcherOptions) => { + const dispose = vi.fn() + startedWatchers.push({ options, dispose }) + return dispose +}) + +vi.mock('./parked-terminal-byte-watcher', () => ({ + startParkedTerminalByteWatcher: (options: ParkedTerminalByteWatcherOptions) => + startParkedTerminalByteWatcher(options) +})) + +type ExitSubscription = { + ptyId: string + callback: (code: number) => void + unsubscribe: ReturnType +} + +const exitSubscriptions: ExitSubscription[] = [] +const subscribeToPtyExit = vi.fn((ptyId: string, callback: (code: number) => void) => { + const unsubscribe = vi.fn() + exitSubscriptions.push({ ptyId, callback, unsubscribe }) + return unsubscribe +}) + +vi.mock('./pty-dispatcher', () => ({ + subscribeToPtyExit: (ptyId: string, callback: (code: number) => void) => + subscribeToPtyExit(ptyId, callback) +})) + +type MockStoreState = { + terminalLayoutsByTabId: Record< + string, + { + root: unknown + activeLeafId: string | null + expandedLeafId: string | null + ptyIdsByLeafId?: Record + } + > + runtimePaneTitlesByTabId: Record> + clearRuntimePaneTitle: ReturnType + setTabLayout: ReturnType +} + +let mockStoreState: MockStoreState + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mockStoreState } +})) + +import { + canWatcherCoverParkedTerminalTab, + captureParkedTerminalPaneCandidates, + disposeParkedTerminalWatchersForPtyIds, + disposeParkedTerminalWatchersForWorktree, + fallbackParkedPaneCandidates, + getParkedTerminalWatcherTabIds, + pruneParkedTerminalWatchers, + shouldDeferParkedPtyExitTabClose, + syncParkedTerminalTabWatchers +} from './terminal-parked-tab-watchers' + +const ptyWrite = vi.fn() +const originalWindow = (globalThis as { window?: unknown }).window + +function capturePanes( + panes: { ptyId: string | null; paneId: number; leafId: string; drivesTabTitle: boolean }[], + args?: { tabId?: string; worktreeId?: string } +): void { + captureParkedTerminalPaneCandidates(args?.tabId ?? TAB_ID, args?.worktreeId ?? WORKTREE_ID, panes) +} + +function syncParked(args?: { + worktreeId?: string + tabs?: { id: string; ptyId: string | null }[] + parkedTabIds?: Iterable +}): void { + syncParkedTerminalTabWatchers({ + worktreeId: args?.worktreeId ?? WORKTREE_ID, + tabs: args?.tabs ?? [{ id: TAB_ID, ptyId: PTY_ID }], + parkedTabIds: new Set(args?.parkedTabIds ?? [TAB_ID]) + }) +} + +describe('terminal-parked-tab-watchers', () => { + beforeEach(() => { + mockStoreState = { + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: {}, + clearRuntimePaneTitle: vi.fn(), + setTabLayout: vi.fn() + } + ;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } } + }) + + afterEach(() => { + // Module-level registries persist across tests; clear them through the + // public prune path so each test starts from an empty parked state. + pruneParkedTerminalWatchers(new Set()) + startedWatchers.length = 0 + exitSubscriptions.length = 0 + vi.clearAllMocks() + ;(globalThis as { window?: unknown }).window = originalWindow + }) + + it('starts one watcher per captured snapshot-backed PTY with the captured pane identity', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) + expect(startedWatchers[0].options).toMatchObject({ + ptyId: PTY_ID, + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneId: 1, + drivesTabTitle: true + }) + expect(startedWatchers[1].options).toMatchObject({ + ptyId: SECOND_PTY_ID, + paneId: 2, + drivesTabTitle: false + }) + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + + it('routes watcher sendInput to window.api.pty.write for the watched PTY', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + startedWatchers[0].options.sendInput('\x1b[?2031;1$y') + expect(ptyWrite).toHaveBeenCalledWith(PTY_ID, '\x1b[?2031;1$y') + }) + + it('skips legacy non-UUID leaf ids instead of throwing in makePaneKey', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1) + expect(startedWatchers[0].options).toMatchObject({ ptyId: SECOND_PTY_ID }) + }) + + it('never starts watchers for remote-runtime or SSH PTYs', () => { + capturePanes([ + { ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] }) + + expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled() + // Why: the tab is still tracked as parked so debug introspection + // (window.__terminalParkingDebug) reflects every parked tab. + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + + it('keeps existing watchers across repeated syncs of the same parked state', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + syncParked() + + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1) + expect(startedWatchers[0].dispose).not.toHaveBeenCalled() + }) + + it('disposes the watcher and exit subscription when the tab unparks', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + syncParked({ parkedTabIds: [] }) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('disposes the watcher when the tab closes while parked', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + syncParked({ tabs: [], parkedTabIds: [TAB_ID] }) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('disposes a PTY watcher when that PTY exits while parked', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + const exited = exitSubscriptions.find((entry) => entry.ptyId === PTY_ID) + exited?.callback(0) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(startedWatchers[1].dispose).not.toHaveBeenCalled() + // The tab itself is still parked, only the exited PTY's watcher is gone. + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + + it('seeds each watcher with the pane slot last known runtime title', () => { + mockStoreState.runtimePaneTitlesByTabId = { [TAB_ID]: { 1: '⠋ Build feature' } } + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + expect(startedWatchers[0].options.initialTitle).toBe('⠋ Build feature') + expect(startedWatchers[1].options.initialTitle).toBeUndefined() + }) + + it('drops the parked tab entry when the pty-exit sidecar disposes the last watcher', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('synchronously disposes watchers for the given PTY ids without unparking the tab', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + disposeParkedTerminalWatchersForPtyIds([PTY_ID]) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + expect(startedWatchers[1].dispose).not.toHaveBeenCalled() + // Why: the entry survives so a sleeping parked tab cannot restart a + // watcher against its stale PTY ids before wake re-mints them. + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + syncParked() + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) + }) + + it('restarts watchers from store layout when the tab PTY was re-minted', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + const remintedPtyId = `${WORKTREE_ID}@@session-after-wake` + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: remintedPtyId } + } + syncParked({ tabs: [{ id: TAB_ID, ptyId: remintedPtyId }] }) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) + expect(startedWatchers[1].options).toMatchObject({ ptyId: remintedPtyId, leafId: LEAF_ID }) + }) + + it('scopes sync disposal to the given worktree', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + const otherPtyId = `${OTHER_WORKTREE_ID}@@session-9` + capturePanes([{ ptyId: otherPtyId, paneId: 1, leafId: SECOND_LEAF_ID, drivesTabTitle: true }], { + tabId: 'tab-other', + worktreeId: OTHER_WORKTREE_ID + }) + syncParked({ + worktreeId: OTHER_WORKTREE_ID, + tabs: [{ id: 'tab-other', ptyId: otherPtyId }], + parkedTabIds: ['tab-other'] + }) + + // Unparking everything in the other worktree must not touch this one. + syncParked({ worktreeId: OTHER_WORKTREE_ID, tabs: [], parkedTabIds: [] }) + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + expect(startedWatchers[0].dispose).not.toHaveBeenCalled() + }) + + it('disposes all of a worktree watchers on worktree teardown and prunes deleted worktrees', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + disposeParkedTerminalWatchersForWorktree(WORKTREE_ID) + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + pruneParkedTerminalWatchers(new Set([OTHER_WORKTREE_ID])) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + describe('shouldDeferParkedPtyExitTabClose', () => { + const closeTab = vi.fn() + + // Mirrors both hosts' onPtyExit wiring: the guard runs before closeTab. + function hostOnPtyExit(tabId: string, ptyId: string): void { + if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) { + return + } + closeTab(tabId) + } + + it('defers tab close on PTY exit in a parked multi-leaf tab and clears the dead slot', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(closeTab).not.toHaveBeenCalled() + // The dead leaf's runtime-title slot cannot pin worktree status. + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 1) + }) + + it('collapses the exited leaf out of the stored layout when deferring', () => { + // Why (regression, ghost/resurrected pane): a deferred parked exit that + // leaves the leaf and its binding in the stored layout reattaches on + // reveal — the daemon re-creates the exited session id as a fresh shell. + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + hostOnPtyExit(TAB_ID, SECOND_PTY_ID) + + expect(closeTab).not.toHaveBeenCalled() + expect(mockStoreState.setTabLayout).toHaveBeenCalledWith(TAB_ID, { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + }) + }) + + it('keeps exit→closeTab parity for a parked single-leaf tab', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(closeTab).toHaveBeenCalledWith(TAB_ID) + }) + + it('keeps exit→closeTab parity when the tab is not parked', () => { + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(closeTab).toHaveBeenCalledWith(TAB_ID) + }) + + it('collapses the exited leaf via the watcher exit sidecar (no host handler while parked)', () => { + // Why: hosts' onPtyExit runs from a mounted TerminalPane, so an exit + // that lands while parked reaches ONLY the watcher sidecar — it must run + // the layout collapse itself or the leaf resurrects on reveal. + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + exitSubscriptions.find((entry) => entry.ptyId === SECOND_PTY_ID)?.callback(0) + + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2) + expect(mockStoreState.setTabLayout).toHaveBeenCalledWith(TAB_ID, { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + }) + }) + + it('does not touch the layout when the last parked watcher exits (tab-level close owns it)', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + + exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0) + + expect(mockStoreState.setTabLayout).not.toHaveBeenCalled() + }) + + it('closes the tab when the last surviving leaf of a parked split exits', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + // First leaf dies: deferred, then its exit sidecar drops the watcher. + hostOnPtyExit(TAB_ID, PTY_ID) + exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0) + expect(closeTab).not.toHaveBeenCalled() + + hostOnPtyExit(TAB_ID, SECOND_PTY_ID) + expect(closeTab).toHaveBeenCalledWith(TAB_ID) + }) + }) + + describe('canWatcherCoverParkedTerminalTab', () => { + it('rejects a tab with no unmount capture and no layout snapshot', () => { + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + + it('accepts a current capture whose panes are all snapshot-backed', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + true + ) + }) + + it('rejects a capture containing a legacy non-UUID leaf id', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + + it('rejects a capture containing a PTY without snapshot backing', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + + it('accepts layout-derived candidates when the capture is stale', () => { + capturePanes([{ ptyId: 'old-pty', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + true + ) + }) + + it('rejects layout-derived candidates missing a leaf PTY binding', () => { + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'row', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + }) +}) + +describe('fallbackParkedPaneCandidates', () => { + it('returns nothing without a layout snapshot', () => { + expect( + fallbackParkedPaneCandidates( + { id: TAB_ID, ptyId: PTY_ID }, + { terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {} } + ) + ).toEqual([]) + }) + + it('reuses the single runtime-title slot for a single-pane tab', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: null } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 7: 'working title' } } + } as never) + ).toEqual([{ ptyId: PTY_ID, paneId: 7, leafId: LEAF_ID, drivesTabTitle: true }]) + }) + + it('maps split leaves to layout PTYs with collision-free negative pane ids', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { + type: 'split', + direction: 'row', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'a', 2: 'b' } } + } as never) + ).toEqual([ + { ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: false }, + { ptyId: SECOND_PTY_ID, paneId: -2, leafId: SECOND_LEAF_ID, drivesTabTitle: true } + ]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts new file mode 100644 index 00000000000..8871bdf4905 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -0,0 +1,278 @@ +/** + * Parked terminal tab watcher lifecycle. + * + * Why: parking unmounts a tab's TerminalPane, so its PTYs lose the renderer + * byte parsers. This module owns the pane-less replacement: it remembers the + * unmounted panes' identities (pane id / leaf id), starts one + * parked-terminal-byte-watcher per PTY when a tab parks, and disposes them on + * reveal, tab close, PTY exit, or worktree teardown. The bookkeeping maps + * live in terminal-parked-watcher-registry so the terminals store slice can + * dispose watchers without importing this store-coupled module. + * See docs/reference/terminal-hidden-view-parking.md. + */ +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../../shared/types' +import { useAppStore } from '@/store' +import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' +import { subscribeToPtyExit } from './pty-dispatcher' +import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher' +import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking' +import { + capturedPanesByTabId, + disposeParkedTabWatchers, + parkedWatchersByTabId, + type ParkedTerminalPaneCapture +} from './terminal-parked-watcher-registry' + +// Why: re-exported so park wiring keeps one import surface; the registry +// split exists only to break the store-slice import cycle. +export { + captureParkedTerminalPaneCandidates, + disposeParkedTerminalWatchersForPtyIds, + disposeParkedTerminalWatchersForWorktree, + getParkedTerminalWatcherTabIds, + pruneParkedTerminalWatchers +} from './terminal-parked-watcher-registry' +export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry' + +export type ParkableTerminalTabModel = Pick + +type ParkedPaneFallbackState = { + terminalLayoutsByTabId: ReturnType['terminalLayoutsByTabId'] + runtimePaneTitlesByTabId: ReturnType['runtimePaneTitlesByTabId'] +} + +// Why: if no unmount capture exists (or it predates a PTY respawn), derive +// pane identities from the persisted layout snapshot. Numeric pane ids are +// unknown here: reuse the single existing runtime-title slot when unambiguous +// so a stale "working" title still gets overwritten, otherwise use negative +// slots that can never collide with real PaneManager ids. +export function fallbackParkedPaneCandidates( + tab: ParkableTerminalTabModel, + state: ParkedPaneFallbackState +): ParkedTerminalPaneCapture[] { + const layout = state.terminalLayoutsByTabId[tab.id] + const leafIds = collectLeafIdsInOrder(layout?.root) + if (leafIds.length === 0) { + return [] + } + const ptyIdsByLeafId = layout?.ptyIdsByLeafId ?? {} + const titleSlots = Object.keys(state.runtimePaneTitlesByTabId[tab.id] ?? {}) + const reusableSlot = + leafIds.length === 1 && titleSlots.length === 1 ? Number(titleSlots[0]) : null + return leafIds.map((leafId, index) => ({ + ptyId: ptyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null), + paneId: reusableSlot ?? -(index + 1), + leafId, + drivesTabTitle: layout?.activeLeafId ? leafId === layout.activeLeafId : index === 0 + })) +} + +// Why: unmount captures and layout fallbacks must resolve identically for the +// watcher start path and the park-eligibility coverage check, or a tab could +// pass the check and then start with different (uncoverable) candidates. +function resolveParkedTerminalPaneCandidates( + tab: ParkableTerminalTabModel, + state: ParkedPaneFallbackState +): ParkedTerminalPaneCapture[] { + const captured = capturedPanesByTabId.get(tab.id) + // Why: a capture that no longer mentions the tab's current PTY is stale + // (the PTY was re-minted since the unmount); fall back to the layout. + const capturedIsCurrent = + captured !== undefined && + captured.panes.length > 0 && + (tab.ptyId === null || captured.panes.some((pane) => pane.ptyId === tab.ptyId)) + return capturedIsCurrent ? captured.panes : fallbackParkedPaneCandidates(tab, state) +} + +/** + * Whether the parked byte watchers can fully cover this tab's PTYs (some + * candidate exists and every candidate has a snapshot-backed PTY bound to a + * valid leaf). Hosts must refuse to park a tab that fails this check — + * parking it would silently drop bell/title/completion side effects, the + * exact failure that sank the first parking attempt. + */ +export function canWatcherCoverParkedTerminalTab( + worktreeId: string, + tab: ParkableTerminalTabModel +): boolean { + const panes = resolveParkedTerminalPaneCandidates(tab, useAppStore.getState()) + return ( + panes.length > 0 && + panes.every( + (pane) => + pane.ptyId !== null && + isTerminalLeafId(pane.leafId) && + isSnapshotBackedTerminalPty(pane.ptyId, worktreeId) + ) + ) +} + +function startParkedTabWatchers(worktreeId: string, tab: ParkableTerminalTabModel): void { + const state = useAppStore.getState() + const panes = resolveParkedTerminalPaneCandidates(tab, state) + const disposersByPtyId = new Map void>() + const paneIdByPtyId = new Map() + for (const pane of panes) { + const ptyId = pane.ptyId + // Why: the park policy already excludes non-snapshot-backed PTYs, but the + // tab model can change between the park decision and this effect — guard + // again so remote-runtime/SSH PTYs never get a local watcher. Legacy + // non-UUID leaf ids are skipped because makePaneKey throws on them. + if ( + !ptyId || + disposersByPtyId.has(ptyId) || + !isTerminalLeafId(pane.leafId) || + !isSnapshotBackedTerminalPty(ptyId, worktreeId) + ) { + continue + } + const initialTitle = state.runtimePaneTitlesByTabId[tab.id]?.[pane.paneId] + const disposeWatcher = startParkedTerminalByteWatcher({ + ptyId, + tabId: tab.id, + worktreeId, + leafId: pane.leafId, + paneId: pane.paneId, + drivesTabTitle: pane.drivesTabTitle, + // Why: seed the watcher's agent tracker with the pane's last known + // title so an agent already working at park time still notifies when + // it finishes while parked. + ...(initialTitle !== undefined ? { initialTitle } : {}), + // Why: no pane transport exists while parked; write straight to the + // PTY, the same channel background agent launches use. + sendInput: (data) => window.api.pty.write(ptyId, data) + }) + // Why: a PTY that exits while parked has no pane to run exit cleanup; at + // minimum its watcher must not outlive it. + const unsubscribeExit = subscribeToPtyExit(ptyId, () => { + // Why: while parked this sidecar is the ONLY exit observer — the hosts' + // onPtyExit runs from a mounted TerminalPane. Run the observed-exit + // teardown's data half here for a multi-leaf tab, or the dead leaf's + // stale binding reattaches on reveal and the daemon re-creates the + // exited session id as a fresh shell, resurrecting the pane. + if (disposersByPtyId.size > 1) { + useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId) + collapseParkedExitedLeaf(tab.id, ptyId) + } + disposersByPtyId.get(ptyId)?.() + disposersByPtyId.delete(ptyId) + // Why: with the last watcher gone there is nothing left to watch or + // dispose; dropping the entry keeps the registry bounded to parked + // tabs that still hold live PTYs. + const entry = parkedWatchersByTabId.get(tab.id) + if (disposersByPtyId.size === 0 && entry?.disposersByPtyId === disposersByPtyId) { + parkedWatchersByTabId.delete(tab.id) + } + }) + paneIdByPtyId.set(ptyId, pane.paneId) + disposersByPtyId.set(ptyId, () => { + unsubscribeExit() + disposeWatcher() + }) + } + // Why: tracked even with zero watchers so parked-state introspection + // (window.__terminalParkingDebug) reflects every parked tab. + parkedWatchersByTabId.set(tab.id, { + worktreeId, + tabPtyId: tab.ptyId, + paneIdByPtyId, + disposersByPtyId + }) +} + +/** + * Hosts call this from their onPtyExit handlers before closing the tab. + * Returns true when the close must be deferred: a parked tab has no + * PaneManager to promote split siblings, so the live exit path degenerates to + * "close the whole tab" — which would kill the surviving sibling panes. The + * reveal remount handles dead PTYs per leaf instead. Single-leaf parked tabs + * return false so exit→closeTab parity is preserved. Also clears the dead + * leaf's runtime-title slot so a stale title cannot pin worktree status. + */ +export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string): boolean { + const entry = parkedWatchersByTabId.get(tabId) + if (!entry) { + return false + } + const paneId = entry.paneIdByPtyId.get(ptyId) + if (paneId !== undefined) { + useAppStore.getState().clearRuntimePaneTitle(tabId, paneId) + } + const remaining = entry.disposersByPtyId.size + if (remaining === 0) { + return false + } + // Why: this runs from the PTY exit handler, before the exit sidecar above + // removes the dead PTY's watcher — so the watcher count still includes the + // exiting PTY. More than one watcher (or an exit for an unwatched PTY) + // means live sibling leaves remain. + const defer = remaining > 1 || !entry.disposersByPtyId.has(ptyId) + if (defer) { + collapseParkedExitedLeaf(tabId, ptyId) + } + return defer +} + +// Why: deferring the tab close is not enough — a stale leaf binding left in +// the stored layout reattaches on reveal, and the daemon re-creates the exited +// session id as a fresh shell, resurrecting a pane whose shell already ended. +// With no PaneManager mounted, the observed-exit teardown's data half runs +// here instead: collapse the leaf out of the stored layout so the reveal +// replays only the surviving panes. +function collapseParkedExitedLeaf(tabId: string, ptyId: string): void { + const state = useAppStore.getState() + const layout = state.terminalLayoutsByTabId[tabId] + const leafId = + capturedPanesByTabId.get(tabId)?.panes.find((pane) => pane.ptyId === ptyId)?.leafId ?? + Object.entries(layout?.ptyIdsByLeafId ?? {}).find(([, boundPtyId]) => boundPtyId === ptyId)?.[0] + if (!leafId) { + return + } + const detached = detachTerminalLayoutLeaf(layout, leafId) + if (detached) { + state.setTabLayout(tabId, detached.sourceLayout) + } +} + +/** + * Reconciles watchers for one worktree against its rendered parked set. + * Callers run this from an effect keyed on the committed render state, so + * disposal lands in the same effect flush as a reveal remount (before any + * PTY data IPC can be delivered) and start lands after the park unmount. + */ +export function syncParkedTerminalTabWatchers(args: { + worktreeId: string + tabs: readonly ParkableTerminalTabModel[] + parkedTabIds: ReadonlySet +}): void { + const liveTabIds = new Set(args.tabs.map((tab) => tab.id)) + for (const [tabId, entry] of parkedWatchersByTabId) { + if (entry.worktreeId !== args.worktreeId) { + continue + } + if (!args.parkedTabIds.has(tabId) || !liveTabIds.has(tabId)) { + disposeParkedTabWatchers(tabId) + } + } + // Why: captures for closed tabs have no future park/reveal; drop them so + // the registry stays bounded by live tabs. + for (const [tabId, capture] of capturedPanesByTabId) { + if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) { + capturedPanesByTabId.delete(tabId) + } + } + for (const tab of args.tabs) { + if (!args.parkedTabIds.has(tab.id)) { + continue + } + const entry = parkedWatchersByTabId.get(tab.id) + if (entry && entry.tabPtyId !== tab.ptyId) { + disposeParkedTabWatchers(tab.id) + } + if (!parkedWatchersByTabId.has(tab.id)) { + startParkedTabWatchers(args.worktreeId, tab) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts new file mode 100644 index 00000000000..d6dd76e817a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -0,0 +1,107 @@ +/** + * Parked terminal watcher registry (store-free bookkeeping). + * + * Why a separate module: shutdownWorktreeTerminals (a store slice) must + * synchronously dispose parked watchers, but the watcher lifecycle module + * imports the store — a slice importing it would re-enter store creation + * mid-evaluation. Keeping the maps and pure disposal here lets the slice + * import cycle-free, mirroring how pty-dispatcher exports its handler maps. + */ + +export type ParkedTerminalPaneCapture = { + ptyId: string | null + /** PaneManager numeric pane id the live pane used for runtime titles. */ + paneId: number + /** Stable terminal-layout leaf UUID (paneKey attribution). */ + leafId: string + drivesTabTitle: boolean +} + +export type CapturedTabPanes = { worktreeId: string; panes: ParkedTerminalPaneCapture[] } + +export const capturedPanesByTabId = new Map() + +// Why: PaneManager pane ids die with the unmounted pane, but the watcher must +// keep writing the exact runtime-title slots the live pane used — a different +// slot would strand a stale "working" title that pins worktree status. +// TerminalPane unmount records the identities here for the park wiring. +export function captureParkedTerminalPaneCandidates( + tabId: string, + worktreeId: string, + panes: ParkedTerminalPaneCapture[] +): void { + capturedPanesByTabId.set(tabId, { worktreeId, panes }) +} + +export type ParkedTabWatcherEntry = { + worktreeId: string + /** Tab-level ptyId at watcher start; a change means the PTY was re-minted + * (e.g. wake respawn) and the watchers must restart against fresh ids. */ + tabPtyId: string | null + /** Runtime-title slot each watcher writes, so parked PTY-exit handling can + * clear the dead leaf's slot (no live pane will ever overwrite it). */ + paneIdByPtyId: Map + disposersByPtyId: Map void> +} + +export const parkedWatchersByTabId = new Map() + +export function getParkedTerminalWatcherTabIds(): string[] { + return Array.from(parkedWatchersByTabId.keys()) +} + +export function disposeParkedTabWatchers(tabId: string): void { + const entry = parkedWatchersByTabId.get(tabId) + if (!entry) { + return + } + parkedWatchersByTabId.delete(tabId) + for (const dispose of entry.disposersByPtyId.values()) { + dispose() + } + entry.disposersByPtyId.clear() +} + +/** + * Synchronously disposes any parked watcher subscribed to these PTYs. + * shutdownWorktreeTerminals silences the live transports' final teardown + * flush via unregisterPtyDataHandlers, but parked watchers ride the + * dispatcher SIDECAR channel that call does not touch — without this, the + * flush still marks unread and arms notification timers for a worktree that + * is already sleeping or deleted. The tab entries are kept so a sleeping + * parked tab does not restart watchers against its stale PTY ids; wake + * re-mints the ids and the sync path restarts watchers then. + */ +export function disposeParkedTerminalWatchersForPtyIds(ptyIds: readonly string[]): void { + for (const entry of parkedWatchersByTabId.values()) { + for (const ptyId of ptyIds) { + const dispose = entry.disposersByPtyId.get(ptyId) + if (dispose) { + entry.disposersByPtyId.delete(ptyId) + dispose() + } + } + } +} + +export function disposeParkedTerminalWatchersForWorktree(worktreeId: string): void { + for (const [tabId, entry] of parkedWatchersByTabId) { + if (entry.worktreeId === worktreeId) { + disposeParkedTabWatchers(tabId) + } + } +} + +/** Drops watchers and captures for worktrees that no longer exist. */ +export function pruneParkedTerminalWatchers(liveWorktreeIds: ReadonlySet): void { + for (const [tabId, entry] of parkedWatchersByTabId) { + if (!liveWorktreeIds.has(entry.worktreeId)) { + disposeParkedTabWatchers(tabId) + } + } + for (const [tabId, capture] of capturedPanesByTabId) { + if (!liveWorktreeIds.has(capture.worktreeId)) { + capturedPanesByTabId.delete(tabId) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts new file mode 100644 index 00000000000..40e226cf5df --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type MockE2EConfig = { exposeStore: boolean; terminalParkingDelayMs: number | null } + +let mockE2EConfig: MockE2EConfig + +vi.mock('@/lib/e2e-config', () => ({ + get e2eConfig() { + return mockE2EConfig + } +})) + +vi.mock('./terminal-parked-tab-watchers', () => ({ + getParkedTerminalWatcherTabIds: () => ['tab-parked'] +})) + +const originalWindow = (globalThis as { window?: unknown }).window + +type TerminalParkingE2EOverridesModule = { + getTerminalParkingPolicyOverrides: () => { + coldParkDelayMs?: number + hotRetainMs?: number + hotRetainLimit?: number + } + registerTerminalParkingDebugHandle: () => void +} + +async function importOverridesModule(): Promise { + vi.resetModules() + return import('./terminal-parking-e2e-overrides') +} + +describe('getTerminalParkingPolicyOverrides', () => { + beforeEach(() => { + mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null } + delete (globalThis as { window?: unknown }).window + }) + + afterEach(() => { + ;(globalThis as { window?: unknown }).window = originalWindow + }) + + it('ignores the delay override outside e2e (exposeStore off)', async () => { + mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: 500 } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({}) + }) + + it('maps the e2e delay to BOTH coldParkDelayMs and hotRetainMs', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({ + coldParkDelayMs: 500, + hotRetainMs: 500 + }) + }) + + it('returns no overrides when no delay is configured', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({}) + }) + + it('registers window.__terminalParkingDebug on import under exposeStore', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 } + const testWindow: { + __terminalParkingDebug?: { parkDelayMs: number; parkedTabIds: () => string[] } + } = {} + ;(globalThis as { window?: unknown }).window = testWindow + await importOverridesModule() + expect(testWindow.__terminalParkingDebug?.parkDelayMs).toBe(500) + expect(testWindow.__terminalParkingDebug?.parkedTabIds()).toEqual(['tab-parked']) + }) + + it('does not register the debug handle outside e2e', async () => { + mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null } + const testWindow: { __terminalParkingDebug?: unknown } = {} + ;(globalThis as { window?: unknown }).window = testWindow + await importOverridesModule() + expect(testWindow.__terminalParkingDebug).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts new file mode 100644 index 00000000000..1717b170bb9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts @@ -0,0 +1,34 @@ +import { e2eConfig } from '@/lib/e2e-config' +import { + TERMINAL_TAB_COLD_PARK_DELAY_MS, + type TerminalColdParkPolicyOverrides +} from './terminal-hidden-view-parking' +import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers' + +// Why: ORCA_E2E_TERMINAL_PARKING_DELAY_MS must shrink BOTH the cold-park +// hysteresis and the hot-retain window — recently hidden tabs otherwise sit +// in the hot-retain working set for 5 minutes and never park within a test +// run. Gated on exposeStore so packaged builds ignore stray env vars. +export function getTerminalParkingPolicyOverrides(): TerminalColdParkPolicyOverrides { + const delayMs = e2eConfig.exposeStore ? e2eConfig.terminalParkingDelayMs : null + return typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0 + ? { coldParkDelayMs: delayMs, hotRetainMs: delayMs } + : {} +} + +export function registerTerminalParkingDebugHandle(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + window.__terminalParkingDebug = { + parkDelayMs: + getTerminalParkingPolicyOverrides().coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS, + parkedTabIds: () => getParkedTerminalWatcherTabIds() + } +} + +// Why: the parking e2e spec gates on window.__terminalParkingDebug existing +// shortly after launch. This module is statically imported by the park +// wiring, so registering at module load makes the handle visible before any +// tab parks. +registerTerminalParkingDebugHandle() diff --git a/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts b/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts new file mode 100644 index 00000000000..9190a9bdfa6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' +import { + _resetParserHandlerReportsForTests, + guardParserHandler +} from './terminal-parser-handler-guard' + +const mocks = vi.hoisted(() => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb +})) + +beforeEach(() => { + mocks.recordRendererCrashBreadcrumb.mockClear() + _resetParserHandlerReportsForTests() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('guardParserHandler', () => { + it('passes arguments and return value through for healthy handlers', () => { + const handler = vi.fn((data: string) => data === 'handled') + const guarded = guardParserHandler('test-handler', handler) + expect(guarded('handled')).toBe(true) + expect(guarded('other')).toBe(false) + expect(handler).toHaveBeenCalledTimes(2) + expect(mocks.recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + }) + + it('degrades a throwing handler to "not handled" and reports a breadcrumb', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const guarded = guardParserHandler('exploding-handler', () => { + throw new TypeError('synthetic handler failure') + }) + expect(guarded()).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_parser_handler_error', + expect.objectContaining({ + handler: 'exploding-handler', + errorName: 'TypeError', + errorMessage: 'synthetic handler failure' + }) + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('caps repeated reports per handler', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const guarded = guardParserHandler('spammy-handler', () => { + throw new Error('always fails') + }) + for (let i = 0; i < 20; i++) { + guarded() + } + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledTimes(5) + } finally { + errorSpy.mockRestore() + } + }) + + it('keeps the real xterm write pipeline alive through a throwing handler (inverse of the wedge repro)', () => { + // Why: xterm-write-buffer-stall.repro.test.ts proves an UNguarded throwing + // handler permanently wedges the WriteBuffer. This is the fix's proof: + // the same poison sequence through a GUARDED handler keeps completing + // writes. + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + vi.useFakeTimers() + const term = new Terminal({ allowProposedApi: true }) + const completed: string[] = [] + term.parser.registerCsiHandler( + { final: 'z' }, + guardParserHandler('poisoned-csi', () => { + throw new Error('synthetic parser handler failure') + }) + ) + + term.write('\x1b[z', () => { + completed.push('poisoned') + }) + term.write('after', () => { + completed.push('after') + }) + expect(() => vi.runAllTimers()).not.toThrow() + + term.write('later', () => { + completed.push('later') + }) + vi.runAllTimers() + expect(completed).toEqual(['poisoned', 'after', 'later']) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_parser_handler_error', + expect.objectContaining({ handler: 'poisoned-csi' }) + ) + } finally { + errorSpy.mockRestore() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.ts b/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.ts new file mode 100644 index 00000000000..cb6b5bc51bf --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.ts @@ -0,0 +1,44 @@ +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' + +// Why: xterm's EscapeSequenceParser invokes custom CSI/OSC handlers +// synchronously inside WriteBuffer._innerWrite, which has no try/catch. A +// handler throw skips the loop's tail re-schedule, and write() only re-arms +// on an EMPTY buffer — one throw permanently freezes the pane (output stops; +// a pending replay guard never releases and silently eats keystrokes). +// Verified against vendored xterm 6.1.0-beta.287 in +// xterm-write-buffer-stall.repro.test.ts. Same escape class as +// terminal-link-provider-guard.ts, applied to parser handlers. +const MAX_REPORTS_PER_HANDLER = 5 +const reportCountsByHandler = new Map() + +/** + * Wrap a custom parser handler so a synchronous throw is reported and + * degraded to "not handled" (xterm falls through to the previous/default + * handler) instead of wedging the terminal's write pipeline. + */ +export function guardParserHandler( + handlerName: string, + handler: (...args: HandlerArgs) => boolean +): (...args: HandlerArgs) => boolean { + return (...args: HandlerArgs): boolean => { + try { + return handler(...args) + } catch (error: unknown) { + const reported = reportCountsByHandler.get(handlerName) ?? 0 + if (reported < MAX_REPORTS_PER_HANDLER) { + reportCountsByHandler.set(handlerName, reported + 1) + console.error(`[terminal] parser handler "${handlerName}" threw`, error) + recordRendererCrashBreadcrumb('terminal_parser_handler_error', { + handler: handlerName, + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error) + }) + } + return false + } + } +} + +export function _resetParserHandlerReportsForTests(): void { + reportCountsByHandler.clear() +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts new file mode 100644 index 00000000000..3280ca36dd1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts @@ -0,0 +1,123 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: false } })) + +describe('terminal-pty-ack-gate cumulative totals', () => { + const ackDataMock = vi.fn() + + beforeEach(() => { + // Why: the cumulative totals are module state; a fresh module per test + // mirrors a fresh renderer page (renderer lifecycle reset). + vi.resetModules() + ackDataMock.mockClear() + ;(window as unknown as { api: unknown }).api = { pty: { ackData: ackDataMock } } + }) + + afterEach(() => { + delete (window as unknown as { api?: unknown }).api + }) + + async function loadAckGate() { + return await import('./terminal-pty-ack-gate') + } + + it('sends monotonic cumulative totals alongside per-chunk deltas', async () => { + const { ackPtyData, getProcessedPtyCharTotals } = await loadAckGate() + + ackPtyData('pty-a', 5) + ackPtyData('pty-a', 7) + ackPtyData('pty-b', 3) + + expect(ackDataMock).toHaveBeenNthCalledWith(1, 'pty-a', 5, 5) + expect(ackDataMock).toHaveBeenNthCalledWith(2, 'pty-a', 7, 12) + expect(ackDataMock).toHaveBeenNthCalledWith(3, 'pty-b', 3, 3) + expect(getProcessedPtyCharTotals()).toEqual({ 'pty-a': 12, 'pty-b': 3 }) + }) + + it('clears a PTY total so a reused id restarts from zero on both sides', async () => { + const { ackPtyData, clearProcessedPtyCharTotal, getProcessedPtyCharTotals } = + await loadAckGate() + + ackPtyData('pty-a', 9) + clearProcessedPtyCharTotal('pty-a') + + expect(getProcessedPtyCharTotals()).toEqual({}) + + ackPtyData('pty-a', 4) + expect(ackDataMock).toHaveBeenLastCalledWith('pty-a', 4, 4) + }) +}) + +describe('terminal-pty-ack-gate parse-deferred crediting', () => { + const ackDataMock = vi.fn() + + beforeEach(() => { + vi.resetModules() + ackDataMock.mockClear() + ;(window as unknown as { api: unknown }).api = { pty: { ackData: ackDataMock } } + }) + + afterEach(() => { + delete (window as unknown as { api?: unknown }).api + }) + + async function loadAckGate() { + return await import('./terminal-pty-ack-gate') + } + + it('settles an unclaimed delivery credit at return', async () => { + const { deliverPtyDataWithDeferredAck } = await loadAckGate() + + deliverPtyDataWithDeferredAck('pty-a', 42, () => {}) + + expect(ackDataMock).toHaveBeenCalledWith('pty-a', 42, 42) + }) + + it('defers a claimed credit to the scheduler callback and fires once', async () => { + const { deliverPtyDataWithDeferredAck, takeCurrentPtyDeliveryAckCredit } = await loadAckGate() + let credit: (() => void) | null = null + + deliverPtyDataWithDeferredAck('pty-a', 10, () => { + credit = takeCurrentPtyDeliveryAckCredit() + }) + + // Claimed: nothing credited at delivery return. + expect(ackDataMock).not.toHaveBeenCalled() + credit!() + expect(ackDataMock).toHaveBeenCalledWith('pty-a', 10, 10) + // Fire-once: split slices / discard paths may re-invoke harmlessly. + credit!() + expect(ackDataMock).toHaveBeenCalledTimes(1) + }) + + it('hands out the credit only once per delivery', async () => { + const { deliverPtyDataWithDeferredAck, takeCurrentPtyDeliveryAckCredit } = await loadAckGate() + let first: (() => void) | null = null + let second: (() => void) | null = null + + deliverPtyDataWithDeferredAck('pty-a', 5, () => { + first = takeCurrentPtyDeliveryAckCredit() + second = takeCurrentPtyDeliveryAckCredit() + }) + + expect(first).not.toBeNull() + expect(second).toBeNull() + }) + + it('returns null outside a delivery', async () => { + const { takeCurrentPtyDeliveryAckCredit } = await loadAckGate() + expect(takeCurrentPtyDeliveryAckCredit()).toBeNull() + }) + + it('settles the credit when the handler throws so the PTY never wedges', async () => { + const { deliverPtyDataWithDeferredAck } = await loadAckGate() + + expect(() => + deliverPtyDataWithDeferredAck('pty-a', 7, () => { + throw new Error('bad sidecar') + }) + ).toThrow('bad sidecar') + expect(ackDataMock).toHaveBeenCalledWith('pty-a', 7, 7) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts index bea658434be..31733aebc47 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts @@ -18,13 +18,26 @@ type E2eTerminalPtyAckGateWindow = Window & { const e2eTerminalAckGatePtyIds = new Set() const e2eTerminalAckGateHeldChars = new Map() +// Why: monotonic per-PTY totals of processed chars, mirrored to main as +// TCP-style cumulative ACKs so a lost ACK message never becomes permanent +// in-flight debt. Cleared on pty:exit so a reused id restarts aligned with +// main's fresh accounting; a renderer reload resets it with the page. +const processedPtyCharTotals = new Map() + +function sendPtyAck(ptyId: string, chars: number): void { + const processedChars = (processedPtyCharTotals.get(ptyId) ?? 0) + chars + processedPtyCharTotals.set(ptyId, processedChars) + // Why: keep the legacy per-chunk delta alongside the cumulative total so an + // older main (dev hot-reload mix) still credits deltas. + window.api.pty.ackData?.(ptyId, chars, processedChars) +} function releaseE2eTerminalAckGate(): void { const held = Array.from(e2eTerminalAckGateHeldChars.entries()) e2eTerminalAckGatePtyIds.clear() e2eTerminalAckGateHeldChars.clear() for (const [ptyId, chars] of held) { - window.api.pty.ackData?.(ptyId, chars) + sendPtyAck(ptyId, chars) } } @@ -58,9 +71,81 @@ export function exposeE2eTerminalPtyAckGate(): void { } export function ackPtyData(ptyId: string, chars: number): void { + // Why: held e2e-gate chars stay out of the cumulative total too, so a + // delivery-resync probe cannot leak them past the simulated backpressure. if (e2eTerminalAckGatePtyIds.has(ptyId)) { e2eTerminalAckGateHeldChars.set(ptyId, (e2eTerminalAckGateHeldChars.get(ptyId) ?? 0) + chars) return } - window.api.pty.ackData?.(ptyId, chars) + sendPtyAck(ptyId, chars) +} + +// ─── Parse-deferred ACK crediting ─────────────────────────────────── +// Why: ACKing at dispatcher enqueue made main's 512KB in-flight window mean +// "bytes RECEIVED", not "bytes PARSED" — under flood the renderer's write +// queue grew unbounded behind instant ACKs, main saw no backpressure, crossed +// its pending cap, and dropped output (rc.7.perf DSR timeouts). Crediting is +// now deferred to the output scheduler's consume point, so in-flight becomes +// true parse backpressure and main's producer flow control pauses the shell +// instead of dropping. + +type DeferredPtyAckCredit = { + ptyId: string + chars: number + claimed: boolean + credited: boolean +} + +let currentDeliveryCredit: DeferredPtyAckCredit | null = null + +function creditDeferredPtyAck(credit: DeferredPtyAckCredit): void { + // Why fire-once: split queue chunks and discard paths may both touch the + // same delivery; the invariant is exactly one credit per delivered chunk. + if (credit.credited) { + return + } + credit.credited = true + ackPtyData(credit.ptyId, credit.chars) +} + +/** Runs one pty:data delivery with a parse-deferred ACK credit. If the + * handler hands bytes to the output scheduler, the claimed credit fires when + * the scheduler consumes (writes or discards) them; any credit left + * unclaimed fires here at return, so a chunk the handler drops outright can + * never leave main's in-flight window permanently open. */ +export function deliverPtyDataWithDeferredAck( + ptyId: string, + chars: number, + deliver: () => void +): void { + const credit: DeferredPtyAckCredit = { ptyId, chars, claimed: false, credited: false } + currentDeliveryCredit = credit + try { + deliver() + } finally { + currentDeliveryCredit = null + if (!credit.claimed) { + creditDeferredPtyAck(credit) + } + } +} + +/** Claims the in-progress delivery's credit for the output scheduler. Returns + * a fire-once callback, or null when outside a delivery or already claimed + * (only the FIRST scheduler write of a delivery carries the credit). */ +export function takeCurrentPtyDeliveryAckCredit(): (() => void) | null { + const credit = currentDeliveryCredit + if (!credit || credit.claimed) { + return null + } + credit.claimed = true + return () => creditDeferredPtyAck(credit) +} + +export function getProcessedPtyCharTotals(): Record { + return Object.fromEntries(processedPtyCharTotals) +} + +export function clearProcessedPtyCharTotal(ptyId: string): void { + processedPtyCharTotals.delete(ptyId) } diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts index 07770304a26..7acbd6a5ff9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts @@ -6,6 +6,25 @@ import { getUtf8ByteLength } from '../../../../shared/utf8-byte-limits' const LEAF_ID = '11111111-1111-4111-8111-111111111111' as const const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222' as const +// Why: capture now appends an absolute cursor restore (see +// terminal-serialize-absolute-cursor.ts), so pane mocks must expose the +// cursor fields and content expectations carry the home-position CUP suffix. +const CURSOR_HOME = '\x1b[1;1H' + +function mockTerminal(scrollback: number): { + options: { scrollback: number } + cols: number + rows: number + buffer: { active: { cursorX: number; cursorY: number } } +} { + return { + options: { scrollback }, + cols: 80, + rows: 24, + buffer: { active: { cursorX: 0, cursorY: 0 } } + } +} + const mocks = vi.hoisted(() => ({ flushTerminalOutput: vi.fn() })) @@ -76,7 +95,7 @@ describe('captureTerminalShutdownLayout', () => { const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture') const order: string[] = [] const terminal = { - options: { scrollback: 1_000 }, + ...mockTerminal(1_000), pendingOutput: '' } const pane = { @@ -115,7 +134,7 @@ describe('captureTerminalShutdownLayout', () => { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: LEAF_ID, expandedLeafId: null, - buffersByLeafId: { [LEAF_ID]: 'snapshot:queued-before-quit' }, + buffersByLeafId: { [LEAF_ID]: `snapshot:queued-before-quit${CURSOR_HOME}` }, ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }, titlesByLeafId: { [LEAF_ID]: 'build logs' } }) @@ -127,7 +146,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 50_000 } }, + terminal: mockTerminal(50_000), serializeAddon: { serialize: vi.fn(() => 'x'.repeat(512 * 1024)) } @@ -166,7 +185,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 512 } }, + terminal: mockTerminal(512), serializeAddon: { serialize: vi.fn((options?: { scrollback?: number }) => multibyteRow.repeat(options?.scrollback ?? 0) @@ -192,7 +211,12 @@ describe('captureTerminalShutdownLayout', () => { expect(getUtf8ByteLength(buffer)).toBeLessThanOrEqual( TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT ) - expect(buffer).toHaveLength(TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT / 2) + // Each 'e-acute' row is 2048 UTF-8 bytes; the CUP suffix joins the byte + // accounting, so one fewer row fits than the bare limit would allow. + const fittingRows = Math.floor( + (TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT - CURSOR_HOME.length) / 2048 + ) + expect(buffer).toHaveLength(fittingRows * 1024 + CURSOR_HOME.length) }) it('does not preserve prior scrollback buffers or refs for a cleared leaf', async () => { @@ -201,7 +225,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } @@ -239,7 +263,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => 'dead scrollback') } @@ -248,7 +272,7 @@ describe('captureTerminalShutdownLayout', () => { id: 2, leafId: LEAF_ID_2, stablePaneId: LEAF_ID_2, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => 'live scrollback') } @@ -275,8 +299,8 @@ describe('captureTerminalShutdownLayout', () => { expect(layout.activeLeafId).toBe(LEAF_ID_2) expect(layout.ptyIdsByLeafId).toEqual({ [LEAF_ID_2]: 'pty-live' }) expect(layout.buffersByLeafId).toEqual({ - [LEAF_ID]: 'dead scrollback', - [LEAF_ID_2]: 'live scrollback' + [LEAF_ID]: `dead scrollback${CURSOR_HOME}`, + [LEAF_ID_2]: `live scrollback${CURSOR_HOME}` }) }) @@ -286,7 +310,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => 'first scrollback') } @@ -295,7 +319,7 @@ describe('captureTerminalShutdownLayout', () => { id: 2, leafId: LEAF_ID_2, stablePaneId: LEAF_ID_2, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => 'second scrollback') } @@ -335,7 +359,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } @@ -344,7 +368,7 @@ describe('captureTerminalShutdownLayout', () => { id: 2, leafId: LEAF_ID_2, stablePaneId: LEAF_ID_2, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } @@ -373,7 +397,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } @@ -382,7 +406,7 @@ describe('captureTerminalShutdownLayout', () => { id: 2, leafId: LEAF_ID_2, stablePaneId: LEAF_ID_2, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts index c0ee1de1713..4e6d1698787 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts @@ -6,6 +6,7 @@ import { serializeTerminalLayout } from './layout-serialization' import { mergeCapturedLeafState } from './merge-captured-leaf-state' import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' +import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' import { measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits' const MAX_BUFFER_BYTES = TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT @@ -66,7 +67,12 @@ export function captureTerminalShutdownLayout({ flushTerminalOutput(pane.terminal) const leafId = pane.leafId let scrollback = pane.terminal.options.scrollback ?? 10_000 - let serialized = pane.serializeAddon.serialize({ scrollback }) + // Why serializeWithAbsoluteCursor: these buffers replay into fresh + // xterms on session restore, and SerializeAddon's relative cursor + // restore lands one column short after a wrap-pending final row. + let serialized = serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { + scrollback + }) // Why: SSH sleep keeps this string in session JSON; cap by UTF-8 // bytes so non-ASCII scrollback cannot bypass the intended bound. if (!fitsSessionScrollbackByteLimit(serialized) && scrollback > 1) { @@ -75,7 +81,9 @@ export function captureTerminalShutdownLayout({ let best = '' while (lo <= hi) { const mid = Math.floor((lo + hi) / 2) - const attempt = pane.serializeAddon.serialize({ scrollback: mid }) + const attempt = serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { + scrollback: mid + }) if (fitsSessionScrollbackByteLimit(attempt)) { best = attempt lo = mid + 1 diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts new file mode 100644 index 00000000000..84a91c2e7c4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -0,0 +1,509 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSideEffectBatch } from '../../../../shared/terminal-side-effect-facts' +import { + _dispatchTerminalSideEffectBatchForTest, + _resetTerminalSideEffectFactConsumersForTest, + isMainTerminalSideEffectAuthorityForPty, + registerTerminalSideEffectFactConsumer, + type TerminalSideEffectFactConsumerCallbacks +} from './terminal-side-effect-facts-handler' + +const PTY_ID = 'wt-1#1' + +function createCallbackRecorder(): { + callbacks: TerminalSideEffectFactConsumerCallbacks + events: unknown[][] +} { + const events: unknown[][] = [] + return { + events, + callbacks: { + onTitleChange: (normalizedTitle, rawTitle) => + events.push(['title', normalizedTitle, rawTitle]), + onBell: () => events.push(['bell']), + onAgentBecameIdle: (title) => events.push(['idle', title]), + onAgentBecameWorking: () => events.push(['working']), + onAgentExited: () => events.push(['exited']) + } + } +} + +function batch( + facts: TerminalSideEffectBatch['facts'], + options: Partial = {} +): TerminalSideEffectBatch { + return { ptyId: PTY_ID, seq: 0, facts, ...options } +} + +describe('isMainTerminalSideEffectAuthorityForPty', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + beforeEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + delete (globalThis as { window?: unknown }).window + }) + + afterEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + function setPersistedSettingsSync(settings: unknown): void { + ;(globalThis as { window: unknown }).window = { + api: { settings: { getSync: () => settings } } + } + } + + it('is on by default for PTYs whose bytes transit local main', () => { + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: {}, runtimeEnvironmentId: null }) + ).toBe(true) + // Why: settings hydrate asynchronously; the default-on switch must not + // flip authority off during the null-settings startup window. + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(true) + }) + + it('is off for remote-runtime PTYs regardless of the setting', () => { + expect( + isMainTerminalSideEffectAuthorityForPty({ + settings: { terminalMainSideEffectAuthority: true }, + runtimeEnvironmentId: 'env-1' + }) + ).toBe(false) + }) + + it('is off when the kill switch is disabled', () => { + expect( + isMainTerminalSideEffectAuthorityForPty({ + settings: { terminalMainSideEffectAuthority: false }, + runtimeEnvironmentId: null + }) + ).toBe(false) + }) + + it('honors the persisted kill switch before settings hydrate', () => { + // Why: the authority decision is made once at transport creation; a pane + // bound during startup must not pick main authority when the user + // persisted the switch off. + setPersistedSettingsSync({ terminalMainSideEffectAuthority: false }) + + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(false) + }) + + it('stays on pre-hydration when the persisted switch is on or unset', () => { + setPersistedSettingsSync({ terminalMainSideEffectAuthority: true }) + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(true) + + _resetTerminalSideEffectFactConsumersForTest() + setPersistedSettingsSync({}) + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(true) + }) + + it('prefers hydrated settings over the persisted sync read', () => { + setPersistedSettingsSync({ terminalMainSideEffectAuthority: false }) + + expect( + isMainTerminalSideEffectAuthorityForPty({ + settings: { terminalMainSideEffectAuthority: true }, + runtimeEnvironmentId: null + }) + ).toBe(true) + }) + + it('caches the sync read so panes do not re-block per bind', () => { + const getSync = vi.fn(() => ({ terminalMainSideEffectAuthority: false })) + ;(globalThis as { window: unknown }).window = { api: { settings: { getSync } } } + + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + + expect(getSync).toHaveBeenCalledTimes(1) + }) +}) + +describe('registerTerminalSideEffectFactConsumer', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + beforeEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + delete (globalThis as { window?: unknown }).window + }) + + afterEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('routes live facts to the registered consumer in batch order', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'title', normalizedTitle: '⠋ Claude', rawTitle: '⠋ Claude' }, + { kind: 'agent-working' }, + { kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' }, + { kind: 'agent-idle', title: '✳ Claude' }, + { kind: 'bell' } + ]) + ) + + expect(events).toEqual([ + ['title', '⠋ Claude', '⠋ Claude'], + ['working'], + ['title', '✳ Claude', '✳ Claude'], + ['idle', '✳ Claude'], + ['bell'] + ]) + }) + + it('routes command-finished and pr-link facts to the registered consumer', () => { + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onCommandFinished: (exitCode) => events.push(['finished', exitCode]), + onPrLink: (link) => events.push(['pr', link.url, link.number]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'command-finished', exitCode: 130 }, + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + }, + { kind: 'command-finished', exitCode: null } + ]) + ) + + expect(events).toEqual([ + ['finished', 130], + ['pr', 'https://github.com/acme/orca/pull/42', 42], + ['finished', null] + ]) + }) + + it('routes command-code scrape facts to the registered consumer', () => { + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]), + onCommandCodeDone: (prompt) => events.push(['cc-done', prompt]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'command-code-working', prompt: 'Fix the spinner' }, + { kind: 'command-code-done', prompt: 'Fix the spinner' } + ]) + ) + + expect(events).toEqual([ + ['cc-working', 'Fix the spinner'], + ['cc-done', 'Fix the spinner'] + ]) + }) + + it('never replays command-code scrape facts', () => { + // Why: a replayed working/done seed would resurrect a finished turn's + // status row — replay batches restore title state only. + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]), + onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]), + onCommandCodeDone: (prompt) => events.push(['cc-done', prompt]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }, + { kind: 'command-code-working', prompt: 'Fix the spinner' }, + { kind: 'command-code-done', prompt: 'Fix the spinner' } + ], + { replay: true, seq: 5 } + ) + ) + + expect(events).toEqual([['title', 'restored']]) + }) + + it('routes 2031-subscribe facts to the registered consumer but never replays them', () => { + // Why: the fact lets hidden-delivery-gated views answer the color-scheme + // query without byte access; a replayed subscribe would re-answer a query + // the snapshot already satisfied. + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]), + onMode2031Subscribe: () => events.push(['2031-subscribe']) + } + }) + + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: '2031-subscribe' }])) + expect(events).toEqual([['2031-subscribe']]) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }, + { kind: '2031-subscribe' } + ], + { replay: true, seq: 5 } + ) + ) + expect(events).toEqual([['2031-subscribe'], ['title', 'restored']]) + }) + + it('never replays command-finished or pr-link facts', () => { + // Why: like bells and agent transitions, command/PR facts are attention + // signals — replay snapshots restore title state only. + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]), + onCommandFinished: (exitCode) => events.push(['finished', exitCode]), + onPrLink: (link) => events.push(['pr', link.url]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }, + { kind: 'command-finished', exitCode: 0 }, + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + } + ], + { replay: true, seq: 5 } + ) + ) + + expect(events).toEqual([['title', 'restored']]) + }) + + it('passes stale-clear provenance through to the title and idle callbacks', () => { + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle, _rawTitle, meta) => + events.push(['title', normalizedTitle, meta]), + onAgentBecameIdle: (title, meta) => events.push(['idle', title, meta]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'agent-idle', title: 'Codex done' }, + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ]) + ) + + expect(events).toEqual([ + ['idle', 'Codex done', undefined], + ['title', 'Codex', { staleWorkingTitleClear: true }], + ['idle', 'Codex', { staleWorkingTitleClear: true }] + ]) + }) + + it('drops batches for PTYs without a registered consumer', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { ptyId: 'other-pty' })) + + expect(events).toEqual([]) + }) + + it('applies only title facts from replay batches — no attention replay', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' }, + { kind: 'bell' }, + { kind: 'agent-idle', title: '✳ Claude' } + ], + { replay: true, seq: 10 } + ) + ) + + expect(events).toEqual([['title', '✳ Claude', '✳ Claude']]) + }) + + it('drops a replay title not newer than the last applied live title', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'title', normalizedTitle: 'live', rawTitle: 'live' }], { seq: 20 }) + ) + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'title', normalizedTitle: 'stale', rawTitle: 'stale' }], { + replay: true, + seq: 20 + }) + ) + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'title', normalizedTitle: 'newer', rawTitle: 'newer' }], { + replay: true, + seq: 21 + }) + ) + + expect(events).toEqual([ + ['title', 'live', 'live'], + ['title', 'newer', 'newer'] + ]) + }) + + it('keeps exactly one consumer per PTY: a new registration replaces the old', () => { + const first = createCallbackRecorder() + const second = createCallbackRecorder() + const disposeFirst = registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: first.callbacks + }) + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks }) + + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) + // A stale registration's dispose must not evict the live consumer. + disposeFirst() + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) + + expect(first.events).toEqual([]) + expect(second.events).toEqual([['bell'], ['bell']]) + }) + + it('stops routing after the consumer unregisters', () => { + const { callbacks, events } = createCallbackRecorder() + const dispose = registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + dispose() + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) + + expect(events).toEqual([]) + }) + + it('subscribes to the channel once and routes IPC batches', () => { + let channelCallback: ((batch: TerminalSideEffectBatch) => void) | null = null + const onSideEffect = vi.fn((callback: (batch: TerminalSideEffectBatch) => void) => { + channelCallback = callback + return () => { + channelCallback = null + } + }) + ;(globalThis as { window: unknown }).window = { + api: { pty: { onSideEffect } } + } + const first = createCallbackRecorder() + const second = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: first.callbacks }) + registerTerminalSideEffectFactConsumer({ ptyId: 'pty-2', callbacks: second.callbacks }) + + expect(onSideEffect).toHaveBeenCalledTimes(1) + channelCallback!(batch([{ kind: 'bell' }], { ptyId: 'pty-2' })) + expect(second.events).toEqual([['bell']]) + }) + + it('applies the title snapshot on register unless the registration was replaced', async () => { + let resolveSnapshot: (value: TerminalSideEffectBatch | null) => void = () => {} + const getSideEffectSnapshot = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + ;(globalThis as { window: unknown }).window = { + api: { pty: { getSideEffectSnapshot } } + } + + const first = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: first.callbacks, + restoreTitleOnRegister: true + }) + expect(getSideEffectSnapshot).toHaveBeenCalledWith(PTY_ID) + + // Replace before the snapshot resolves: the slow snapshot must not fire + // into the superseded registration. + const second = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks }) + resolveSnapshot( + batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], { + replay: true, + seq: 5 + }) + ) + await Promise.resolve() + + expect(first.events).toEqual([]) + expect(second.events).toEqual([]) + }) + + it('restores the snapshot title for a live registration', async () => { + const snapshot = batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], { + replay: true, + seq: 5 + }) + ;(globalThis as { window: unknown }).window = { + api: { pty: { getSideEffectSnapshot: vi.fn(async () => snapshot) } } + } + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks, + restoreTitleOnRegister: true + }) + + await Promise.resolve() + await Promise.resolve() + + expect(events).toEqual([['title', 'restored', 'restored']]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts new file mode 100644 index 00000000000..6fffdbea1a4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -0,0 +1,242 @@ +/** + * Renderer consumer registry for the `pty:sideEffect` channel. + * + * Why: with main as the side-effect parser for local-daemon/SSH PTYs + * (docs/reference/terminal-side-effect-authority.md), the renderer no longer + * derives title/bell/agent facts from bytes for those PTYs. This module is + * the single channel subscriber; mounted panes and parked-tab watchers + * register exactly one fact consumer per PTY (their existing policy + * callbacks), so every fact has exactly one policy consumer regardless of + * whether the tab is mounted, hidden, or parked. Facts for PTYs without a + * registered consumer are dropped — mirroring today's eager-buffer behavior + * where pre-mount output produces no attention side effects. + */ +import type { GlobalSettings } from '../../../../shared/types' +import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' +import type { + TerminalSideEffectBatch, + TerminalSideEffectFact +} from '../../../../shared/terminal-side-effect-facts' + +// Why: cached once per session — the blocking read should only ever run on +// the pre-hydration startup path, never per pane bind. +let persistedAuthorityFlagCache: boolean | null | undefined + +function readPersistedSideEffectAuthorityFlagSync(): boolean | null { + if (persistedAuthorityFlagCache === undefined) { + try { + const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync + persistedAuthorityFlagCache = + typeof getSync === 'function' ? (getSync()?.terminalMainSideEffectAuthority ?? null) : null + } catch { + persistedAuthorityFlagCache = null + } + } + return persistedAuthorityFlagCache +} + +/** + * Structural authority predicate: main owns side effects for a PTY when its + * bytes transit local main (everything except remote-runtime PTYs) and the + * kill switch is on. Decided at transport/watcher creation — never per chunk — + * so each fact has one consumer with no race. + */ +export function isMainTerminalSideEffectAuthorityForPty(args: { + settings: Pick | null + /** Remote-runtime owner environment; null means bytes transit local main. */ + runtimeEnvironmentId: string | null +}): boolean { + if (args.runtimeEnvironmentId !== null) { + return false + } + if (args.settings !== null) { + return args.settings.terminalMainSideEffectAuthority !== false + } + // Why: settings hydrate asynchronously, and the authority decision made + // here at transport/watcher creation is never revisited. A pane bound + // before hydration must honor the persisted kill switch — otherwise a user + // who turned main authority off gets startup panes with no byte parsers + // and a fact consumer they disabled. Surfaces without the sync read (web + // remote clients, tests) keep the default-on behavior. + return readPersistedSideEffectAuthorityFlagSync() !== false +} + +export type TerminalSideEffectFactConsumerCallbacks = { + /** `meta.staleWorkingTitleClear` marks facts derived from main's 3s + * stale-title timer — policy must clear title/cache state without + * scheduling task-complete notifications or unread attention. */ + onTitleChange?: ( + normalizedTitle: string, + rawTitle: string, + meta?: { staleWorkingTitleClear?: boolean } + ) => void + onBell?: () => void + onAgentBecameIdle?: (title: string, meta?: { staleWorkingTitleClear?: boolean }) => void + onAgentBecameWorking?: () => void + onAgentExited?: () => void + /** OSC 133;D — same policy hook the byte-mode commandLifecycle drove + * (stale agent-status row drop + interrupt-inference coordination). */ + onCommandFinished?: (bestEffortExitCode: number | null) => void + onPrLink?: (link: TerminalGitHubPRLink) => void + /** Command Code output scrape (no hooks): working seeds the status row; + * done is settle-checked by the pane policy before completing the turn. */ + onCommandCodeWorking?: (prompt: string) => void + onCommandCodeDone?: (prompt: string) => void + /** DECSET 2031 subscribe observed by main's tracker. Registered only by + * hidden-delivery-gated consumers (their bytes never arrive); the theme + * reply is sent renderer-side — query authority stays with the view. */ + onMode2031Subscribe?: () => void +} + +type ConsumerEntry = { + callbacks: TerminalSideEffectFactConsumerCallbacks + /** Output sequence of the last live title fact applied. Replay snapshots at + * or before this point are stale and must not regress the title state. */ + lastLiveTitleSeq: number | null +} + +const consumersByPtyId = new Map() +let channelUnsubscribe: (() => void) | null = null + +function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: number): void { + switch (fact.kind) { + case 'title': + entry.lastLiveTitleSeq = seq + entry.callbacks.onTitleChange?.( + fact.normalizedTitle, + fact.rawTitle, + fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + ) + return + case 'bell': + entry.callbacks.onBell?.() + return + case 'agent-working': + entry.callbacks.onAgentBecameWorking?.() + return + case 'agent-idle': + entry.callbacks.onAgentBecameIdle?.( + fact.title, + fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + ) + return + case 'agent-exited': + entry.callbacks.onAgentExited?.() + return + case 'command-finished': + entry.callbacks.onCommandFinished?.(fact.exitCode) + return + case 'pr-link': + entry.callbacks.onPrLink?.(fact.link) + return + case 'command-code-working': + entry.callbacks.onCommandCodeWorking?.(fact.prompt) + return + case 'command-code-done': + entry.callbacks.onCommandCodeDone?.(fact.prompt) + return + case '2031-subscribe': + entry.callbacks.onMode2031Subscribe?.() + } +} + +function applyBatchToConsumer(entry: ConsumerEntry, batch: TerminalSideEffectBatch): void { + if (batch.replay) { + // Why: the no-attention-replay rule — (re)attach snapshots restore title + // state only; historical bells/completions must never fire again. A replay + // older (by output sequence) than the last live title fact is stale. + if (entry.lastLiveTitleSeq !== null && batch.seq <= entry.lastLiveTitleSeq) { + return + } + for (const fact of batch.facts) { + if (fact.kind === 'title') { + entry.callbacks.onTitleChange?.(fact.normalizedTitle, fact.rawTitle) + } + } + return + } + for (const fact of batch.facts) { + applyLiveFact(entry, fact, batch.seq) + } +} + +function handleSideEffectBatch(batch: TerminalSideEffectBatch): void { + const entry = consumersByPtyId.get(batch.ptyId) + if (!entry) { + return + } + applyBatchToConsumer(entry, batch) +} + +function ensureSideEffectChannelSubscription(): void { + if (channelUnsubscribe !== null) { + return + } + // Why: optional-chained from globalThis so unit tests (and any non-preload + // surface) without window.api degrade to "no channel" instead of throwing. + const onSideEffect = (globalThis as { window?: Window }).window?.api?.pty?.onSideEffect + if (typeof onSideEffect !== 'function') { + return + } + channelUnsubscribe = onSideEffect(handleSideEffectBatch) +} + +export type TerminalSideEffectFactConsumerOptions = { + ptyId: string + callbacks: TerminalSideEffectFactConsumerCallbacks + /** Pull main's title-only replay snapshot on registration. Pane transports + * use this in place of deriving titles from eager-buffer byte replay; + * parked watchers skip it because the pane's runtime title slot is already + * current at park time. */ + restoreTitleOnRegister?: boolean +} + +/** + * Register the single fact consumer for a PTY. A new registration replaces a + * stale one for the same PTY (same semantics as the parked watcher registry): + * two consumers would double-fire bell/completion policy for the same bytes. + */ +export function registerTerminalSideEffectFactConsumer( + options: TerminalSideEffectFactConsumerOptions +): () => void { + ensureSideEffectChannelSubscription() + const entry: ConsumerEntry = { + callbacks: options.callbacks, + lastLiveTitleSeq: null + } + consumersByPtyId.set(options.ptyId, entry) + + if (options.restoreTitleOnRegister) { + const getSnapshot = (globalThis as { window?: Window }).window?.api?.pty?.getSideEffectSnapshot + if (typeof getSnapshot === 'function') { + void getSnapshot(options.ptyId) + .then((batch) => { + // Why: apply only while this registration is still the live + // consumer; a slow snapshot must not fire into a replaced one. + if (batch && consumersByPtyId.get(options.ptyId) === entry) { + applyBatchToConsumer(entry, { ...batch, replay: true }) + } + }) + .catch(() => {}) + } + } + + return () => { + if (consumersByPtyId.get(options.ptyId) === entry) { + consumersByPtyId.delete(options.ptyId) + } + } +} + +/** Test seam: deliver a batch as if it arrived on the channel. */ +export function _dispatchTerminalSideEffectBatchForTest(batch: TerminalSideEffectBatch): void { + handleSideEffectBatch(batch) +} + +/** Test seam: reset module state between tests. */ +export function _resetTerminalSideEffectFactConsumersForTest(): void { + consumersByPtyId.clear() + channelUnsubscribe?.() + channelUnsubscribe = null + persistedAuthorityFlagCache = undefined +} diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts new file mode 100644 index 00000000000..ae5fa99b63f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -0,0 +1,378 @@ +// Why: Phase 3 slice 1 of terminal-side-effect-authority.md runs a per-PTY +// title tracker in main alongside the renderer transport's byte parser. Both +// must derive IDENTICAL ordered title/status facts from the same bytes, or +// main-side consumers (tui-idle waiters, worktree ps, mobile titles) drift +// from what the renderer shows. This harness feeds identical byte fixtures +// through the renderer `createPtyOutputProcessor` and through main's +// consumption shape (OSC 9999 strip → shared title tracker) and asserts the +// event sequences match. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createAgentStatusOscProcessor } from '../../../../shared/agent-status-osc' +import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status' +import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' +import { createTerminalTitleTracker } from '../../../../shared/terminal-output-side-effects' +import { createPtyOutputProcessor } from './pty-transport' +import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' + +const ESC = '\x1b' +const BEL = '\x07' +const ST = `${ESC}\\` + +type TitleFactEvent = + | { kind: 'title'; normalized: string; raw: string } + | { kind: 'became-working' } + | { kind: 'became-idle'; title: string } + | { kind: 'agent-exited' } + | { kind: 'bell' } + +type TitleFactPath = { + events: TitleFactEvent[] + feed: (chunk: string) => void +} + +function createRendererPath(): TitleFactPath { + const events: TitleFactEvent[] = [] + const processor = createPtyOutputProcessor({ + onTitleChange: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), + onAgentBecameWorking: () => events.push({ kind: 'became-working' }), + onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), + onAgentExited: () => events.push({ kind: 'agent-exited' }), + onBell: () => events.push({ kind: 'bell' }) + }) + const callbacks = { onData: () => {} } + return { + events, + feed(chunk: string): void { + processor.processData(chunk, callbacks) + // Why: the renderer defers side effects behind a setTimeout(0) drain to + // protect xterm paint. Flush synchronously so both paths observe each + // chunk at the same fake-timer instant. + processor.flushPendingSideEffects() + } + } +} + +function createMainPath(): TitleFactPath { + const events: TitleFactEvent[] = [] + // Why: mirrors OrcaRuntimeService.onPtyData — the per-PTY OSC 9999 + // processor strips status payloads before the title tracker sees the chunk. + const processAgentStatusChunk = createAgentStatusOscProcessor() + const tracker = createTerminalTitleTracker({ + onTitle: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), + onAgentBecameWorking: () => events.push({ kind: 'became-working' }), + onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), + onAgentExited: () => events.push({ kind: 'agent-exited' }), + onBell: () => events.push({ kind: 'bell' }) + }) + return { + events, + feed(chunk: string): void { + tracker.handleChunk(processAgentStatusChunk(chunk).cleanData) + } + } +} + +type ChunkFeed = { feed: (chunk: string) => void } + +function feedBoth(paths: { renderer: ChunkFeed; main: ChunkFeed }, chunk: string): void { + paths.renderer.feed(chunk) + paths.main.feed(chunk) +} + +describe('main title tracker parity with the renderer transport processor', () => { + let paths: { renderer: TitleFactPath; main: TitleFactPath } + + beforeEach(() => { + vi.useFakeTimers() + paths = { renderer: createRendererPath(), main: createMainPath() } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('derives identical facts from a coalesced spinner+idle chunk (issue #1083)', () => { + // One realistic node-pty batch: Pi's 80ms spinner frames plus agent_end's + // trailing idle title. A last-title reader sees only the idle title and + // never observes the working state. + const chunk = + `${ESC}]0;⠋ π - cwd${BEL}response text\r\n` + + `${ESC}]0;⠙ π - cwd${BEL}more text\r\n` + + `${ESC}]0;π - cwd${BEL}` + feedBoth(paths, chunk) + + expect(paths.main.events).toEqual(paths.renderer.events) + const kinds = paths.main.events.map((event) => event.kind) + expect(kinds).toContain('became-working') + expect(kinds.indexOf('became-working')).toBeLessThan(kinds.indexOf('became-idle')) + }) + + it('derives identical facts from BEL- and ST-terminated titles', () => { + feedBoth(paths, `${ESC}]2;Codex working${ST}body bytes`) + feedBoth(paths, `${ESC}]0;Codex done${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toContainEqual({ kind: 'became-idle', title: 'Codex done' }) + }) + + it('drops the bare cursor-agent native title in both paths', () => { + feedBoth(paths, `${ESC}]0;⠋ Cursor Agent${BEL}`) + feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + const titles = paths.main.events.filter((event) => event.kind === 'title') + expect(titles).toEqual([{ kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' }]) + }) + + it('clears a stale working title after the 3s timeout in both paths', () => { + feedBoth(paths, `${ESC}]0;. Claude working${BEL}`) + feedBoth(paths, 'output with no title\r\n') + + vi.advanceTimersByTime(3_000) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.at(-1)).toEqual({ kind: 'became-idle', title: 'Claude' }) + }) + + it('keeps the stale-title timer unperturbed by pure OSC 9999 status chunks', () => { + feedBoth(paths, `${ESC}]0;Codex working${BEL}`) + feedBoth(paths, 'plain output arms the timer\r\n') + + vi.advanceTimersByTime(2_000) + // Why: a chunk that is ONLY an Orca status payload strips to empty + // cleanData; neither path may restart (or newly arm) the stale probe. + feedBoth(paths, `${ESC}]9999;{"state":"working","agentType":"codex"}${BEL}`) + vi.advanceTimersByTime(1_000) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.at(-1)).toEqual({ kind: 'became-idle', title: 'Codex' }) + }) + + it('ignores a title split across chunk boundaries in both paths', () => { + feedBoth(paths, `${ESC}]0;split-ti`) + feedBoth(paths, `tle${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) + + it('orders a real BEL after the same chunk titles in both paths', () => { + feedBoth(paths, `${ESC}]0;⠋ Claude working${BEL}done text${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.map((event) => event.kind)).toEqual([ + 'title', + 'became-working', + 'bell' + ]) + }) + + it('never reports an OSC-terminator BEL as a bell, even spanning chunks', () => { + feedBoth(paths, `${ESC}]0;par`) + feedBoth(paths, `tial title${BEL}`) + feedBoth(paths, `${ESC}]2;st-terminated${ST}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.filter((event) => event.kind === 'bell')).toEqual([]) + }) + + it('treats a BEL after a CAN-cancelled OSC as a real bell in both paths', () => { + // ECMA-48 CAN aborts the in-progress OSC; the next BEL is a real bell. + feedBoth(paths, `${ESC}]0;truncated`) + feedBoth(paths, `\x18${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([{ kind: 'bell' }]) + }) + + it('keeps bells suppressed inside OSC 9999 status payloads in both paths', () => { + feedBoth(paths, `${ESC}]9999;{"state":"working","agentType":"codex"}${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) +}) + +// Why: slice 3 moves the renderer's OSC 133;D and PR-link byte parsing into +// main's tracker for local/SSH PTYs. Both must derive identical fact +// sequences from the same chunk boundaries, or flipping the kill switch +// changes which commands/links are observed. +type LifecycleFactEvent = ['command-finished', number | null] | ['pr-link', string, number] + +type LifecycleFactPath = { + events: LifecycleFactEvent[] + feed: (chunk: string) => void +} + +function createRendererLifecyclePath(): LifecycleFactPath { + const events: LifecycleFactEvent[] = [] + // Why: mirrors pty-connection's dataCallback wiring — the transport + // processor strips OSC 9999 before the lifecycle/PR-link byte scans run. + const processAgentStatusChunk = createAgentStatusOscProcessor() + const lifecycle = createTerminalCommandLifecycle({ + onCommandFinished: (exitCode) => events.push(['command-finished', exitCode]) + }) + const detectPRLinks = createTerminalGitHubPRLinkDetector() + return { + events, + feed(chunk: string): void { + const clean = processAgentStatusChunk(chunk).cleanData + lifecycle.handlePtyData(clean) + for (const link of detectPRLinks(clean)) { + events.push(['pr-link', link.url, link.number]) + } + } + } +} + +function createMainLifecyclePath(): LifecycleFactPath { + const events: LifecycleFactEvent[] = [] + const processAgentStatusChunk = createAgentStatusOscProcessor() + const tracker = createTerminalTitleTracker({ + onCommandFinished: (exitCode) => events.push(['command-finished', exitCode]), + onPrLink: (link) => events.push(['pr-link', link.url, link.number]) + }) + return { + events, + feed(chunk: string): void { + tracker.handleChunk(processAgentStatusChunk(chunk).cleanData) + } + } +} + +describe('main tracker parity with renderer 133;D and PR-link byte parsers', () => { + let paths: { renderer: LifecycleFactPath; main: LifecycleFactPath } + + beforeEach(() => { + paths = { renderer: createRendererLifecyclePath(), main: createMainLifecyclePath() } + }) + + it('derives identical command-finished facts from split OSC 133;D chunks', () => { + feedBoth(paths, `output${ESC}]133`) + feedBoth(paths, ';D;13') + feedBoth(paths, `0${BEL}prompt $ `) + feedBoth(paths, `${ESC}]133;D;0${BEL}`) + feedBoth(paths, `${ESC}]133;D${ST}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([ + ['command-finished', 130], + ['command-finished', 0], + ['command-finished', null] + ]) + }) + + it('derives identical pr-link facts from split and repeated URLs', () => { + feedBoth(paths, 'Created https://github.com/acme/orca/pull/4') + feedBoth(paths, '2\r\nAlso https://github.com/acme/orca/pull/43 merged\r\n') + feedBoth(paths, 'again https://github.com/acme/orca/pull/42\r\n') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([ + ['pr-link', 'https://github.com/acme/orca/pull/42', 42], + ['pr-link', 'https://github.com/acme/orca/pull/43', 43] + ]) + }) + + it('ignores 133;D and PR URLs inside stripped OSC 9999 payloads in both paths', () => { + feedBoth( + paths, + `${ESC}]9999;{"state":"done","prompt":"https://github.com/acme/orca/pull/9"}${BEL}\r\n` + ) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) +}) + +// Why: slice 4 moves the Command Code output scrape into main for local/SSH +// PTYs. The renderer byte path observes raw transport data; main observes the +// OSC 9999-stripped cleanData. Both must derive identical working/done +// sequences from the same chunk boundaries, or flipping the kill switch +// changes Command Code status rows. +type CommandCodeFactEvent = ['working' | 'done', string] + +type CommandCodeFactPath = { + events: CommandCodeFactEvent[] + feed: (chunk: string) => void +} + +function createCommandCodePath(options: { stripStatusPayloads: boolean }): CommandCodeFactPath { + const events: CommandCodeFactEvent[] = [] + const processAgentStatusChunk = createAgentStatusOscProcessor() + const detector = createCommandCodeOutputStatusDetector({ + startupCommand: null, + onWorking: (prompt) => events.push(['working', prompt]), + onDone: (prompt) => events.push(['done', prompt]) + }) + return { + events, + feed(chunk: string): void { + detector.observe( + options.stripStatusPayloads ? processAgentStatusChunk(chunk).cleanData : chunk + ) + } + } +} + +describe('main Command Code scrape parity with the renderer byte detector', () => { + let paths: { renderer: CommandCodeFactPath; main: CommandCodeFactPath } + + beforeEach(() => { + paths = { + renderer: createCommandCodePath({ stripStatusPayloads: false }), + main: createCommandCodePath({ stripStatusPayloads: true }) + } + }) + + it('derives identical working facts after the banner arms across chunks', () => { + feedBoth(paths, '# Command') + feedBoth(paths, ' Code v0.27.3\r\n') + feedBoth(paths, '❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([['working', 'Fix the spinner']]) + }) + + it('derives identical done facts for a no-tool turn in both paths', () => { + feedBoth(paths, '# Command Code v0.27.3\r\n') + feedBoth(paths, '❯ say hi\r\n✻ Thinking...') + feedBoth(paths, '\r\n:: Hi!\r\n❯ Ask your question...') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([ + ['working', 'say hi'], + ['done', 'say hi'] + ]) + }) + + it('recovers prompt capture from interleaved OSC 9999 payloads (main improvement)', () => { + // Deliberate divergence, not drift: the renderer's raw byte path lets an + // OSC 9999 payload leak partial text into the scrape window (its ANSI + // strip consumes only the ESC] introducer), which breaks the prompt-echo + // line match. Main feeds the OSC 9999-stripped cleanData, so the prompt + // (and therefore the done settle hint) survives an adjacent payload. + const payloadThenPrompt = [ + '# Command Code v0.27.3\r\n', + `${ESC}]9999;{"state":"working","agentType":"command-code"}${BEL}`, + '❯ say hi\r\n✻ Thinking...', + '\r\n:: Hi!\r\n❯ Ask your question...' + ] + for (const chunk of payloadThenPrompt) { + feedBoth(paths, chunk) + } + + expect(paths.renderer.events).toEqual([['working', '']]) + expect(paths.main.events).toEqual([ + ['working', 'say hi'], + ['done', 'say hi'] + ]) + }) + + it('stays silent without the Command Code banner in both paths', () => { + feedBoth(paths, '❯ Fix the spinner\r\nThinking about unrelated shell output...') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts new file mode 100644 index 00000000000..ea7613ea354 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts @@ -0,0 +1,249 @@ +/** + * View-attribute bridge publication (terminal-query-authority.md §View- + * attribute bridge): the composed snapshot must mirror xterm ThemeService + * resolution (defaults, cursor blend, 256-entry palette), and pushes must + * happen once per actual change — not per pane, not per font tweak. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' +import { getDefaultSettings } from '../../../../shared/constants' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { applyTerminalAppearance } from './terminal-appearance' +import { + _resetTerminalViewAttributesPublisherForTest, + composeTerminalViewAttributes, + publishTerminalViewAttributes +} from './terminal-view-attributes-publisher' + +const cursorSettings = { + terminalCursorStyle: 'block' as const, + terminalCursorBlink: true +} + +beforeEach(() => { + _resetTerminalViewAttributesPublisherForTest() + vi.unstubAllGlobals() +}) + +describe('composeTerminalViewAttributes', () => { + it('resolves a null theme to the xterm ThemeService defaults', () => { + const attrs = composeTerminalViewAttributes(null, 'dark', cursorSettings) + expect(attrs.foreground).toEqual([0xff, 0xff, 0xff]) + expect(attrs.background).toEqual([0x00, 0x00, 0x00]) + expect(attrs.cursor).toEqual([0xff, 0xff, 0xff]) + expect(attrs.ansi).toHaveLength(256) + // DEFAULT_ANSI_COLORS parity: named 16, color cube, greys. + expect(attrs.ansi[0]).toEqual([0x2e, 0x34, 0x36]) + expect(attrs.ansi[1]).toEqual([0xcc, 0x00, 0x00]) + expect(attrs.ansi[15]).toEqual([0xee, 0xee, 0xec]) + expect(attrs.ansi[16]).toEqual([0x00, 0x00, 0x00]) + expect(attrs.ansi[196]).toEqual([0xff, 0x00, 0x00]) + expect(attrs.ansi[232]).toEqual([8, 8, 8]) + expect(attrs.ansi[255]).toEqual([238, 238, 238]) + expect(attrs.colorSchemeMode).toBe('dark') + expect(attrs.cursorStyle).toBe('block') + expect(attrs.cursorBlink).toBe(true) + }) + + it('parses composed theme colors including rgba() opacity forms', () => { + const attrs = composeTerminalViewAttributes( + { + // composeActiveTerminalTheme emits rgba() when terminalBackgroundOpacity + // or terminalCursorOpacity apply; the reply drops alpha like xterm's + // toColorRGB, except the cursor which blends over the background. + background: 'rgba(30, 30, 46, 0.9)', + foreground: '#d0d0d0', + cursor: 'rgba(255, 0, 0, 0.5)', + red: '#ff8800' + }, + 'light', + { terminalCursorStyle: 'underline', terminalCursorBlink: false } + ) + expect(attrs.background).toEqual([30, 30, 46]) + expect(attrs.foreground).toEqual([0xd0, 0xd0, 0xd0]) + // color.blend parity: a = round(0.5*255)/255; ch = bg + round((fg-bg)*a). + expect(attrs.cursor).toEqual([143, 15, 23]) + expect(attrs.ansi[1]).toEqual([0xff, 0x88, 0x00]) + expect(attrs.colorSchemeMode).toBe('light') + expect(attrs.cursorStyle).toBe('underline') + expect(attrs.cursorBlink).toBe(false) + }) + + it('keeps an opaque cursor un-blended and blends short-hex alpha', () => { + const attrs = composeTerminalViewAttributes( + { background: '#000000', cursor: '#ff0000' }, + 'dark', + cursorSettings + ) + expect(attrs.cursor).toEqual([255, 0, 0]) + + const blended = composeTerminalViewAttributes( + { background: '#000000', cursor: '#f00a' }, + 'dark', + cursorSettings + ) + // #f00a → alpha 0xaa: 0 + round(255 * (0xaa/0xff)) = 170. + expect(blended.cursor).toEqual([170, 0, 0]) + }) + + it('overlays extendedAnsi onto the default 256 palette tail', () => { + const attrs = composeTerminalViewAttributes( + { extendedAnsi: ['#102030'] }, + 'dark', + cursorSettings + ) + expect(attrs.ansi[16]).toEqual([0x10, 0x20, 0x30]) + // Untouched tail entries stay on the generated cube. + expect(attrs.ansi[17]).toEqual([0x00, 0x00, 0x5f]) + }) + + it('falls back to slot defaults for named colors (hand-edited settings divergence)', () => { + // A visible pane resolves named CSS via canvas; the composer cannot, so + // hand-edited values fall back — the documented divergence boundary. + const attrs = composeTerminalViewAttributes( + { red: 'darkred', foreground: 'hotpink' }, + 'dark', + cursorSettings + ) + expect(attrs.ansi[1]).toEqual([0xcc, 0x00, 0x00]) + expect(attrs.foreground).toEqual([0xff, 0xff, 0xff]) + }) +}) + +describe('publishTerminalViewAttributes dedupe', () => { + it('publishes once per snapshot change, not per call', () => { + const send = vi.fn(() => true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(false) + expect(send).toHaveBeenCalledTimes(1) + + // A real attribute change (theme flip) publishes again. + expect(publishTerminalViewAttributes(null, 'light', cursorSettings, send)).toBe(true) + expect(send).toHaveBeenCalledTimes(2) + }) + + it('does not record a failed send, so the next call retries', () => { + const failingSend = vi.fn(() => false) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, failingSend)).toBe(false) + + const send = vi.fn(() => true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(true) + }) + + it('skips silently when the preload bridge is unavailable (web client, tests)', () => { + // No window stub: default send must be a safe no-op. + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings)).toBe(false) + }) +}) + +describe('applyTerminalAppearance publication', () => { + function makePane(id: number): ManagedPane { + return { + id, + terminal: { options: {}, cols: 80, rows: 24 } + } as unknown as ManagedPane + } + + function makeManager(panes: ManagedPane[]): PaneManager { + return { + getPanes: () => panes, + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + } + + function stubPublishBridge(): ReturnType { + const publish = vi.fn<(attributes: TerminalViewAttributes) => void>() + vi.stubGlobal('window', { api: { pty: { publishTerminalViewAttributes: publish } } }) + return publish + } + + it('pushes the app-global snapshot once per change, not per pane or per manager', () => { + const publish = stubPublishBridge() + const settings = getDefaultSettings('/tmp') + + // Two panes in one manager plus a second manager (another tab): the + // attributes are app-global, so identical applies publish exactly once. + applyTerminalAppearance( + makeManager([makePane(1), makePane(2)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + applyTerminalAppearance( + makeManager([makePane(3)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(1) + const attributes = publish.mock.calls[0][0] as TerminalViewAttributes + expect(attributes.ansi).toHaveLength(256) + expect(attributes.cursorStyle).toBe(settings.terminalCursorStyle) + + // Attribute-neutral tweak (font size) must not re-push… + applyTerminalAppearance( + makeManager([makePane(1)]), + { ...settings, terminalFontSize: settings.terminalFontSize + 2 }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(1) + + // …while a cursor-style change is a real attribute change. + applyTerminalAppearance( + makeManager([makePane(1)]), + { ...settings, terminalCursorStyle: 'underline' }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(2) + }) + + it('publishes the resolved color-scheme mode flip (system dark toggle)', () => { + const publish = stubPublishBridge() + const settings = { ...getDefaultSettings('/tmp'), theme: 'system' as const } + + applyTerminalAppearance( + makeManager([makePane(1)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + applyTerminalAppearance( + makeManager([makePane(1)]), + settings, + false, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + + const modes = publish.mock.calls.map( + (call) => (call[0] as TerminalViewAttributes).colorSchemeMode + ) + expect(modes).toEqual(['dark', 'light']) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts new file mode 100644 index 00000000000..304ff399f6c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts @@ -0,0 +1,251 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): renderer→main `pty:terminalViewAttributes` publication. Composes + * the reply-relevant slots of the active terminal theme exactly the way + * xterm's browser ThemeService resolves an ITheme (defaults, cursor blend, + * 256-entry palette), so main's hidden-PTY responder replies byte-identically + * to a visible pane's xterm. Deduped module-globally: applyTerminalAppearance + * runs per pane manager and on every font/opacity tweak, but the attributes + * are app-global, so identical snapshots publish once. + */ +import type { ITheme } from '@xterm/xterm' +import type { GlobalSettings } from '../../../../shared/types' +import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' +import type { + TerminalViewAttributes, + TerminalViewRgb +} from '../../../../shared/terminal-view-attributes' + +type ParsedCssColor = { + rgb: TerminalViewRgb + /** 0-255, the precision xterm stores (rgba byte) — blend parity needs it. */ + alpha: number +} + +// ThemeService defaults for the reply-relevant slots (browser/services/ +// ThemeService.ts): fg #ffffff, bg #000000, cursor #ffffff. +const DEFAULT_FOREGROUND: ParsedCssColor = { rgb: [0xff, 0xff, 0xff], alpha: 0xff } +const DEFAULT_BACKGROUND: ParsedCssColor = { rgb: [0x00, 0x00, 0x00], alpha: 0xff } +const DEFAULT_CURSOR: ParsedCssColor = { rgb: [0xff, 0xff, 0xff], alpha: 0xff } + +// xterm's DEFAULT_ANSI_COLORS first 16 entries (browser/Types.ts). +const DEFAULT_ANSI_16: readonly string[] = [ + '#2e3436', + '#cc0000', + '#4e9a06', + '#c4a000', + '#3465a4', + '#75507b', + '#06989a', + '#d3d7cf', + '#555753', + '#ef2929', + '#8ae234', + '#fce94f', + '#729fcf', + '#ad7fa8', + '#34e2e2', + '#eeeeec' +] + +const THEME_ANSI_KEYS: readonly (keyof ITheme)[] = [ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white', + 'brightBlack', + 'brightRed', + 'brightGreen', + 'brightYellow', + 'brightBlue', + 'brightMagenta', + 'brightCyan', + 'brightWhite' +] + +function buildDefaultAnsiPalette(): TerminalViewRgb[] { + const palette = DEFAULT_ANSI_16.map((hex) => parseThemeColor(hex, DEFAULT_BACKGROUND).rgb) + // 16-231: the 6x6x6 color cube, 232-255: greys — same generator as xterm's + // DEFAULT_ANSI_COLORS IIFE so untouched extended slots reply identically. + const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] + for (let i = 0; i < 216; i++) { + palette.push([v[((i / 36) % 6) | 0], v[((i / 6) % 6) | 0], v[i % 6]]) + } + for (let i = 0; i < 24; i++) { + const c = 8 + i * 10 + palette.push([c, c, c]) + } + return palette +} + +const DEFAULT_ANSI_PALETTE: readonly TerminalViewRgb[] = buildDefaultAnsiPalette() + +/** Mirror of xterm's css.toColor fast paths (#rgb[a], #rrggbb[aa], rgb(), + * rgba()) — every format first-party inputs produce (builtin themes and the + * ghostty import are hex-validated; composeActiveTerminalTheme only adds the + * rgba() form this regex accepts). Known divergence boundary: the renderer's + * css.toColor also resolves named/modern CSS via a canvas litmus, so a + * hand-edited settings value like `background: 'darkslategray'` renders on + * a visible pane but falls back to the slot default in the hidden reply. */ +export function parseCssColor(css: string): ParsedCssColor | null { + if (/^#[\da-f]{3,8}$/i.test(css)) { + switch (css.length) { + case 4: + return { + rgb: [ + Number.parseInt(css.slice(1, 2).repeat(2), 16), + Number.parseInt(css.slice(2, 3).repeat(2), 16), + Number.parseInt(css.slice(3, 4).repeat(2), 16) + ], + alpha: 0xff + } + case 5: + return { + rgb: [ + Number.parseInt(css.slice(1, 2).repeat(2), 16), + Number.parseInt(css.slice(2, 3).repeat(2), 16), + Number.parseInt(css.slice(3, 4).repeat(2), 16) + ], + alpha: Number.parseInt(css.slice(4, 5).repeat(2), 16) + } + case 7: + return { + rgb: [ + Number.parseInt(css.slice(1, 3), 16), + Number.parseInt(css.slice(3, 5), 16), + Number.parseInt(css.slice(5, 7), 16) + ], + alpha: 0xff + } + case 9: + return { + rgb: [ + Number.parseInt(css.slice(1, 3), 16), + Number.parseInt(css.slice(3, 5), 16), + Number.parseInt(css.slice(5, 7), 16) + ], + alpha: Number.parseInt(css.slice(7, 9), 16) + } + default: + return null + } + } + const rgbaMatch = css.match( + /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/ + ) + if (rgbaMatch) { + return { + rgb: [ + Number.parseInt(rgbaMatch[1], 10), + Number.parseInt(rgbaMatch[2], 10), + Number.parseInt(rgbaMatch[3], 10) + ], + alpha: Math.round((rgbaMatch[5] === undefined ? 1 : Number.parseFloat(rgbaMatch[5])) * 0xff) + } + } + return null +} + +function parseThemeColor(css: string | undefined, fallback: ParsedCssColor): ParsedCssColor { + if (css !== undefined) { + const parsed = parseCssColor(css) + if (parsed) { + return parsed + } + } + return fallback +} + +// Mirror of xterm's color.blend: ThemeService blends the cursor color's +// alpha over the background at theme-set time (terminalCursorOpacity), and +// the OSC 12 reply reports the blended value. +function blendOverBackground(background: TerminalViewRgb, color: ParsedCssColor): TerminalViewRgb { + if (color.alpha === 0xff) { + return color.rgb + } + const a = color.alpha / 0xff + return [ + background[0] + Math.round((color.rgb[0] - background[0]) * a), + background[1] + Math.round((color.rgb[1] - background[1]) * a), + background[2] + Math.round((color.rgb[2] - background[2]) * a) + ] +} + +export function composeTerminalViewAttributes( + theme: ITheme | null, + mode: TerminalColorSchemeMode, + settings: Pick +): TerminalViewAttributes { + const foreground = parseThemeColor(theme?.foreground, DEFAULT_FOREGROUND) + const background = parseThemeColor(theme?.background, DEFAULT_BACKGROUND) + const cursor = parseThemeColor(theme?.cursor, DEFAULT_CURSOR) + const ansi: TerminalViewRgb[] = THEME_ANSI_KEYS.map((key, i) => { + const value = theme?.[key] + return parseThemeColor(typeof value === 'string' ? value : undefined, { + rgb: DEFAULT_ANSI_PALETTE[i], + alpha: 0xff + }).rgb + }) + for (let i = 16; i < DEFAULT_ANSI_PALETTE.length; i++) { + const extended = theme?.extendedAnsi?.[i - 16] + ansi.push( + parseThemeColor(extended, { + rgb: DEFAULT_ANSI_PALETTE[i], + alpha: 0xff + }).rgb + ) + } + return { + foreground: foreground.rgb, + background: background.rgb, + cursor: blendOverBackground(background.rgb, cursor), + ansi, + colorSchemeMode: mode, + // Same resolution as the per-pane option writes in applyTerminalAppearance. + cursorStyle: settings.terminalCursorStyle ?? 'block', + cursorBlink: settings.terminalCursorBlink === true + } +} + +let lastPublishedSnapshot: string | null = null + +function sendViaPreload(attributes: TerminalViewAttributes): boolean { + // Guarded: unit tests and the web client run without the preload bridge + // (remote-runtime PTYs are never hidden-gate markable anyway). + if (typeof window === 'undefined' || !window.api?.pty?.publishTerminalViewAttributes) { + return false + } + window.api.pty.publishTerminalViewAttributes(attributes) + return true +} + +/** Publishes the composed app-global attributes, once per actual change: + * repeat calls from per-pane appearance applies (and attribute-neutral + * tweaks like font size) are deduped against the last published snapshot. */ +export function publishTerminalViewAttributes( + theme: ITheme | null, + mode: TerminalColorSchemeMode, + settings: Pick, + send: (attributes: TerminalViewAttributes) => boolean = sendViaPreload +): boolean { + const attributes = composeTerminalViewAttributes(theme, mode, settings) + const serialized = JSON.stringify(attributes) + if (serialized === lastPublishedSnapshot) { + return false + } + if (!send(attributes)) { + // Not recorded: a later call with a working bridge must still publish. + return false + } + lastPublishedSnapshot = serialized + return true +} + +/** Test seam: reset the dedupe state between tests. */ +export function _resetTerminalViewAttributesPublisherForTest(): void { + lastPublishedSnapshot = null +} diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts new file mode 100644 index 00000000000..8fbb426ade8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' +import { + getTerminalFreezeBreadcrumbs, + resetTerminalFreezeBreadcrumbsForTesting +} from './terminal-freeze-breadcrumbs' + +// Why: lib-layer WebGL code records through the shared sink because it may not +// import the components-layer ring. Importing terminal-freeze-breadcrumbs wires +// that sink to the ring at module load; this pins that the WebGL crumbs land in +// the same one-paste report as delivery/visibility history. +describe('WebGL diagnostics → freeze breadcrumb ring', () => { + beforeEach(() => { + resetTerminalFreezeBreadcrumbsForTesting() + }) + + afterEach(() => { + resetTerminalFreezeBreadcrumbsForTesting() + }) + + it('routes context-loss and atlas-reset crumbs into the freeze report ring', () => { + recordTerminalWebglDiagnostic('webgl-context-loss', { paneId: 3 }) + recordTerminalWebglDiagnostic('webgl-atlas-reset', { managers: 1 }) + + const crumbs = getTerminalFreezeBreadcrumbs() + expect(crumbs.map((crumb) => crumb.kind)).toEqual(['webgl-context-loss', 'webgl-atlas-reset']) + expect(crumbs[0]?.detail).toEqual({ paneId: 3 }) + expect(crumbs[1]?.detail).toEqual({ managers: 1 }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index e021ced6b6a..09033df6264 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -21,6 +21,10 @@ import { type TerminalHiddenReason } from './terminal-visibility-resume' import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery' +import { + releaseRendererPtyVisibilityClaim, + setRendererPtyVisibilityClaim +} from './pty-renderer-delivery-claims' type UseTerminalPaneGlobalEffectsArgs = { tabId: string @@ -50,7 +54,7 @@ function reportRendererPtyVisibility( // renderer-visibility registry, so reporting them here is misleading. continue } - window.api.pty.setRendererPtyVisible?.(ptyId, visible) + setRendererPtyVisibilityClaim(transport, ptyId, visible) } } @@ -122,7 +126,11 @@ export function useTerminalPaneGlobalEffects({ useEffect(() => { const paneTransports = paneTransportsRef.current reportRendererPtyVisibility(paneTransports, rendererVisible) - return () => reportRendererPtyVisibility(paneTransports, false) + return () => { + for (const transport of paneTransports.values()) { + releaseRendererPtyVisibilityClaim(transport) + } + } }, [rendererVisible, paneTransportsRef]) useEffect(() => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 7999db503ca..b0e2cd10ef8 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -15,6 +15,7 @@ import { resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal-scrollback-policy' +import { configureTerminalOutputBacklogCap } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' import { normalizeTerminalTuiMouseWheelMultiplier } from '@/lib/pane-manager/pane-terminal-mouse-wheel' import { buildWindowsPtyCompatibilityOptions } from '@/lib/pane-manager/windows-pty-compatibility' @@ -65,6 +66,7 @@ import { import { handleOsc52ClipboardRequest } from './osc52-clipboard' import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast' import { parseOsc7 } from './parse-osc7' +import { guardParserHandler } from './terminal-parser-handler-guard' import { resolveTerminalJisYenInput } from './terminal-jis-yen-input' import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker' import { @@ -107,6 +109,7 @@ import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' +import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers' import { e2eConfig } from '@/lib/e2e-config' import { PRIMARY_SELECTION_MAX_LENGTH, @@ -559,6 +562,10 @@ export function useTerminalPaneLifecycle({ const terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( settings?.terminalScrollbackRows ) + // Why here: the output scheduler's backlog cap scales with the same + // scrollback setting; applying it where the setting is read keeps the two + // in lockstep without a separate settings subscription. + configureTerminalOutputBacklogCap(settings?.terminalScrollbackRows) const systemPrefersDarkRef = useRef(systemPrefersDark) systemPrefersDarkRef.current = systemPrefersDark const previousVisibleForReconcileRef = useRef(null) @@ -764,6 +771,13 @@ export function useTerminalPaneLifecycle({ setCacheTimerStartedAt, syncPanePtyLayoutBinding, clearExitedPanePtyLayoutBinding, + // Why: a DECSET 2031 subscribe answered from main's fact channel must + // land in the same registries the xterm CSI handler writes — otherwise + // theme flips never push CSI 997 and the TUI keeps a stale theme. + recordPaneMode2031Subscription: (paneId: number, repliedMode: 'dark' | 'light') => { + paneMode2031Ref.current.set(paneId, true) + paneLastThemeModeRef.current.set(paneId, repliedMode) + }, restoredPtyIdByLeafId: initialLayoutRef.current.ptyIdsByLeafId ?? {} } @@ -791,7 +805,19 @@ export function useTerminalPaneLifecycle({ const mode2031Disposables = installMode2031Handlers({ paneId: pane.id, parser: pane.terminal.parser, - onSubscribe: () => pushMode2031ForPane(pane.id), + onSubscribe: () => { + // Why: for hidden-delivery-gate-managed PTYs main's + // '2031-subscribe' fact is the sole responder — bytes reaching + // xterm live (foreground, sidecar interest) must not produce a + // second reply. The CSI handler still records the subscription. + const binding = panePtyBindings.get(pane.id) as + | (IDisposable & { isHiddenDeliveryGateManagedPty?: () => boolean }) + | undefined + if (binding?.isHiddenDeliveryGateManagedPty?.()) { + return + } + pushMode2031ForPane(pane.id) + }, isReplaying: () => isPaneReplaying(replayingPanesRef, pane.id), paneMode2031: paneMode2031Ref.current, paneLastThemeMode: paneLastThemeModeRef.current @@ -805,12 +831,15 @@ export function useTerminalPaneLifecycle({ // both the enabled and disabled paths so xterm doesn't fall // through to any other OSC 52 handler and so our intentional drop // in the disabled path is explicit. - const osc52Disposable = pane.terminal.parser.registerOscHandler(52, (data) => - handleOsc52ClipboardRequest(data, { - allowClipboardWrite: settingsRef.current?.terminalAllowOsc52Clipboard === true, - writeClipboardText: window.api.ui.writeClipboardText, - onBlockedWrite: showOsc52ClipboardBlockedToast - }) + const osc52Disposable = pane.terminal.parser.registerOscHandler( + 52, + guardParserHandler('osc-52-clipboard', (data) => + handleOsc52ClipboardRequest(data, { + allowClipboardWrite: settingsRef.current?.terminalAllowOsc52Clipboard === true, + writeClipboardText: window.api.ui.writeClipboardText, + onBlockedWrite: showOsc52ClipboardBlockedToast + }) + ) ) osc52DisposablesRef.current.set(pane.id, osc52Disposable) @@ -836,14 +865,17 @@ export function useTerminalPaneLifecycle({ confirmed: false }) } - const osc7Disposable = pane.terminal.parser.registerOscHandler(7, (data) => { - const parsedCwd = parseOsc7(data, { uncHost: osc7UncHost }) - if (parsedCwd) { - const confirmed = !isPaneReplaying(replayingPanesRef, pane.id) - paneCwdRef.current.set(pane.id, { cwd: parsedCwd, confirmed }) - } - return true - }) + const osc7Disposable = pane.terminal.parser.registerOscHandler( + 7, + guardParserHandler('osc-7-cwd', (data) => { + const parsedCwd = parseOsc7(data, { uncHost: osc7UncHost }) + if (parsedCwd) { + const confirmed = !isPaneReplaying(replayingPanesRef, pane.id) + paneCwdRef.current.set(pane.id, { cwd: parsedCwd, confirmed }) + } + return true + }) + ) osc7DisposablesRef.current.set(pane.id, osc7Disposable) // Why: let host-handled keys bypass xterm's kitty CSI-u encoder. @@ -1722,6 +1754,19 @@ export function useTerminalPaneLifecycle({ disposable.dispose() } imeNativeTextForwarderDisposables.clear() + // Why: hidden-view parking starts pane-less byte watchers right after + // this unmount; record pane identities before transports detach so the + // watchers write the same runtime-title slots the live panes used. + captureParkedTerminalPaneCandidates( + tabId, + worktreeId, + manager.getPanes().map((capturedPane) => ({ + ptyId: paneTransports.get(capturedPane.id)?.getPtyId() ?? null, + paneId: capturedPane.id, + leafId: capturedPane.leafId, + drivesTabTitle: manager.getActivePane()?.id === capturedPane.id + })) + ) for (const transport of paneTransports.values()) { const ptyId = transport.getPtyId() if ( diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts new file mode 100644 index 00000000000..190a5fd5a27 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -0,0 +1,243 @@ +/** + * Per-tab hidden-view parking for TerminalPaneOverlayLayer. + * + * Why: owns the cold-park policy bookkeeping (hiddenSince tracking, recheck + * timers, parked-set selection) and the parked byte-watcher reconciliation so + * the overlay layer only consumes the final parked tab set when deciding to + * render a slot as null. See docs/reference/terminal-hidden-view-parking.md. + */ +import { useEffect, useMemo, useRef, useState } from 'react' +import type { TerminalTab } from '../../../../shared/types' +import { useAppStore } from '../../store' +import { + findActivityTerminalPortal, + type ActivityTerminalPortalTarget +} from '../activity/activity-terminal-portal' +import { + getTerminalTabColdParkRecheckDelayMs, + selectColdParkedTerminalTabs, + type TerminalTabColdParkCandidate +} from './terminal-hidden-view-parking' +import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides' +import { + canWatcherCoverParkedTerminalTab, + disposeParkedTerminalWatchersForWorktree, + syncParkedTerminalTabWatchers +} from './terminal-parked-tab-watchers' + +type TerminalOverlayTabAssignment = { + groupId: string + isActiveInGroup: boolean +} + +function haveSameTerminalTabIds(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false + } + for (const id of left) { + if (!right.has(id)) { + return false + } + } + return true +} + +export function useTerminalTabColdParking(args: { + worktreeId: string + terminalTabs: readonly TerminalTab[] + assignments: ReadonlyMap + isWorktreeActive: boolean + /** Worktree-level park verdict from Terminal.tsx. */ + coldParkTerminalPanes: boolean + /** Hidden-measuring startup probe from Terminal.tsx — the panes must stay + * mounted for their first xterm fit, mirroring the worktree-level guard. */ + shouldMeasureHiddenWorktree: boolean + activityTerminalPortals: ActivityTerminalPortalTarget[] +}): ReadonlySet { + const { + worktreeId, + terminalTabs, + assignments, + isWorktreeActive, + coldParkTerminalPanes, + shouldMeasureHiddenWorktree, + activityTerminalPortals + } = args + const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId) + const terminalParkingEnabled = useAppStore( + (state) => state.settings?.terminalHiddenViewParking !== false + ) + const terminalTabHiddenSinceRef = useRef(new Map()) + const terminalTabParkingTimersRef = useRef(new Map()) + const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0) + const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState>( + () => new Set() + ) + + useEffect(() => { + const timers = terminalTabParkingTimersRef.current + return () => { + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + } + }, []) + + // Why: per-tab cold-park policy — hiddenSince bookkeeping, parked-set + // selection, and one recheck timer per still-pending deadline so React + // re-renders exactly when the hysteresis elapses instead of polling. + useEffect(() => { + const timers = terminalTabParkingTimersRef.current + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + + const nowMs = Date.now() + const overrides = getTerminalParkingPolicyOverrides() + const currentTerminalTabIds = new Set(terminalTabs.map((tab) => tab.id)) + const portalTabIds = new Set( + activityTerminalPortals + .filter((portal) => portal.worktreeId === worktreeId) + .map((portal) => portal.tabId) + ) + for (const tabId of Array.from(terminalTabHiddenSinceRef.current.keys())) { + if (!currentTerminalTabIds.has(tabId)) { + terminalTabHiddenSinceRef.current.delete(tabId) + } + } + + const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => { + const assignment = assignments.get(terminalTab.id) + const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) + const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id) + // Why: hidden-measuring counts as visibility — the startup probe needs + // mounted panes, so the hidden clock must not run during it. + if (isVisible || hasActivityTerminalPortal || shouldMeasureHiddenWorktree) { + terminalTabHiddenSinceRef.current.delete(terminalTab.id) + } else if (!terminalTabHiddenSinceRef.current.has(terminalTab.id)) { + terminalTabHiddenSinceRef.current.set(terminalTab.id, nowMs) + } + return { + id: terminalTab.id, + ptyId: terminalTab.ptyId, + pendingActivationSpawn: terminalTab.pendingActivationSpawn, + isVisible, + hasActivityTerminalPortal, + hiddenSinceMs: terminalTabHiddenSinceRef.current.get(terminalTab.id) ?? null + } + }) + + const nextColdParkedTerminalTabIds = selectColdParkedTerminalTabs({ + worktreeId, + terminalTabs: candidates, + pendingStartupByTabId, + parkingEnabled: terminalParkingEnabled, + nowMs, + ...overrides + }) + // Why: a tab the byte watchers cannot cover (no capture, no layout + // snapshot, legacy leaf ids) must never park — it would go silent for + // bells/titles/completions, the failure that sank the first attempt. + for (const terminalTab of terminalTabs) { + if ( + nextColdParkedTerminalTabIds.has(terminalTab.id) && + !canWatcherCoverParkedTerminalTab(worktreeId, terminalTab) + ) { + nextColdParkedTerminalTabIds.delete(terminalTab.id) + } + } + setColdParkedTerminalTabIds((current) => + haveSameTerminalTabIds(current, nextColdParkedTerminalTabIds) + ? current + : nextColdParkedTerminalTabIds + ) + + for (const candidate of candidates) { + if ( + candidate.isVisible || + candidate.hasActivityTerminalPortal || + nextColdParkedTerminalTabIds.has(candidate.id) + ) { + continue + } + const delayMs = getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: terminalParkingEnabled, + hiddenSinceMs: candidate.hiddenSinceMs, + nowMs, + ...overrides + }) + if (delayMs !== null && delayMs > 0) { + const tabId = candidate.id + const timer = window.setTimeout(() => { + timers.delete(tabId) + setTerminalTabParkingRevision((revision) => revision + 1) + }, delayMs) + timers.set(tabId, timer) + } + } + }, [ + activityTerminalPortals, + assignments, + isWorktreeActive, + pendingStartupByTabId, + shouldMeasureHiddenWorktree, + terminalParkingEnabled, + terminalTabParkingRevision, + terminalTabs, + worktreeId + ]) + + // Why: the rendered park verdict — worktree-level park (prop from + // Terminal.tsx) or per-tab cold park, never portal-hosted tabs. Render and + // the watcher-sync effect must share this exact set so watcher lifecycle + // tracks the committed unmounts. + const parkedTerminalTabIds = useMemo(() => { + const parked = new Set() + for (const terminalTab of terminalTabs) { + const assignment = assignments.get(terminalTab.id) + const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) + const hasActivityTerminalPortal = + findActivityTerminalPortal(activityTerminalPortals, { + worktreeId, + tabId: terminalTab.id + }) !== null + if ( + (coldParkTerminalPanes || (!isVisible && coldParkedTerminalTabIds.has(terminalTab.id))) && + !hasActivityTerminalPortal && + // Why: the hidden-measuring startup probe needs mounted panes; gate + // here too so the reveal lands in the same render that starts it. + !shouldMeasureHiddenWorktree + ) { + parked.add(terminalTab.id) + } + } + return parked + }, [ + activityTerminalPortals, + assignments, + coldParkTerminalPanes, + coldParkedTerminalTabIds, + isWorktreeActive, + shouldMeasureHiddenWorktree, + terminalTabs, + worktreeId + ]) + + // Why: runs in the same effect flush as the commit that parked/revealed the + // panes — watcher disposal therefore lands before any PTY data IPC can + // reach a freshly remounted pane, and watcher start lands after the parked + // pane's unmount capture. + useEffect(() => { + syncParkedTerminalTabWatchers({ + worktreeId, + tabs: terminalTabs, + parkedTabIds: parkedTerminalTabIds + }) + }, [parkedTerminalTabIds, terminalTabs, worktreeId]) + + useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId]) + + return parkedTerminalTabIds +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts new file mode 100644 index 00000000000..c83cdde9288 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' + +const { recoverVisibleTerminalWindowWakeMock } = vi.hoisted(() => ({ + recoverVisibleTerminalWindowWakeMock: vi.fn() +})) + +vi.mock('./terminal-visibility-resume', () => ({ + recoverVisibleTerminalWindowWake: recoverVisibleTerminalWindowWakeMock +})) + +import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery' +import { + getTerminalFreezeBreadcrumbs, + resetTerminalFreezeBreadcrumbsForTesting +} from './terminal-freeze-breadcrumbs' + +describe('useTerminalWindowWakeRecovery', () => { + const manager = {} as PaneManager + let systemResumedCallback: (() => void) | null = null + const unsubscribeSystemResumed = vi.fn() + const onSystemResumed = vi.fn((callback: () => void) => { + systemResumedCallback = callback + return unsubscribeSystemResumed + }) + + beforeEach(() => { + systemResumedCallback = null + recoverVisibleTerminalWindowWakeMock.mockClear() + unsubscribeSystemResumed.mockClear() + onSystemResumed.mockClear() + resetTerminalFreezeBreadcrumbsForTesting() + // Why: without requestAnimationFrame the hook skips its settled-frame + // follow-up, so every trigger maps to exactly one synchronous recovery. + vi.stubGlobal('requestAnimationFrame', undefined) + ;(window as unknown as { api: unknown }).api = { ui: { onSystemResumed } } + }) + + afterEach(() => { + vi.unstubAllGlobals() + delete (window as unknown as { api?: unknown }).api + }) + + function renderWakeRecoveryHook(isVisible = true) { + return renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible, + managerRef: { current: manager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + } + + it('clears the glyph atlas on system resume but not on plain window focus', () => { + // Why: wiping the shared WebGL glyph atlas on a plain refocus provokes + // xterm's page-merge race and paints garbled glyphs (#7604). Only a genuine + // OS resume — which can leave a stale renderer surface — clears the atlas. + renderWakeRecoveryHook() + + window.dispatchEvent(new Event('focus')) + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenCalledTimes(1) + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenNthCalledWith(1, { + manager, + isActive: true, + clearGlyphAtlases: false + }) + + expect(systemResumedCallback).toBeTypeOf('function') + systemResumedCallback?.() + + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenCalledTimes(2) + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenNthCalledWith(2, { + manager, + isActive: true, + clearGlyphAtlases: true + }) + }) + + it('records a wake-recovery breadcrumb with the trigger source and atlas decision', () => { + // Why: a post-wake garble report attributes to the trigger that ran (or its + // absence). Pin that focus records source=focus/atlas=false and system + // resume records source=system-resumed/atlas=true. + renderWakeRecoveryHook() + + window.dispatchEvent(new Event('focus')) + systemResumedCallback?.() + + const wakeCrumbs = getTerminalFreezeBreadcrumbs().filter((crumb) => + crumb.kind.startsWith('wake-recovery:') + ) + expect(wakeCrumbs.map((crumb) => [crumb.kind, crumb.detail])).toEqual([ + ['wake-recovery:focus', { clearGlyphAtlases: false }], + ['wake-recovery:system-resumed', { clearGlyphAtlases: true }] + ]) + }) + + it('unsubscribes from the system resume event on cleanup', () => { + const { unmount } = renderWakeRecoveryHook() + expect(onSystemResumed).toHaveBeenCalledTimes(1) + + unmount() + + expect(unsubscribeSystemResumed).toHaveBeenCalledTimes(1) + }) + + it('does not subscribe while the terminal surface is hidden', () => { + renderWakeRecoveryHook(false) + + expect(onSystemResumed).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts index 8c5da7ed97b..839b6184696 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' type UseTerminalWindowWakeRecoveryArgs = { isVisible: boolean @@ -29,7 +30,19 @@ export function useTerminalWindowWakeRecovery({ cancelAnimationFrame(wakeRecoveryFrameId) wakeRecoveryFrameId = null } - const recoverVisibleWake = (clearGlyphAtlases: boolean): void => { + const recoverVisibleWake = ( + clearGlyphAtlases: boolean, + source: 'focus' | 'visibilitychange' | 'system-resumed' + ): void => { + // Why: the decisive crumb for a post-wake garble report — which trigger + // fired and whether it wiped the atlas. If the report shows a stale pane + // but NO wake crumb near the unlock time, the trigger never fired (the + // unlock-screen gap); a crumb with clearGlyphAtlases=false means the light + // path ran but may not have healed a corrupted atlas. Silent (memory ring). + // Source is in the kind so distinct triggers don't coalesce into one + // entry (focus and resume often fire together); repeats of the same + // source still fold, which is the noise control we want. + recordTerminalFreezeBreadcrumb(`wake-recovery:${source}`, { clearGlyphAtlases }) // Focus and visibility often fire together; keep one immediate recovery and one settled RAF pass. if (wakeRecoveryFrameId !== null) { // Why: a pending settled pass may only upgrade in strength — a plain @@ -69,23 +82,27 @@ export function useTerminalWindowWakeRecovery({ // an agent streams; wiping the shared glyph atlas then provokes xterm's // page-merge race and paints garbled glyphs. Focus recovery keeps the warm // atlas: it only retries WebGL attach, refits, and repaints pane-scoped. - const onFocus = (): void => recoverVisibleWake(false) + const onFocus = (): void => recoverVisibleWake(false, 'focus') const onVisibilityChange = (): void => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') { - recoverVisibleWake(true) + recoverVisibleWake(true, 'visibilitychange') } } // Why: Linux has no window-occlusion tracking, so visibilitychange never // fires around system suspend; the main process broadcasts OS resume. const onSystemResumed = (): void => { if (typeof document === 'undefined' || document.visibilityState === 'visible') { - recoverVisibleWake(true) + recoverVisibleWake(true, 'system-resumed') } } window.addEventListener('focus', onFocus) if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { document.addEventListener('visibilitychange', onVisibilityChange) } + // Why: a focus-preserving display wake fires neither focus nor + // visibilitychange, so main relays powerMonitor resume over IPC. Genuine + // wake clears the WebGL glyph atlas (clearGlyphAtlases=true via + // onSystemResumed) — the latch-clearing recovery — unlike plain refocus. const unsubscribeSystemResumed = typeof window.api?.ui?.onSystemResumed === 'function' ? window.api.ui.onSystemResumed(onSystemResumed) diff --git a/src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts b/src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts new file mode 100644 index 00000000000..084b74edcc3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts @@ -0,0 +1,79 @@ +/** + * Repro for the frozen-terminal investigation (Discord #performance / #2836). + * + * The vendored xterm WriteBuffer (6.1.0-beta.287) permanently wedges when a + * synchronous exception escapes a write-completion callback: `_innerWrite` + * has no try/catch around `cb()`, the tail `_scheduleInnerWrite()` never + * runs, and later `write()` calls only re-schedule processing when the + * buffer is EMPTY — which a stalled buffer never is again. + * + * In Orca, write-completion callbacks run settleForegroundRender → refresh → + * renderer/WebGL code (pane-terminal-foreground-render-settle.ts) and the + * replay-guard decrement (replay-guard.ts). So one renderer exception during + * write completion freezes that pane's output forever AND latches the replay + * guard, whose gate in pty-connection.ts onData then silently drops every + * keystroke — the exact live-shell/flat-output.log/frozen-pane state the + * field reports describe. @xterm/headless shares the same WriteBuffer as + * @xterm/xterm at the same pinned version. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' + +describe('xterm WriteBuffer stall (vendored 6.1.0-beta.287)', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('permanently stops completing writes after a sync throw in a write-completion callback', () => { + vi.useFakeTimers() + const term = new Terminal({ allowProposedApi: true }) + const completed: string[] = [] + + term.write('first', () => { + completed.push('first') + throw new Error('synthetic renderer failure during write completion') + }) + term.write('second', () => { + completed.push('second') + }) + + expect(() => vi.runAllTimers()).toThrow('synthetic renderer failure') + + // The wedge: the stalled buffer is never empty again, so new writes only + // enqueue — no drain is ever scheduled and no callback ever fires. + term.write('third', () => { + completed.push('third') + }) + vi.runAllTimers() + expect(completed).toEqual(['first']) + expect(vi.getTimerCount()).toBe(0) + }) + + it('permanently stops completing writes after a sync throw in a custom parser handler', () => { + // Orca registers custom CSI/OSC handlers (capability replies, titles, + // agent status). The parser does NOT isolate sync handler exceptions: + // they escape _action and wedge the buffer exactly like the callback + // case — custom handlers are a second freeze vector. + vi.useFakeTimers() + const term = new Terminal({ allowProposedApi: true }) + const completed: string[] = [] + term.parser.registerCsiHandler({ final: 'z' }, () => { + throw new Error('synthetic parser handler failure') + }) + + term.write('\x1b[z', () => { + completed.push('poisoned') + }) + term.write('after', () => { + completed.push('after') + }) + expect(() => vi.runAllTimers()).toThrow('synthetic parser handler failure') + + term.write('later', () => { + completed.push('later') + }) + vi.runAllTimers() + expect(completed).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 31c51256a88..cbf24b7c1a6 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -67,6 +67,10 @@ declare global { interface Window { __paneManagers?: Map __onboardingFeatureSetupDeps?: OnboardingFeatureSetupDeps + __terminalParkingDebug?: { + parkDelayMs: number + parkedTabIds: () => string[] + } } } diff --git a/src/renderer/src/lib/automation-session-observer.test.ts b/src/renderer/src/lib/automation-session-observer.test.ts new file mode 100644 index 00000000000..6c8efbf81cf --- /dev/null +++ b/src/renderer/src/lib/automation-session-observer.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockSubscribeToPtyData = vi.fn() +const mockSubscribeToPtyExit = vi.fn() +const mockSubscribeTerminal = vi.fn() +const mockCallRuntimeRpc = vi.fn() + +const state = { + settings: { + activeRuntimeEnvironmentId: null as string | null, + terminalMainSideEffectAuthority: undefined as boolean | undefined + }, + setAgentStatus: vi.fn() +} + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => state + } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + subscribeToPtyExit: mockSubscribeToPtyExit +})) + +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: mockSubscribeToPtyData +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mockCallRuntimeRpc, + getActiveRuntimeTarget: vi.fn(() => ({ kind: 'local' })) +})) + +vi.mock('@/runtime/remote-runtime-terminal-multiplexer', () => ({ + getRemoteRuntimeTerminalMultiplexer: () => ({ subscribeTerminal: mockSubscribeTerminal }) +})) + +const DONE_STATUS_OSC = '\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07' + +describe('observeExistingAutomationSession', () => { + beforeEach(() => { + vi.clearAllMocks() + state.settings = { + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined + } + mockSubscribeToPtyData.mockReturnValue(vi.fn()) + mockSubscribeToPtyExit.mockReturnValue(vi.fn()) + mockCallRuntimeRpc.mockReturnValue(new Promise(() => {})) + mockSubscribeTerminal.mockResolvedValue({ close: vi.fn() }) + }) + + it('skips the duplicate OSC store write for local PTYs under main authority', async () => { + // Why: main already parses OSC 9999 for local/SSH PTYs and routes it to + // the store via agentStatus:set; writing here too would race that path. + const onAgentStatus = vi.fn() + const { observeExistingAutomationSession } = await import('./automation-session-observer') + + await observeExistingAutomationSession({ + ptyId: 'pty-local-1', + paneKey: 'tab-1:leaf-1', + runId: 'run-1', + onData: vi.fn(), + onAgentStatus, + onExit: vi.fn() + }) + + const handleData = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + handleData(DONE_STATUS_OSC) + + expect(state.setAgentStatus).not.toHaveBeenCalled() + expect(onAgentStatus).toHaveBeenCalledWith( + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }) + ) + }) + + it('keeps the legacy OSC store write when the kill switch is off', async () => { + state.settings.terminalMainSideEffectAuthority = false + const onAgentStatus = vi.fn() + const { observeExistingAutomationSession } = await import('./automation-session-observer') + + await observeExistingAutomationSession({ + ptyId: 'pty-local-1', + paneKey: 'tab-1:leaf-1', + runId: 'run-1', + onData: vi.fn(), + onAgentStatus, + onExit: vi.fn() + }) + + const handleData = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + handleData(DONE_STATUS_OSC) + + expect(state.setAgentStatus).toHaveBeenCalledWith( + 'tab-1:leaf-1', + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }), + undefined + ) + expect(onAgentStatus).toHaveBeenCalledTimes(1) + }) + + it('keeps the OSC store write for remote-runtime PTYs (bytes never transit local main)', async () => { + const onAgentStatus = vi.fn() + const { observeExistingAutomationSession } = await import('./automation-session-observer') + + await observeExistingAutomationSession({ + ptyId: 'remote:env-1@@terminal-9', + paneKey: 'tab-1:leaf-1', + runId: 'run-1', + onData: vi.fn(), + onAgentStatus, + onExit: vi.fn() + }) + + expect(mockSubscribeTerminal).toHaveBeenCalledTimes(1) + const callbacks = mockSubscribeTerminal.mock.calls[0]?.[0]?.callbacks as { + onData: (data: string) => void + } + callbacks.onData(DONE_STATUS_OSC) + + expect(state.setAgentStatus).toHaveBeenCalledWith( + 'tab-1:leaf-1', + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }), + undefined + ) + expect(onAgentStatus).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/automation-session-observer.ts b/src/renderer/src/lib/automation-session-observer.ts index c80ae9156e8..9976468ed69 100644 --- a/src/renderer/src/lib/automation-session-observer.ts +++ b/src/renderer/src/lib/automation-session-observer.ts @@ -10,6 +10,7 @@ import { import { useAppStore } from '@/store' import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' export async function observeExistingAutomationSession(args: { ptyId: string @@ -20,12 +21,25 @@ export async function observeExistingAutomationSession(args: { onExit: (code: number) => void }): Promise<() => void> { const { ptyId, paneKey, runId, onData, onExit } = args + // Why: for local/SSH PTYs main already parses OSC 9999 and routes it + // through the hook server (agentStatus:set → store); writing here too + // would race/duplicate that path. Remote-runtime bytes never transit local + // main, and the kill switch restores the legacy write. The onAgentStatus + // callback always fires — automation completion tracking stays here. + const mainOwnsAgentStatusWrites = + !isRemoteRuntimePtyId(ptyId) && + isMainTerminalSideEffectAuthorityForPty({ + settings: useAppStore.getState().settings, + runtimeEnvironmentId: null + }) const processAgentStatus = createAgentStatusOscProcessor() const handleData = (data: string): void => { onData(data) const processed = processAgentStatus(data) for (const payload of processed.payloads) { - useAppStore.getState().setAgentStatus(paneKey, payload, undefined) + if (!mainOwnsAgentStatusWrites) { + useAppStore.getState().setAgentStatus(paneKey, payload, undefined) + } args.onAgentStatus(payload) } } diff --git a/src/renderer/src/lib/crash-breadcrumb-recorder.ts b/src/renderer/src/lib/crash-breadcrumb-recorder.ts new file mode 100644 index 00000000000..c4a646ea01c --- /dev/null +++ b/src/renderer/src/lib/crash-breadcrumb-recorder.ts @@ -0,0 +1,25 @@ +import type { CrashReportBreadcrumbData } from '../../../shared/crash-reporting' + +// Why a leaf module: terminal modules (replay-guard, output scheduler, parser +// guards) record breadcrumbs, and e2e specs import those modules' constants — +// Playwright loads spec imports at collection time under a transform that +// cannot handle crash-diagnostics.ts (top-level `import.meta.hot` plus the +// webview-registry import chain). Keep this file free of value imports and +// import.meta so it stays loadable from any context. + +/** Best-effort breadcrumb recording; must never create or mask failures. */ +export function recordRendererCrashBreadcrumb( + name: string, + data?: CrashReportBreadcrumbData +): void { + if (typeof window === 'undefined') { + return + } + + try { + const api = (window as Window & { api?: Window['api'] }).api + api?.crashReports.recordBreadcrumb({ name, ...(data ? { data } : {}) }) + } catch { + // Best-effort crash evidence only. + } +} diff --git a/src/renderer/src/lib/crash-diagnostics.ts b/src/renderer/src/lib/crash-diagnostics.ts index aa7ec860ef6..bb0875f1433 100644 --- a/src/renderer/src/lib/crash-diagnostics.ts +++ b/src/renderer/src/lib/crash-diagnostics.ts @@ -3,6 +3,7 @@ import type { CrashReportDetailValue } from '../../../shared/crash-reporting' import { getBrowserWebviewMemoryProfile } from '../components/browser-pane/webview-registry' +import { recordRendererCrashBreadcrumb } from './crash-breadcrumb-recorder' const RENDERER_MEMORY_SAMPLE_INTERVAL_MS = 60_000 const BYTES_PER_MEGABYTE = 1024 * 1024 @@ -16,22 +17,10 @@ type BrowserPerformanceMemory = { let rendererCrashDiagnosticsInstalled = false let rendererMemoryInterval: number | null = null -export function recordRendererCrashBreadcrumb( - name: string, - data?: CrashReportBreadcrumbData -): void { - if (typeof window === 'undefined') { - return - } - - try { - // Why: crash diagnostics must never create or mask renderer startup failures. - const api = (window as Window & { api?: Window['api'] }).api - api?.crashReports.recordBreadcrumb({ name, ...(data ? { data } : {}) }) - } catch { - // Best-effort crash evidence only. - } -} +// Why re-exported from a leaf module: terminal modules and their e2e-visible +// import chains need breadcrumb recording without this file's import.meta / +// webview-registry baggage. See crash-breadcrumb-recorder.ts. +export { recordRendererCrashBreadcrumb } from './crash-breadcrumb-recorder' export function installRendererCrashDiagnostics(): void { if (rendererCrashDiagnosticsInstalled || typeof window === 'undefined') { diff --git a/src/renderer/src/lib/github-links.ts b/src/renderer/src/lib/github-links.ts index 05ea8c812f5..34a53c0494e 100644 --- a/src/renderer/src/lib/github-links.ts +++ b/src/renderer/src/lib/github-links.ts @@ -1,19 +1,20 @@ +// Why: the parsing core moved to shared so main's terminal side-effect +// tracker can emit pr-link facts (terminal-side-effect-authority.md, slice 3). +// Re-exported here so renderer consumers keep their '@/lib' import path. +// normalizeGitHubLinkQuery stays renderer-side: its too-large guard is link- +// picker input policy, not parsing. +import { + type GitHubIssueOrPRLink, + parseGitHubIssueOrPRLink, + parseGitHubIssueOrPRNumber +} from '../../../shared/github-links' + import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' -const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i +export * from '../../../shared/github-links' + const HTTP_URL_PREFIX_RE = /^https?:\/\//i -export type RepoSlug = { - owner: string - repo: string -} - -export type GitHubIssueOrPRLink = { - slug: RepoSlug - number: number - type: 'issue' | 'pr' -} - export type GitHubLinkQuery = { query: string directNumber: number | null @@ -21,93 +22,6 @@ export type GitHubLinkQuery = { tooLarge?: boolean } -export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null { - if (!slug?.owner || !slug.repo) { - return null - } - return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}` -} - -function matchGitHubItemPath(url: URL): RegExpExecArray | null { - return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, '')) -} - -function parseGitHubItemNumber(value: string): number | null { - const parsed = Number.parseInt(value, 10) - return parsed > 0 ? parsed : null -} - -/** - * Parses a GitHub issue/PR reference from plain input. - * Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs. - */ -export function parseGitHubIssueOrPRNumber(input: string): number | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed - if (/^\d+$/.test(numeric)) { - return parseGitHubItemNumber(numeric) - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - return null - } - - const match = matchGitHubItemPath(url) - if (!match) { - return null - } - - return parseGitHubItemNumber(match[4]) -} - -/** - * Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns - * null for anything that isn't a recognizable GitHub-shaped issue or pull URL. - */ -export function parseGitHubIssueOrPRLink(input: string): GitHubIssueOrPRLink | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - return null - } - - const match = matchGitHubItemPath(url) - if (!match) { - return null - } - const number = parseGitHubItemNumber(match[4]) - if (number === null) { - return null - } - - return { - slug: { owner: match[1], repo: match[2] }, - type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue', - number - } -} - /** * Normalizes link-picker input so both raw issue/PR numbers and full GitHub * URLs resolve to a usable query + direct-number lookup. diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 2a089f8411c..96f01753116 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -38,7 +38,11 @@ function expectStablePaneSpawn(): string { const state = { activeRepoId: 'repo-1', activeWorktreeId: 'wt-1', - settings: { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null as string | null }, + settings: { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: null as string | null, + terminalMainSideEffectAuthority: undefined as boolean | undefined + }, projects: [ { id: 'repo-1', @@ -110,7 +114,11 @@ describe('launchAgentBackgroundSession', () => { ) state.activeRepoId = 'repo-1' state.activeWorktreeId = 'wt-1' - state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null } + state.settings = { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined + } state.projects = [ { id: 'repo-1', @@ -339,7 +347,10 @@ describe('launchAgentBackgroundSession', () => { expect(mockSpawn).toHaveBeenCalled() }) - it('parses agent status from hidden PTY output', async () => { + it('parses agent status from hidden PTY output when the kill switch is off', async () => { + // Why: with main side-effect authority disabled, this sidecar is the only + // OSC 9999 → store path for hidden local sessions. + state.settings.terminalMainSideEffectAuthority = false const onAgentStatus = vi.fn() const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -367,6 +378,29 @@ describe('launchAgentBackgroundSession', () => { ) }) + it('skips the duplicate OSC store write under main side-effect authority', async () => { + // Why: main already routes OSC 9999 through the hook server to the store + // (agentStatus:set); a second write here would race the authoritative + // path. The automation onAgentStatus callback must still fire. + const onAgentStatus = vi.fn() + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation', + onAgentStatus + }) + + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07') + + expect(state.setAgentStatus).not.toHaveBeenCalled() + expect(onAgentStatus).toHaveBeenCalledWith( + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }) + ) + }) + it('seeds a working status for Command Code prompt launches', async () => { const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -530,7 +564,8 @@ describe('launchAgentBackgroundSession', () => { state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] state.settings = { agentCmdOverrides: { codex: "codex --prefill 'draft from override'" }, - activeRuntimeEnvironmentId: null + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined } const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -591,7 +626,11 @@ describe('launchAgentBackgroundSession', () => { }) it('creates background sessions on the active runtime environment', async () => { - state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' } + state.settings = { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: 'env-1', + terminalMainSideEffectAuthority: undefined + } const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') const result = await launchAgentBackgroundSession({ @@ -663,7 +702,11 @@ describe('launchAgentBackgroundSession', () => { }) it('closes a created runtime terminal when its data subscription fails', async () => { - state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' } + state.settings = { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: 'env-1', + terminalMainSideEffectAuthority: undefined + } mockRuntimeEnvironmentSubscribe.mockRejectedValueOnce(new Error('subscription failed')) const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 85a8f1171d7..ccdbd5fa16d 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -37,6 +37,7 @@ import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc' import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' import { createSshBackgroundStartupDelivery } from '@/lib/ssh-background-startup-delivery' import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' +import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' function runBestEffortCleanup(action: () => void): void { try { @@ -163,6 +164,12 @@ export async function launchAgentBackgroundSession( useAppStore.getState().clearAgentLaunchConfig(paneKey) onExit?.(exitPtyId, code) } + // Why: local/SSH status facts already pass through main's authoritative + // scanner; remote-runtime bytes still need this renderer-side store write. + const mainOwnsAgentStatusWrites = isMainTerminalSideEffectAuthorityForPty({ + settings: store.settings, + runtimeEnvironmentId: runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null + }) const processAgentStatus = createAgentStatusOscProcessor() const handleData = (data: string): void => { data = sshStartupDelivery.handleData(data) @@ -170,9 +177,11 @@ export async function launchAgentBackgroundSession( sshStartupDelivery.schedule(ptyId) const processed = processAgentStatus(data) for (const payload of processed.payloads) { - useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, { - launchToken - }) + if (!mainOwnsAgentStatusWrites) { + useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, { + launchToken + }) + } onAgentStatus?.(payload) } } diff --git a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts index 5fa2be90a20..2a52b31ba78 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts @@ -1,7 +1,11 @@ +import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' +import type { PaneRenderingDiagnostics } from './pane-manager-types' + type RegisteredPaneManager = { resetWebglTextureAtlases(): void fitAllPanes?: () => void refreshAllPanes?: () => void + getRenderingDiagnostics?: () => PaneRenderingDiagnostics[] } const liveManagers = new Set() @@ -35,6 +39,9 @@ export function resetAllTerminalWebglAtlases(): void { } export function resetAndRefreshAllTerminalWebglAtlases(): void { + // Why: the atlas wipe is the heavy recovery path; recording it lets a freeze + // report show whether a post-wake repaint actually ran. Silent breadcrumb. + recordTerminalWebglDiagnostic('webgl-atlas-reset', { managers: liveManagers.size }) const resetManagers: RegisteredPaneManager[] = [] for (const manager of liveManagers) { try { @@ -55,6 +62,27 @@ export function resetAndRefreshAllTerminalWebglAtlases(): void { } } +/** + * Per-pane WebGL renderer state across all live managers, for the one-paste + * freeze report. Lets a post-wake garble report show, per pane, whether it + * held a live WebGL addon or had fallen back after a context loss — the state + * that distinguishes "missed repaint" from "atlas corrupted". + */ +export function getAllPaneRenderingDiagnostics(): PaneRenderingDiagnostics[] { + const all: PaneRenderingDiagnostics[] = [] + for (const manager of liveManagers) { + try { + const diagnostics = manager.getRenderingDiagnostics?.() + if (diagnostics) { + all.push(...diagnostics) + } + } catch { + // Why: best-effort during teardown; one manager must not sink the report. + } + } + return all +} + export function refitAndRefreshAllTerminalPanes(): void { for (const manager of liveManagers) { try { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts index 93b848e7450..eab1caa497d 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts @@ -1,3 +1,5 @@ +import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' + export type ForegroundTerminalOutputTarget = { buffer?: { active?: { @@ -131,18 +133,24 @@ export function writeForegroundTerminalChunk( const beforeWriteViewport = options.forceViewportRefresh ? captureViewportSnapshot(terminal) : null - try { - terminal.write(data, () => { - if (beforeWriteViewport) { - settleForegroundRender(terminal, beforeWriteViewport, options) - } - options.onParsed?.() - }) - } catch { + // Why guarded steps: this callback runs inside xterm's WriteBuffer loop, + // where an escaping throw permanently wedges the terminal (see + // xterm-write-callback-guard.ts). Guard settle and onParsed separately so a + // renderer/WebGL failure during settle can't starve the replay-guard release. + const runCompletionSteps = (): void => { if (beforeWriteViewport) { - settleForegroundRender(terminal, beforeWriteViewport, options) + runGuardedWriteCompletionStep('foreground-render-settle', () => + settleForegroundRender(terminal, beforeWriteViewport, options) + ) } - options.onParsed?.() + if (options.onParsed) { + runGuardedWriteCompletionStep('foreground-on-parsed', options.onParsed) + } + } + try { + terminal.write(data, runCompletionSteps) + } catch { + runCompletionSteps() } } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts new file mode 100644 index 00000000000..a245f2d82b9 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts @@ -0,0 +1,45 @@ +type TerminalOutputAckTarget = object + +const inFlightAckCompletions = new WeakMap void>>() + +/** Tracks credits after submission to xterm so pane disposal can treat its + * unparsed write buffer as discarded instead of leaking main's ACK window. */ +export function registerTerminalOutputAckCredits( + terminal: TerminalOutputAckTarget, + credits: readonly (() => void)[] +): (() => void) | undefined { + if (credits.length === 0) { + return undefined + } + let completions = inFlightAckCompletions.get(terminal) + if (!completions) { + completions = new Set() + inFlightAckCompletions.set(terminal, completions) + } + let completed = false + const complete = (): void => { + if (completed) { + return + } + completed = true + completions?.delete(complete) + if (completions?.size === 0) { + inFlightAckCompletions.delete(terminal) + } + for (const credit of credits) { + credit() + } + } + completions.add(complete) + return complete +} + +export function discardInFlightTerminalOutputAckCredits(terminal: TerminalOutputAckTarget): void { + const completions = inFlightAckCompletions.get(terminal) + if (!completions) { + return + } + for (const complete of completions) { + complete() + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts new file mode 100644 index 00000000000..77ca783af6c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Benchmark harness for the terminal performance initiative: measures the +// scheduler-imposed drain ceiling in isolation. A mock terminal parses +// instantly, so the measured rate is pure scheduler drip (writes-per-tick x +// chunk size / reschedule interval). Baseline-jul02 measured agent-tui at +// 2.0 MB/s end-to-end while bare xterm parses the same bytes at ~103 MB/s; +// this pins how much of that ceiling the drain loop itself imposes. +// Run with: +// ORCA_TERMINAL_PERF_BENCH=1 pnpm vitest run \ +// src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts \ +// --config config/vitest.config.ts +const benchEnabled = process.env.ORCA_TERMINAL_PERF_BENCH === '1' + +vi.mock('@/lib/e2e-config', () => ({ + e2eConfig: { exposeStore: false } +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +const TOTAL_CHARS = 4 * 1024 * 1024 +const FEED_CHUNK_CHARS = 8 * 1024 +const MAX_SIMULATED_MS = 60_000 + +function createInstantParseTerminal() { + let written = 0 + return { + get written() { + return written + }, + buffer: { active: { cursorY: 0, baseY: 0, viewportY: 0 } }, + rows: 24, + refresh: vi.fn(), + _core: { refresh: vi.fn() }, + write: vi.fn((data: string, callback?: () => void) => { + written += data.length + callback?.() + }) + } +} + +async function loadScheduler() { + vi.resetModules() + return import('./pane-terminal-output-scheduler') +} + +async function measure(options: { foreground: boolean }): Promise { + vi.useFakeTimers() + const scheduler = await loadScheduler() + const terminal = createInstantParseTerminal() + const payload = 'x'.repeat(FEED_CHUNK_CHARS) + // Why paced feeding: dumping the whole payload trips the backlog cap + // (replaceBacklogWithWarning). Real sources are paced by main's 512KB + // delivery watermark; keep in-flight below a 256KB window like a live PTY. + const IN_FLIGHT_WINDOW_CHARS = 256 * 1024 + let fed = 0 + let elapsed = 0 + while (terminal.written < TOTAL_CHARS && elapsed < MAX_SIMULATED_MS) { + while (fed < TOTAL_CHARS && fed - terminal.written < IN_FLIGHT_WINDOW_CHARS) { + scheduler.writeTerminalOutput(terminal as never, payload, { + foreground: options.foreground, + // Why false: floods are classified latency-insensitive by + // pty-connection's isLatencySensitiveForegroundOutput once the + // immediate budget is spent — this is the sustained-throughput path. + latencySensitive: false + }) + fed += FEED_CHUNK_CHARS + } + vi.advanceTimersByTime(1) + elapsed += 1 + } + expect(terminal.written).toBe(TOTAL_CHARS) + return TOTAL_CHARS / 1024 / 1024 / (elapsed / 1000) +} + +describe.skipIf(!benchEnabled)('scheduler drain ceiling', () => { + beforeEach(() => { + vi.stubGlobal('window', globalThis) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('measures foreground (visible pane flood) and background ceilings', async () => { + const foreground = await measure({ foreground: true }) + const background = await measure({ foreground: false }) + // eslint-disable-next-line no-console -- bench harness output + console.log( + `\n[scheduler-ceiling] foreground flood: ${foreground.toFixed(1)} MB/s, background: ${background.toFixed(1)} MB/s (simulated time, instant parse)` + ) + }) +}) + +// Real-timer smoke: the MessageChannel drain path must actually drain a +// high-priority backlog without any timer advancing (the clamp-dodge works). +import { setUseMessageChannelDrainForTesting } from './pane-terminal-output-scheduler' + +describe('message-channel drain path', () => { + it('drains high-priority output with real timers and no timer advance', async () => { + vi.useRealTimers() + setUseMessageChannelDrainForTesting(true) + try { + const writes: string[] = [] + const terminal = { + write: (data: string, cb?: () => void) => { + writes.push(data) + cb?.() + } + } + const { writeTerminalOutput, discardTerminalOutput } = + await import('./pane-terminal-output-scheduler') + for (let i = 0; i < 40; i++) { + writeTerminalOutput(terminal as never, `chunk-${i};`, { foreground: true }) + } + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(writes.join('')).toContain('chunk-39;') + discardTerminalOutput(terminal as never) + } finally { + setUseMessageChannelDrainForTesting(null) + vi.useFakeTimers() + } + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index bca02b5fcc5..f85d9b8c56f 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -5,6 +5,14 @@ vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: true } })) +const mocks = vi.hoisted(() => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb +})) + function createTerminal() { const classes = new Set() return { @@ -51,6 +59,7 @@ async function loadScheduler() { describe('pane terminal output scheduler', () => { beforeEach(() => { vi.stubGlobal('window', globalThis) + mocks.recordRendererCrashBreadcrumb.mockClear() }) afterEach(() => { @@ -60,6 +69,168 @@ describe('pane terminal output scheduler', () => { vi.unstubAllGlobals() }) + describe('parse-deferred ACK crediting', () => { + // Why these tests: the credit invariant is "every delivered chunk credits + // exactly once, whether parsed or discarded" — a missed credit permanently + // shrinks main's in-flight window and wedges the PTY (rc.7.perf). + function makeCredit(): { fire: () => void; count: () => number } { + let fired = 0 + return { fire: () => (fired += 1), count: () => fired } + } + + it('credits when a queued chunk finishes parsing', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + let parsed: (() => void) | undefined + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + parsed = callback + }) + const credit = makeCredit() + + writeTerminalOutput(terminal, 'queued', { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire + }) + expect(credit.count()).toBe(0) + + vi.advanceTimersByTime(0) + expect(terminal.write).toHaveBeenCalledWith('queued', expect.any(Function)) + expect(credit.count()).toBe(0) + parsed?.() + expect(credit.count()).toBe(1) + }) + + it('credits exactly once when a chunk is split across drain slices', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const credit = makeCredit() + + // 40 KB > the 16 KB slice size: consumed across multiple drain writes. + writeTerminalOutput(terminal, 'q'.repeat(40 * 1024), { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire + }) + for (let index = 0; index < 24; index += 1) { + vi.advanceTimersByTime(4) + } + const written = terminal.write.mock.calls.map((call) => String(call[0])).join('') + expect(written).toContain('q'.repeat(40 * 1024)) + expect(credit.count()).toBe(1) + }) + + it('defers split-chunk credit and onParsed until the final slice parses', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const parseCallbacks: (() => void)[] = [] + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + if (callback) { + parseCallbacks.push(callback) + } + }) + const credit = makeCredit() + const onParsed = vi.fn() + + writeTerminalOutput(terminal, 'q'.repeat(40 * 1024), { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire, + onParsed + }) + vi.advanceTimersByTime(0) + + expect(parseCallbacks).toHaveLength(3) + parseCallbacks[0]() + parseCallbacks[1]() + expect(credit.count()).toBe(0) + expect(onParsed).not.toHaveBeenCalled() + parseCallbacks[2]() + expect(credit.count()).toBe(1) + expect(onParsed).toHaveBeenCalledTimes(1) + }) + + it('credits when the foreground backlog is replaced with the overflow warning', async () => { + vi.useFakeTimers() + const { writeTerminalOutput, configureTerminalOutputBacklogCap } = await loadScheduler() + configureTerminalOutputBacklogCap(1_000) + const terminal = createTerminal() + // Never complete a write so the queue only grows. + terminal.write.mockImplementation(() => {}) + const credits = [makeCredit(), makeCredit(), makeCredit()] + + for (const credit of credits) { + writeTerminalOutput(terminal, 'x'.repeat(1024 * 1024), { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire + }) + } + // The cap replacement discards queued chunks — their deliveries still + // consumed and must credit. + for (const credit of credits) { + expect(credit.count()).toBe(1) + } + }) + + it('credits when queued output is discarded', async () => { + vi.useFakeTimers() + const { writeTerminalOutput, discardTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + terminal.write.mockImplementation(() => {}) + const credit = makeCredit() + + writeTerminalOutput(terminal, 'doomed', { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire + }) + expect(credit.count()).toBe(0) + discardTerminalOutput(terminal) + expect(credit.count()).toBe(1) + }) + + it('credits an empty write immediately', async () => { + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const credit = makeCredit() + + writeTerminalOutput(terminal, '', { foreground: true, ackCredit: credit.fire }) + expect(credit.count()).toBe(1) + }) + + it('credits the immediate foreground path after its parse callback', async () => { + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + let parsed: (() => void) | undefined + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + parsed = callback + }) + const credit = makeCredit() + + writeTerminalOutput(terminal, 'now', { foreground: true, ackCredit: credit.fire }) + expect(terminal.write).toHaveBeenCalledWith('now', expect.any(Function)) + expect(credit.count()).toBe(0) + parsed?.() + expect(credit.count()).toBe(1) + }) + + it('credits submitted but unparsed output when the terminal is discarded', async () => { + const { writeTerminalOutput, discardTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + terminal.write.mockImplementation(() => {}) + const credit = makeCredit() + + writeTerminalOutput(terminal, 'submitted', { foreground: true, ackCredit: credit.fire }) + expect(credit.count()).toBe(0) + discardTerminalOutput(terminal) + expect(credit.count()).toBe(1) + }) + }) + it('writes foreground output immediately', async () => { const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() @@ -277,7 +448,7 @@ describe('pane terminal output scheduler', () => { expect(terminal._core.refresh).not.toHaveBeenCalled() }) - it('keeps parsed callbacks on large background chunks split by the scheduler', async () => { + it('runs parsed callbacks after the final background slice', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() @@ -299,14 +470,12 @@ describe('pane terminal output scheduler', () => { vi.advanceTimersByTime(50) expect(writes.map((data) => data.length)).toEqual([16 * 1024, 4 * 1024]) - expect(parseCallbacks).toHaveLength(2) + expect(parseCallbacks).toHaveLength(1) expect(onParsed).not.toHaveBeenCalled() parseCallbacks[0]?.() expect(onParsed).toHaveBeenCalledTimes(1) - parseCallbacks[1]?.() - expect(onParsed).toHaveBeenCalledTimes(2) }) it('defers throughput foreground output to the shared high-priority drain', async () => { @@ -798,6 +967,31 @@ describe('pane terminal output scheduler', () => { expect(terminals[2].write).toHaveBeenCalledWith('pane-2') }) + it('drains active foreground backlog before older background terminal backlog', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const backgroundA = createTerminal() + const backgroundB = createTerminal() + const active = createTerminal() + + writeTerminalOutput(backgroundA, 'background-a', { foreground: false }) + writeTerminalOutput(backgroundB, 'background-b', { foreground: false }) + writeTerminalOutput(active, 'active', { + foreground: true, + latencySensitive: false + }) + + vi.advanceTimersByTime(0) + + expect(active.write).toHaveBeenCalledWith('active', expect.any(Function)) + expect(active.write.mock.invocationCallOrder[0]).toBeLessThan( + backgroundA.write.mock.invocationCallOrder[0] + ) + expect(active.write.mock.invocationCallOrder[0]).toBeLessThan( + backgroundB.write.mock.invocationCallOrder[0] + ) + }) + it('rotates terminals with remaining backlog behind untouched queued terminals', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() @@ -897,11 +1091,14 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).not.toHaveBeenCalled() + // Why 8: promoted backlogs use the parse-clocked high-priority budget + // (HIGH_PRIORITY_MAX_WRITES_PER_DRAIN) so a visible flood drains at the + // parser's pace instead of a fixed 2-write drip. vi.advanceTimersByTime(0) - expect(terminal.write).toHaveBeenCalledTimes(2) + expect(terminal.write).toHaveBeenCalledTimes(8) vi.advanceTimersByTime(4) - expect(terminal.write).toHaveBeenCalledTimes(4) + expect(terminal.write).toHaveBeenCalledTimes(16) }) it('yields high-priority backlog drains when writes spend the frame budget', async () => { @@ -979,6 +1176,95 @@ describe('pane terminal output scheduler', () => { expect(output).not.toContain('x'.repeat(1024)) }) + it('caps a visible pane backlog the drain cannot keep up with and writes a warning', async () => { + // Why: the foreground path was previously uncapped — a flooding visible + // TUI on a starved renderer grew queuedChars without bound (field + // reports of ~1.5 GB renderer RSS before terminals froze). + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(512 * 1024) + + for (let i = 0; i < 5; i++) { + writeTerminalOutput(terminal, chunk, { foreground: true, latencySensitive: false }) + } + writeTerminalOutput(terminal, 'after-cap\r\n', { foreground: true, latencySensitive: false }) + + vi.advanceTimersByTime(0) + + const output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).toContain('Orca skipped a burst of terminal output') + expect(output).toContain('after-cap') + expect(output).not.toContain('x'.repeat(1024)) + }) + + it('records a drop breadcrumb with sizes when the cap replaces a backlog', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(512 * 1024) + + for (let i = 0; i < 5; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_output_backlog_dropped', + expect.objectContaining({ + foreground: false, + droppedChars: expect.any(Number), + capChars: 2 * 1024 * 1024 + }) + ) + }) + + it('scales the backlog cap with the scrollback setting', async () => { + vi.useFakeTimers() + const { writeTerminalOutput, configureTerminalOutputBacklogCap } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(512 * 1024) + + // 50k-row scrollback ⇒ 6 MB cap: a 2.5 MB flood that would trip the + // 2 MB floor must survive intact. + configureTerminalOutputBacklogCap(50_000) + for (let i = 0; i < 5; i++) { + writeTerminalOutput(terminal, chunk, { foreground: true, latencySensitive: false }) + } + vi.advanceTimersByTime(0) + + let output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).not.toContain('Orca skipped') + expect(output).toContain('x'.repeat(1024)) + + // But the scaled cap still bounds a runaway flood. + terminal.write.mockClear() + for (let i = 0; i < 13; i++) { + writeTerminalOutput(terminal, chunk, { foreground: true, latencySensitive: false }) + } + vi.advanceTimersByTime(0) + output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).toContain('Orca skipped a burst of terminal output') + }) + + it('caps a held/coalesced foreground backlog as well', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + const chunk = 'y'.repeat(512 * 1024) + + // holdForeground engages the synchronized-output hold — the branch a + // flooding TUI in sync mode exercises. + for (let i = 0; i < 5; i++) { + writeTerminalOutput(terminal, chunk, { foreground: true, holdForeground: true }) + } + + vi.advanceTimersByTime(1_000) + + const output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).toContain('Orca skipped a burst of terminal output') + expect(output).not.toContain('y'.repeat(1024)) + }) + it('caps hidden backlog chunk count even when each chunk is tiny', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 20a3b8138ca..79ddae6e086 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -11,6 +11,16 @@ import { captureTerminalWriteScrollIntent, enforceTerminalWriteScrollIntent } from './terminal-scroll-intent' +import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { + discardInFlightTerminalOutputAckCredits, + registerTerminalOutputAckCredits +} from './pane-terminal-output-ack-credit' +import { + TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS, + terminalOutputBacklogCapChars +} from '../../../../shared/terminal-scrollback-policy' type TerminalOutputTarget = ForegroundTerminalOutputTarget @@ -22,6 +32,12 @@ type WriteTerminalOutputOptions = { foreground: boolean beforeWrite?: TerminalOutputBeforeWrite onParsed?: TerminalOutputParsedCallback + /** Parse-deferred delivery ACK (terminal-pty-ack-gate). The scheduler MUST + * invoke it exactly when the chunk's bytes are parsed by xterm OR discarded + * by any drop path. A missed credit permanently shrinks + * main's in-flight window for this PTY (the callback is fire-once, so + * double invocation is safe; omission is not). */ + ackCredit?: () => void onBackgroundBacklogDropped?: () => void latencySensitive?: boolean forceForegroundRefresh?: boolean @@ -38,6 +54,7 @@ type QueueChunk = { followupForegroundRefresh: boolean stripTransientCursorShows: boolean onParsed?: TerminalOutputParsedCallback + ackCredit?: () => void } type QueuedWrite = { @@ -47,6 +64,7 @@ type QueuedWrite = { followupForegroundRefresh: boolean stripTransientCursorShows: boolean onParsed?: TerminalOutputParsedCallback + ackCredits: (() => void)[] } type QueueEntry = { @@ -71,12 +89,26 @@ const BACKGROUND_DRAIN_INTERVAL_MS = 16 const HIGH_PRIORITY_DRAIN_INTERVAL_MS = 4 const BACKGROUND_CHUNK_CHARS = 16 * 1024 const MAX_WRITES_PER_DRAIN = 2 -const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 2 +// Why 8: with the parse-clock pacer, high-priority ticks fire only after +// xterm confirms the previous batch parsed, and Chromium clamps chained +// timers to ~4ms — so per-tick volume (8 x 16KB = 128KB ≈ 1.3ms of parse) +// sets the sustained ceiling (~30MB/s) while staying far inside +// DRAIN_TIME_BUDGET_MS. At 2 the ceiling was 8MB/s against a ~100MB/s +// parser (see pane-terminal-output-scheduler-throughput.bench.test.ts). +const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 8 const DRAIN_TIME_BUDGET_MS = 8 const LARGE_BACKLOG_CHARS = 512 * 1024 const SYNC_FOREGROUND_FLUSH_CHARS = 256 * 1024 -const MAX_BACKGROUND_QUEUE_CHARS = 2 * 1024 * 1024 +// Why mutable: the cap scales with the user's scrollback setting (see +// terminalOutputBacklogCapChars); the terminal lifecycle configures it when +// settings are applied. The chunk-count cap stays fixed — it bounds queue +// bookkeeping, not retained content. +let maxQueueChars = TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS const MAX_BACKGROUND_QUEUE_CHUNKS = 4096 + +export function configureTerminalOutputBacklogCap(scrollbackRows: unknown): void { + maxQueueChars = terminalOutputBacklogCapChars(scrollbackRows) +} const PARSE_SETTLE_TIMEOUT_MS = 250 const FOREGROUND_COALESCE_DELAY_MS = 1000 const FOREGROUND_HOLD_SAFETY_DELAY_MS = 250 @@ -88,9 +120,15 @@ const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' // Why: CAN aborts a partial escape sequence before resetting style and showing -// the lossy-backlog warning. +// the lossy-backlog warning. Cap-agnostic wording: the byte limit scales with +// the scrollback setting. const BACKGROUND_BACKLOG_WARNING = - '\x18\x1b[0m\r\n[Orca skipped hidden terminal output because the backlog exceeded 2 MB.]\r\n' + '\x18\x1b[0m\r\n[Orca skipped hidden terminal output because the backlog grew too large.]\r\n' +// Why a separate foreground message: a visible pane hitting the cap means the +// drain could not keep up with a flood (starved renderer) — the output was +// skipped, not merely produced while hidden. +const FOREGROUND_BACKLOG_WARNING = + '\x18\x1b[0m\r\n[Orca skipped a burst of terminal output because the backlog grew too large.]\r\n' const queuedByTerminal = new Map() const backlogRecoveryByTerminal = new WeakMap< @@ -99,6 +137,47 @@ const backlogRecoveryByTerminal = new WeakMap< >() let drainTimer: ReturnType | null = null let drainTimerDelayMs: number | null = null +// Why a MessageChannel for zero-delay drains: Chromium clamps nested +// setTimeout(0) to ~4ms, which stacks a dead gap onto every parse-clocked +// drain tick under flood (measured: standing queue ~18ms vs VS Code ~7ms). +// A posted message is still a macrotask — input events and paint are +// serviced between posts — so the cooperative yield survives without the +// clamp. Cancellation is by generation: posts carry the generation they +// were armed with and no-op when it has moved on. +let drainImmediatePending = false +let drainImmediateGeneration = 0 +let useMessageChannelDrain = typeof MessageChannel !== 'undefined' && !isVitestEnv() +let drainChannel: MessageChannel | null = null + +function isVitestEnv(): boolean { + // Why: vitest fake timers cannot advance MessageChannel macrotasks; the + // timer path keeps the existing suites' virtual clock authoritative. + return typeof process !== 'undefined' && process.env?.VITEST === 'true' +} + +function getDrainChannel(): MessageChannel { + if (drainChannel === null) { + drainChannel = new MessageChannel() + drainChannel.port1.onmessage = (event: MessageEvent) => { + if (event.data !== drainImmediateGeneration || !drainImmediatePending) { + return + } + drainImmediatePending = false + drainQueuedOutput() + } + } + return drainChannel +} + +function cancelImmediateDrain(): void { + drainImmediateGeneration++ + drainImmediatePending = false +} + +export function setUseMessageChannelDrainForTesting(value: boolean | null): void { + cancelImmediateDrain() + useMessageChannelDrain = value ?? (typeof MessageChannel !== 'undefined' && !isVitestEnv()) +} const debugEnabled = e2eConfig.exposeStore // Why the cap is lossy: a hidden/backgrounded Chromium document can throttle @@ -219,6 +298,10 @@ function exposeDebugApi(): void { } function scheduleDrain(delayMs: number): void { + if (drainImmediatePending) { + // An immediate drain is already armed — nothing can beat zero delay. + return + } if (drainTimer !== null) { if (drainTimerDelayMs !== null && drainTimerDelayMs <= delayMs) { return @@ -233,6 +316,11 @@ function scheduleDrain(delayMs: number): void { if (debugEnabled) { debugState.scheduledDrainCount++ } + if (delayMs === 0 && useMessageChannelDrain) { + drainImmediatePending = true + getDrainChannel().port2.postMessage(drainImmediateGeneration) + return + } drainTimer = setTimeout(drainQueuedOutput, delayMs) drainTimerDelayMs = delayMs } @@ -485,6 +573,7 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { let followupForegroundRefresh = false let stripTransientCursorShows = false const parsedCallbacks: TerminalOutputParsedCallback[] = [] + const ackCredits: (() => void)[] = [] while (remaining > 0 && entry.chunkIndex < entry.chunks.length) { const chunk = entry.chunks[entry.chunkIndex] @@ -503,13 +592,13 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { if (chunk.onParsed) { parsedCallbacks.push(chunk.onParsed) } + if (chunk.ackCredit) { + ackCredits.push(chunk.ackCredit) + } continue } data += chunk.data.slice(0, remaining) - if (chunk.onParsed) { - parsedCallbacks.push(chunk.onParsed) - } entry.chunks[entry.chunkIndex] = { ...chunk, data: chunk.data.slice(remaining) @@ -537,7 +626,8 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { callback() } } - : undefined + : undefined, + ackCredits } : null } @@ -566,6 +656,7 @@ function enqueueChunk( followupForegroundRefresh?: boolean stripTransientCursorShows?: boolean onParsed?: TerminalOutputParsedCallback + ackCredit?: () => void } ): void { entry.chunks.push({ @@ -574,18 +665,49 @@ function enqueueChunk( forceForegroundRefresh: options?.forceForegroundRefresh === true, followupForegroundRefresh: options?.followupForegroundRefresh === true, stripTransientCursorShows: options?.stripTransientCursorShows === true, - onParsed: options?.onParsed + onParsed: options?.onParsed, + ackCredit: options?.ackCredit }) entry.queuedChars += data.length recordQueueDebugPressure() } -function replaceBacklogWithWarning(entry: QueueEntry): void { +// Fires the delivery ACK credits of every not-yet-consumed queued chunk. +// Every discard path MUST call this before clearing/replacing the queue — +// a dropped chunk still counts as consumed for main's in-flight window, or +// the window shrinks permanently and the PTY wedges behind lost credit. +function fireQueuedAckCredits(entry: QueueEntry): void { + for (let index = entry.chunkIndex; index < entry.chunks.length; index += 1) { + entry.chunks[index].ackCredit?.() + } +} + +function queueCapExceeded(entry: QueueEntry): boolean { + return ( + entry.queuedChars > maxQueueChars || + entry.chunks.length - entry.chunkIndex > MAX_BACKGROUND_QUEUE_CHUNKS + ) +} + +function replaceBacklogWithWarning( + entry: QueueEntry, + warning: string = BACKGROUND_BACKLOG_WARNING +): void { const shouldNotify = !entry.backgroundBacklogDropped + if (shouldNotify) { + // Why: field visibility for cap tuning — how often drops happen and at + // what size decides whether the cap is too small (issue #2836 / #7017). + recordRendererCrashBreadcrumb('terminal_output_backlog_dropped', { + foreground: warning === FOREGROUND_BACKLOG_WARNING, + droppedChars: entry.queuedChars, + capChars: maxQueueChars + }) + } clearForegroundHoldSafety(entry) + fireQueuedAckCredits(entry) entry.chunks = [ { - data: BACKGROUND_BACKLOG_WARNING, + data: warning, foreground: false, forceForegroundRefresh: false, followupForegroundRefresh: false, @@ -593,7 +715,7 @@ function replaceBacklogWithWarning(entry: QueueEntry): void { } ] entry.chunkIndex = 0 - entry.queuedChars = BACKGROUND_BACKLOG_WARNING.length + entry.queuedChars = warning.length entry.backgroundBacklogDropped = true entry.highPriority = true entry.foregroundHold = false @@ -637,26 +759,34 @@ function writeBackgroundTerminalChunk( data: string, onParsed?: TerminalOutputParsedCallback ): void { + // Why guarded: these callbacks run inside xterm's WriteBuffer loop, where an + // escaping throw permanently wedges the terminal (see + // xterm-write-callback-guard.ts). + const runOnParsed = onParsed + ? (): void => runGuardedWriteCompletionStep('background-on-parsed', onParsed) + : undefined const scrollIntent = captureTerminalWriteScrollIntent(terminal) if (!scrollIntent) { - if (!onParsed || terminal.write.length < 2) { + if (!runOnParsed || terminal.write.length < 2) { terminal.write(data) - onParsed?.() + runOnParsed?.() return } - terminal.write(data, onParsed) + terminal.write(data, runOnParsed) return } + const runScrollIntentThenParsed = (): void => { + runGuardedWriteCompletionStep('background-scroll-intent', () => + enforceTerminalWriteScrollIntent(terminal, scrollIntent) + ) + runOnParsed?.() + } if (terminal.write.length < 2) { terminal.write(data) - enforceTerminalWriteScrollIntent(terminal, scrollIntent) - onParsed?.() + runScrollIntentThenParsed() return } - terminal.write(data, () => { - enforceTerminalWriteScrollIntent(terminal, scrollIntent) - onParsed?.() - }) + terminal.write(data, runScrollIntentThenParsed) } function writeForegroundTerminalChunkWithIntent( @@ -682,6 +812,25 @@ function writeForegroundTerminalChunkWithIntent( } function takeNextDrainableEntry(): QueueEntry | null { + let largeBacklogEntry: QueueEntry | null = null + for (const entry of queuedByTerminal.values()) { + if (!isEntryDrainable(entry)) { + continue + } + // Why: active/foreground output should be chosen first, not just widen the + // drain budget while older background terminals keep their insertion order. + if (entry.highPriority) { + queuedByTerminal.delete(entry.terminal) + return entry + } + if (!largeBacklogEntry && entry.queuedChars > LARGE_BACKLOG_CHARS) { + largeBacklogEntry = entry + } + } + if (largeBacklogEntry) { + queuedByTerminal.delete(largeBacklogEntry.terminal) + return largeBacklogEntry + } for (const entry of queuedByTerminal.values()) { if (!isEntryDrainable(entry)) { continue @@ -692,11 +841,50 @@ function takeNextDrainableEntry(): QueueEntry | null { return null } +// Why: the parse-completion pacer re-arms a zero-delay drain as soon as xterm +// reports the previous high-priority batch parsed. Without it, cadence is a +// fixed 4/16ms nap per <=32KB batch — a ~2-8 MB/s drip against xterm's +// ~100 MB/s parse rate (measured: scheduler-throughput bench + baseline-jul02). +// Only high-priority (visible-pane) backlogs are pacer-clocked; background +// panes keep the fixed cadence that protects the focused terminal. +function makeParseClockPacer(): () => void { + return () => { + try { + if (queuedByTerminal.size > 0 && hasHighPriorityBacklog()) { + scheduleDrain(0) + } + } catch { + // Why: runs inside xterm's write-callback chain; a throw here would + // wedge the terminal (see xterm-write-callback-guard.ts). + } + } +} + +function composeParsedCallback( + onParsed: TerminalOutputParsedCallback | undefined, + ackCreditsParsed: (() => void) | undefined, + pacer: (() => void) | undefined +): TerminalOutputParsedCallback | undefined { + if (!onParsed && !ackCreditsParsed && !pacer) { + return undefined + } + return () => { + try { + onParsed?.() + } finally { + ackCreditsParsed?.() + pacer?.() + } + } +} + function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null { const queuedWrite = takeQueuedChunk(entry, BACKGROUND_CHUNK_CHARS) if (!queuedWrite) { return null } + const pacer = entry.highPriority ? makeParseClockPacer() : undefined + const ackCreditsParsed = registerTerminalOutputAckCredits(entry.terminal, queuedWrite.ackCredits) try { entry.beforeWrite?.(queuedWrite.data) if (queuedWrite.foreground) { @@ -708,16 +896,22 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, - onParsed: queuedWrite.onParsed + onParsed: composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, pacer) } ) } else { - writeBackgroundTerminalChunk(entry.terminal, queuedWrite.data, queuedWrite.onParsed) + writeBackgroundTerminalChunk( + entry.terminal, + queuedWrite.data, + composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, pacer) + ) } } catch { // Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping; // a write to a disposed terminal throws. Drop the entry rather than crashing // the scheduler for other panes still draining. + ackCreditsParsed?.() + fireQueuedAckCredits(entry) entry.chunks.length = 0 entry.chunkIndex = 0 entry.queuedChars = 0 @@ -781,8 +975,17 @@ function drainQueuedOutput(): void { } recordQueueDebugPressure() if (queuedByTerminal.size > 0 && hasDrainableBacklog()) { + // Why 0 on the channel path: the 4ms high-priority interval existed to + // yield between ticks, but a posted message already yields — Chromium + // services input and paint between macrotasks. The explicit sleep only + // deepened the standing queue (~4ms per 128KB tick). Timer path keeps + // the interval so fake-timer tests retain stepwise drain semantics. scheduleDrain( - hasHighPriorityBacklog() ? HIGH_PRIORITY_DRAIN_INTERVAL_MS : BACKGROUND_DRAIN_INTERVAL_MS + hasHighPriorityBacklog() + ? useMessageChannelDrain + ? 0 + : HIGH_PRIORITY_DRAIN_INTERVAL_MS + : BACKGROUND_DRAIN_INTERVAL_MS ) } } @@ -794,6 +997,9 @@ export function writeTerminalOutput( ): void { exposeDebugApi() if (!data) { + // Why: an empty write still consumed its delivery — credit or main's + // in-flight window leaks. + options.ackCredit?.() return } @@ -810,12 +1016,20 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onParsed: options.onParsed + onParsed: options.onParsed, + ackCredit: options.ackCredit }) if (debugEnabled) { debugState.foregroundWriteCount++ debugState.deferredForegroundEnqueueCount++ } + // Why: a visible pane's queue was previously uncapped — a flood the + // drain can't keep up with ballooned renderer memory without bound. + if (queueCapExceeded(queued)) { + replaceBacklogWithWarning(queued, FOREGROUND_BACKLOG_WARNING) + scheduleDrain(0) + return + } if (options.holdForeground) { // Why: synchronized-output start/body chunks contain transient cursor // moves. Holding them prevents Chromium from rasterizing those states. @@ -877,12 +1091,16 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onParsed: options.onParsed + onParsed: options.onParsed, + ackCredit: options.ackCredit }) if (debugEnabled) { debugState.foregroundWriteCount++ debugState.deferredForegroundEnqueueCount++ } + if (queueCapExceeded(entry)) { + replaceBacklogWithWarning(entry, FOREGROUND_BACKLOG_WARNING) + } // Why: returning from a hidden window can have megabytes queued. Keep // byte order, but drain it asynchronously so the first foreground frame // is not pinned behind the entire backlog. @@ -904,12 +1122,16 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onParsed: options.onParsed + onParsed: options.onParsed, + ackCredit: options.ackCredit }) if (debugEnabled) { debugState.foregroundWriteCount++ debugState.deferredForegroundEnqueueCount++ } + if (queueCapExceeded(queued)) { + replaceBacklogWithWarning(queued, FOREGROUND_BACKLOG_WARNING) + } // Why: visible command floods are throughput work, not keystroke echo. // Queue them behind a zero-delay drain so one IPC callback cannot pin // the renderer in xterm.write while input and paint are waiting. @@ -920,16 +1142,27 @@ export function writeTerminalOutput( if (debugEnabled) { debugState.foregroundWriteCount++ } - options.beforeWrite?.(data) - writeForegroundTerminalChunkWithIntent( + const ackCreditsParsed = registerTerminalOutputAckCredits( terminal, - options.stripTransientCursorShows ? removeTransientCursorShowSequences(data) : data, - { - forceViewportRefresh: options.forceForegroundRefresh === true, - followupViewportRefresh: options.followupForegroundRefresh === true, - onParsed: options.onParsed - } + options.ackCredit ? [options.ackCredit] : [] ) + try { + options.beforeWrite?.(data) + writeForegroundTerminalChunkWithIntent( + terminal, + options.stripTransientCursorShows ? removeTransientCursorShowSequences(data) : data, + { + forceViewportRefresh: options.forceForegroundRefresh === true, + followupViewportRefresh: options.followupForegroundRefresh === true, + onParsed: composeParsedCallback(options.onParsed, ackCreditsParsed, undefined) + } + ) + } catch (error) { + // beforeWrite can throw before xterm owns the callback; consume the + // delivery here. xterm write throws are caught by the foreground writer. + ackCreditsParsed?.() + throw error + } return } @@ -943,12 +1176,10 @@ export function writeTerminalOutput( entry.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped } enqueueChunk(entry, data, { - onParsed: options.onParsed + onParsed: options.onParsed, + ackCredit: options.ackCredit }) - if ( - entry.queuedChars > MAX_BACKGROUND_QUEUE_CHARS || - entry.chunks.length - entry.chunkIndex > MAX_BACKGROUND_QUEUE_CHUNKS - ) { + if (queueCapExceeded(entry)) { replaceBacklogWithWarning(entry) } if (debugEnabled) { @@ -977,6 +1208,7 @@ export function flushTerminalOutput( return } if (entry.backgroundBacklogDropped && requestRegisteredTerminalBacklogRecovery(terminal)) { + fireQueuedAckCredits(entry) entry.chunks.length = 0 entry.chunkIndex = 0 entry.queuedChars = 0 @@ -994,6 +1226,7 @@ export function flushTerminalOutput( if (debugEnabled) { debugState.flushWriteCount++ } + const ackCreditsParsed = registerTerminalOutputAckCredits(terminal, queuedWrite.ackCredits) try { entry.beforeWrite?.(queuedWrite.data) if (queuedWrite.foreground) { @@ -1005,16 +1238,23 @@ export function flushTerminalOutput( { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, - onParsed: queuedWrite.onParsed + onParsed: composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, undefined) } ) } else { - writeBackgroundTerminalChunk(terminal, queuedWrite.data, queuedWrite.onParsed) + writeBackgroundTerminalChunk( + terminal, + queuedWrite.data, + composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, undefined) + ) } } catch { // Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping; // a write to a disposed terminal throws. Drop the entry rather than crashing - // the scheduler for other panes still draining. + // the scheduler for other panes still draining. Consumed + abandoned + // chunks both credit their deliveries. + ackCreditsParsed?.() + fireQueuedAckCredits(entry) clearForegroundHoldSafety(entry) clearForegroundCoalesce(entry) recordQueueDebugPressure() @@ -1089,6 +1329,13 @@ export function waitForTerminalOutputParsed(terminal: TerminalOutputTarget): Pro export function discardTerminalOutput(terminal: TerminalOutputTarget): void { exposeDebugApi() + const entry = queuedByTerminal.get(terminal) + if (entry) { + // Why: discarded queued chunks still consumed their deliveries — credit + // them or main's in-flight window leaks (see fireQueuedAckCredits). + fireQueuedAckCredits(entry) + } + discardInFlightTerminalOutputAckCredits(terminal) queuedByTerminal.delete(terminal) discardForegroundRenderSettle(terminal) recordQueueDebugPressure() diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index e0ca805801e..7ae295b4a9d 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -1,5 +1,6 @@ import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' +import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' import { getTerminalWebglAutoDecision, @@ -170,6 +171,10 @@ export function attachWebgl(pane: ManagedPaneInternal): void { pane.id, '— falling back to DOM renderer' ) + // Why: a lost context is the decisive signal for a post-wake garble + // report — it means the glyph atlas was wiped (needs a full reset), not + // just a missed repaint. Silent breadcrumb; the console.warn stays. + recordTerminalWebglDiagnostic('webgl-context-loss', { paneId: pane.id }) // Why: Chromium starts reclaiming terminal contexts under pressure. // Recreating WebGL for this pane can loop context loss and leave xterm // visually blank, so keep the pane on the DOM renderer until the next diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts index bfd67604707..6060ec3b3ee 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts @@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest' import { buildWindowsPtyCompatibilityOptions, isLocalNativeWindowsConpty, - isLocalNativeWindowsPty + isLocalNativeWindowsPty, + resolveWindowsShellOverride } from './windows-pty-compatibility' function writeTerminal(terminal: Terminal, data: string): Promise { @@ -164,6 +165,39 @@ describe('buildWindowsPtyCompatibilityOptions', () => { ).toEqual({}) }) + it('classifies a global-WSL default shell as non-native, matching main', () => { + // Why: main folds the global terminalWindowsShell into its spawn + // classification (isNativeWindowsLocalPtySpawn). Without the fold the + // renderer would call a tab with no override native-ConPTY while main + // never marks it. + const windowsUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + expect( + isLocalNativeWindowsPty({ + userAgent: windowsUserAgent, + connectionId: null, + cwd: 'C:\\repo', + shellOverride: resolveWindowsShellOverride(undefined, 'wsl.exe') + }) + ).toBe(false) + // A tab-level override beats the global setting, both directions. + expect( + isLocalNativeWindowsPty({ + userAgent: windowsUserAgent, + connectionId: null, + cwd: 'C:\\repo', + shellOverride: resolveWindowsShellOverride('powershell.exe', 'wsl.exe') + }) + ).toBe(true) + expect( + isLocalNativeWindowsPty({ + userAgent: windowsUserAgent, + connectionId: null, + cwd: 'C:\\repo', + shellOverride: resolveWindowsShellOverride('wsl.exe', 'powershell.exe') + }) + ).toBe(false) + }) + it('exposes the same local native Windows predicate for related renderer workarounds', () => { expect( isLocalNativeWindowsPty({ diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts index 0f35968c21d..549defbb3a5 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts @@ -62,6 +62,17 @@ export function buildWindowsPtyCompatibilityOptions( } } +/** Mirror of main's effectiveShellOverride fold (pty.ts spawn handlers): a + * tab-level shell override wins, else the global Windows shell setting + * applies — so renderer and main classify a global-WSL default identically + * (the main-side twin is isNativeWindowsLocalPtySpawn). */ +export function resolveWindowsShellOverride( + tabShellOverride: string | null | undefined, + globalWindowsShell: string | null | undefined +): string | undefined { + return tabShellOverride ?? globalWindowsShell ?? undefined +} + /** * Raw client-side heuristic for a native-Windows ConPTY pane (Windows UA, no SSH * connection, non-WSL cwd/shell). Necessary but not sufficient: it cannot tell a diff --git a/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts b/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts new file mode 100644 index 00000000000..52285bae7bd --- /dev/null +++ b/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + _resetWriteCompletionReportsForTests, + runGuardedWriteCompletionStep +} from './xterm-write-callback-guard' +import { writeForegroundTerminalChunk } from './pane-terminal-foreground-render-settle' + +const mocks = vi.hoisted(() => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb +})) + +beforeEach(() => { + mocks.recordRendererCrashBreadcrumb.mockClear() + _resetWriteCompletionReportsForTests() +}) + +describe('runGuardedWriteCompletionStep', () => { + it('contains a synchronous throw and reports a breadcrumb', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(() => + runGuardedWriteCompletionStep('test-step', () => { + throw new RangeError('synthetic settle failure') + }) + ).not.toThrow() + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_write_completion_error', + expect.objectContaining({ + context: 'test-step', + errorName: 'RangeError', + errorMessage: 'synthetic settle failure' + }) + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('caps repeated reports per context so a throw-per-write loop cannot spam breadcrumbs', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + for (let i = 0; i < 20; i++) { + runGuardedWriteCompletionStep('spammy-step', () => { + throw new Error('always fails') + }) + } + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledTimes(5) + } finally { + errorSpy.mockRestore() + } + }) + + it('runs non-throwing steps transparently', () => { + const step = vi.fn() + runGuardedWriteCompletionStep('ok-step', step) + expect(step).toHaveBeenCalledTimes(1) + expect(mocks.recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + }) +}) + +describe('writeForegroundTerminalChunk completion guarding', () => { + it('still releases onParsed when the settle step throws (replay-guard latch protection)', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const pendingCallbacks: (() => void)[] = [] + // Why a getter that throws only after the write is dispatched: it + // models renderer/buffer state failing between parse start and the + // post-parse viewport settle (refreshVisibleRowsNow self-catches, so + // the viewport comparison is the escaping surface). + let bufferAccessPoisoned = false + const realBuffer = { active: { cursorY: 0, baseY: 0, viewportY: 0 } } + const terminal = { + rows: 24, + get buffer() { + if (bufferAccessPoisoned) { + throw new Error('synthetic buffer access failure') + } + return realBuffer + }, + write: (_data: string, cb?: () => void) => { + if (cb) { + pendingCallbacks.push(cb) + } + } + } + const onParsed = vi.fn() + + writeForegroundTerminalChunk(terminal, 'restored bytes', { + forceViewportRefresh: true, + onParsed + }) + bufferAccessPoisoned = true + // Simulate xterm completing the parse: the completion callback must not + // let the settle throw escape into the WriteBuffer, and onParsed (the + // replay-guard release) must still run. + expect(() => pendingCallbacks.forEach((cb) => cb())).not.toThrow() + expect(onParsed).toHaveBeenCalledTimes(1) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_write_completion_error', + expect.objectContaining({ context: 'foreground-render-settle' }) + ) + } finally { + errorSpy.mockRestore() + } + }) +}) diff --git a/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.ts b/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.ts new file mode 100644 index 00000000000..0eb9227737c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.ts @@ -0,0 +1,40 @@ +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' + +// Why: xterm's WriteBuffer._innerWrite invokes write-completion callbacks with +// no try/catch; a synchronous throw skips the loop's tail re-schedule, and +// write() only re-arms processing when the buffer is EMPTY — which a stalled +// buffer never is again. One escaping throw therefore permanently freezes the +// pane: output stops rendering and a pending replay guard never releases, so +// the pane silently eats every keystroke while the shell stays alive +// (Discord #performance / issue #2836). Verified against the vendored xterm +// 6.1.0-beta.287 in xterm-write-buffer-stall.repro.test.ts. +const MAX_REPORTS_PER_CONTEXT = 5 +const reportCountsByContext = new Map() + +/** + * Run one step of a write-completion callback so a synchronous throw cannot + * escape into xterm's WriteBuffer. Steps are guarded individually so an + * earlier step's failure (e.g. a WebGL refresh during viewport settle) cannot + * starve a later step (e.g. the replay-guard release). + */ +export function runGuardedWriteCompletionStep(context: string, step: () => void): void { + try { + step() + } catch (error: unknown) { + const reported = reportCountsByContext.get(context) ?? 0 + if (reported >= MAX_REPORTS_PER_CONTEXT) { + return + } + reportCountsByContext.set(context, reported + 1) + console.error(`[terminal] write-completion step "${context}" threw`, error) + recordRendererCrashBreadcrumb('terminal_write_completion_error', { + context, + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error) + }) + } +} + +export function _resetWriteCompletionReportsForTests(): void { + reportCountsByContext.clear() +} diff --git a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts index daebefb6e73..e5ada3e6ca6 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts @@ -181,7 +181,7 @@ describe('remote terminal frame-drop resync', () => { expect(data).toEqual(['aaa']) expect(server.droppedFrames).toBe(1) // Instead, a fresh authoritative snapshot recovers the terminal. - expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[2J\x1b[3J\x1b[HRECOVERED']) }) it('passes contiguous output straight through without resyncing', async () => { @@ -208,7 +208,7 @@ describe('remote terminal frame-drop resync', () => { await Promise.resolve() expect(data).toEqual(['é']) - expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[2J\x1b[3J\x1b[HRECOVERED']) }) it('defers recovery until an in-flight manual snapshot finishes', async () => { @@ -231,6 +231,6 @@ describe('remote terminal frame-drop resync', () => { await expect(manualSnapshot).resolves.toMatchObject({ data: 'MANUAL' }) expect(server.snapshotRequests).toHaveLength(2) - expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[2J\x1b[3J\x1b[HRECOVERED']) }) }) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 5f0b751a91f..e854a5e7efc 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -9,6 +9,7 @@ import { encodeTerminalStreamJson, encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' +import { e2eConfig } from '@/lib/e2e-config' import { unwrapRuntimeRpcResult } from './runtime-rpc-client' type RuntimeEnvironmentSubscriptionHandle = { @@ -70,10 +71,12 @@ type RemoteRuntimeMultiplexedTerminalState = { streamId: number terminal: string callbacks: RemoteRuntimeMultiplexedTerminalCallbacks + acknowledgeOutput: boolean + heldAckBytes: number snapshotChunks: Uint8Array[] snapshotBytes: number snapshotOverflowed: boolean - snapshotTarget: 'initial' | 'request' + snapshotTarget: 'initial' | 'request' | 'recovery' snapshotInfo: RemoteRuntimeSnapshotInfo | null initialSnapshotReceived: boolean pendingSnapshotRequest: RemoteRuntimeSnapshotRequest | null @@ -123,6 +126,73 @@ const REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS = 10_000 export const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE = 'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.' +type E2eRemoteTerminalMultiplexAckGateSnapshot = { + heldTerminalCount: number + heldStreamCount: number + heldAckChars: number + releasedAckChars: number +} + +type E2eRemoteTerminalMultiplexAckGateApi = { + hold: (terminals: string[]) => void + release: () => void + snapshot: () => E2eRemoteTerminalMultiplexAckGateSnapshot +} + +type E2eRemoteTerminalMultiplexAckGateWindow = Window & { + __remoteTerminalMultiplexAckGate?: E2eRemoteTerminalMultiplexAckGateApi +} + +const e2eHeldRemoteAckTerminals = new Set() +let e2eReleasedRemoteAckChars = 0 + +function shouldHoldE2eRemoteTerminalAck(terminal: string): boolean { + return e2eConfig.exposeStore && e2eHeldRemoteAckTerminals.has(terminal) +} + +function getE2eRemoteAckSnapshot(): E2eRemoteTerminalMultiplexAckGateSnapshot { + let heldStreamCount = 0 + let heldAckChars = 0 + for (const multiplexer of multiplexers.values()) { + for (const stream of multiplexer.getStreamsForE2e()) { + if (stream.heldAckBytes > 0) { + heldStreamCount += 1 + heldAckChars += stream.heldAckBytes + } + } + } + return { + heldTerminalCount: e2eHeldRemoteAckTerminals.size, + heldStreamCount, + heldAckChars, + releasedAckChars: e2eReleasedRemoteAckChars + } +} + +function releaseE2eRemoteTerminalAcks(): void { + for (const multiplexer of multiplexers.values()) { + e2eReleasedRemoteAckChars += multiplexer.releaseHeldAcksForE2e() + } + e2eHeldRemoteAckTerminals.clear() +} + +function exposeE2eRemoteTerminalMultiplexAckGate(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + const target = window as E2eRemoteTerminalMultiplexAckGateWindow + target.__remoteTerminalMultiplexAckGate ??= { + hold: (terminals) => { + releaseE2eRemoteTerminalAcks() + for (const terminal of terminals) { + e2eHeldRemoteAckTerminals.add(terminal) + } + }, + release: releaseE2eRemoteTerminalAcks, + snapshot: getE2eRemoteAckSnapshot + } +} + class RemoteRuntimeTerminalMultiplexer { private readonly streams = new Map() private subscription: RuntimeEnvironmentSubscriptionHandle | null = null @@ -152,6 +222,8 @@ class RemoteRuntimeTerminalMultiplexer { streamId, terminal: args.terminal, callbacks: args.callbacks, + acknowledgeOutput: args.client.type === 'desktop', + heldAckBytes: 0, snapshotChunks: [], snapshotBytes: 0, snapshotOverflowed: false, @@ -198,7 +270,8 @@ class RemoteRuntimeTerminalMultiplexer { streamId, terminal: args.terminal, client: args.client, - viewport: args.viewport + viewport: args.viewport, + capabilities: args.client.type === 'desktop' ? { ackOutput: 1 } : undefined }) ) if (!sent) { @@ -346,21 +419,31 @@ class RemoteRuntimeTerminalMultiplexer { } if (frame.opcode === TerminalStreamOpcode.Output) { const data = decodeTerminalStreamText(frame.payload) - const rawLength = data.length - // Why: a resync snapshot is authoritative; drop live output that arrives - // while it is in flight so the corrupt post-gap tail is never rendered. - if (stream.resyncInFlight) { - return + try { + const rawLength = data.length + // Why: a resync snapshot is authoritative; discard live output while + // it is in flight, but still return transport credit in finally. + if (stream.resyncInFlight) { + return + } + const seq = typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined + if (this.detectOutputGap(stream, seq, rawLength)) { + this.requestResyncSnapshot(stream) + return + } + if (typeof seq === 'number') { + stream.expectedSeq = seq + } + stream.callbacks.onData(data, { seq, rawLength }) + } finally { + if (stream.acknowledgeOutput) { + if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) { + stream.heldAckBytes += frame.payload.byteLength + } else { + this.acknowledgeOutput(stream, frame.payload.byteLength) + } + } } - const seq = typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined - if (this.detectOutputGap(stream, seq, rawLength)) { - this.requestResyncSnapshot(stream) - return - } - if (typeof seq === 'number') { - stream.expectedSeq = seq - } - stream.callbacks.onData(data, { seq, rawLength }) return } if (frame.opcode === TerminalStreamOpcode.SnapshotStart) { @@ -371,7 +454,9 @@ class RemoteRuntimeTerminalMultiplexer { typeof requestId === 'number' || (stream.initialSnapshotReceived && stream.pendingSnapshotRequest) ? 'request' - : 'initial' + : stream.initialSnapshotReceived + ? 'recovery' + : 'initial' return } if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) { @@ -417,6 +502,14 @@ class RemoteRuntimeTerminalMultiplexer { stream.callbacks.onSnapshot(data ?? '', { pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi }) + } else if (target === 'recovery') { + // Why: a server-pushed recovery snapshot replaces terminal state + // mid-session; clear the screen and scrollback before applying it. + // An empty snapshot is still applied so stale dropped output does + // not linger on a terminal the model says is blank. + stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data ?? ''}`, { + pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi + }) } } else if (matchesPendingRequest) { pendingRequest.resolve(null) @@ -562,6 +655,33 @@ class RemoteRuntimeTerminalMultiplexer { return id } + private acknowledgeOutput(stream: RemoteRuntimeMultiplexedTerminalState, bytes: number): boolean { + return this.sendFrame( + stream.streamId, + TerminalStreamOpcode.Ack, + encodeTerminalStreamJson({ bytes }) + ) + } + + getStreamsForE2e(): Iterable { + return this.streams.values() + } + + releaseHeldAcksForE2e(): number { + let released = 0 + for (const stream of this.streams.values()) { + if (stream.heldAckBytes <= 0) { + continue + } + const bytes = stream.heldAckBytes + stream.heldAckBytes = 0 + if (this.acknowledgeOutput(stream, bytes)) { + released += bytes + } + } + return released + } + private sendFrame( streamId: number, opcode: TerminalStreamOpcode, @@ -644,6 +764,7 @@ function releaseRemoteRuntimeTerminalMultiplexer( export function getRemoteRuntimeTerminalMultiplexer( environmentId: string ): RemoteRuntimeTerminalMultiplexer { + exposeE2eRemoteTerminalMultiplexAckGate() let multiplexer = multiplexers.get(environmentId) if (!multiplexer) { multiplexer = new RemoteRuntimeTerminalMultiplexer( @@ -661,6 +782,8 @@ export function _getRemoteRuntimeTerminalMultiplexerCountForTest(): number { export function resetRemoteRuntimeTerminalMultiplexersForTests(): void { multiplexers.clear() + e2eHeldRemoteAckTerminals.clear() + e2eReleasedRemoteAckChars = 0 } function concatBytes(chunks: Uint8Array[]): Uint8Array { diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 0d0a59212f0..79166f666d2 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -4,6 +4,7 @@ import { decodeTerminalStreamFrame, decodeTerminalStreamJson, encodeTerminalStreamFrame, + encodeTerminalStreamJson, encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' import { @@ -98,8 +99,12 @@ describe('remote runtime terminal data subscriptions', () => { const subscribeFrame = decodeTerminalStreamFrame(sendBinary.mock.calls[0][0]) expect(subscribeFrame?.opcode).toBe(TerminalStreamOpcode.Subscribe) const subscribePayload = - subscribeFrame && decodeTerminalStreamJson<{ streamId: number }>(subscribeFrame.payload) + subscribeFrame && + decodeTerminalStreamJson<{ streamId: number; capabilities?: { ackOutput?: 1 } }>( + subscribeFrame.payload + ) expect(subscribePayload?.streamId).toEqual(expect.any(Number)) + expect(subscribePayload?.capabilities).toEqual({ ackOutput: 1 }) callbacks?.onBinary?.( encodeTerminalStreamFrame({ @@ -111,6 +116,12 @@ describe('remote runtime terminal data subscriptions', () => { ) expect(watcher).toHaveBeenCalledWith('live') + const ackFrame = sendBinary.mock.calls + .slice(1) + .map((call) => decodeTerminalStreamFrame(call[0])) + .find((frame) => frame?.opcode === TerminalStreamOpcode.Ack) + expect(ackFrame?.streamId).toBe(subscribePayload!.streamId) + expect(ackFrame && decodeTerminalStreamJson(ackFrame.payload)).toEqual({ bytes: 4 }) expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(1) dispose() expect(unsubscribe).toHaveBeenCalled() @@ -189,3 +200,228 @@ describe('remote runtime terminal data subscriptions', () => { expect(unsubscribe).toHaveBeenCalledOnce() }) }) + +describe('remote runtime terminal multiplex ACK gate', () => { + const runtimeSubscribe = vi.fn() + const sendBinary = vi.fn() + const unsubscribe = vi.fn() + let callbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { message: string }) => void + onClose?: () => void + } | null = null + + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + callbacks = null + runtimeSubscribe.mockImplementation(async (_args: unknown, nextCallbacks: typeof callbacks) => { + callbacks = nextCallbacks + queueMicrotask(() => + callbacks?.onResponse({ + ok: true, + result: { type: 'ready' } + }) + ) + return { unsubscribe, sendBinary } + }) + vi.stubGlobal('window', { + api: { + e2e: { + getConfig: () => ({ exposeStore: true }) + }, + runtimeEnvironments: { + subscribe: runtimeSubscribe + } + } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.resetModules() + }) + + it('holds and releases ACKs for selected remote terminal streams only', async () => { + const { getRemoteRuntimeTerminalMultiplexer, resetRemoteRuntimeTerminalMultiplexersForTests } = + await import('./remote-runtime-terminal-multiplexer') + resetRemoteRuntimeTerminalMultiplexersForTests() + + const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-ack-gate') + const heldTerminal = await multiplexer.subscribeTerminal({ + terminal: 'terminal-held', + client: { id: 'desktop-held', type: 'desktop' }, + callbacks: { + onData: vi.fn(), + onSnapshot: vi.fn() + } + }) + const liveTerminal = await multiplexer.subscribeTerminal({ + terminal: 'terminal-live', + client: { id: 'desktop-live', type: 'desktop' }, + callbacks: { + onData: vi.fn(), + onSnapshot: vi.fn() + } + }) + + await vi.waitFor(() => expect(sendBinary).toHaveBeenCalledTimes(2)) + const heldStreamId = heldTerminal.streamId + const liveStreamId = liveTerminal.streamId + const gate = ( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { + hold: (terminals: string[]) => void + release: () => void + snapshot: () => { + heldTerminalCount: number + heldStreamCount: number + heldAckChars: number + releasedAckChars: number + } + } + } + ).__remoteTerminalMultiplexAckGate + expect(gate).toBeDefined() + gate?.hold(['terminal-held']) + sendBinary.mockClear() + + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: heldStreamId, + seq: 1, + payload: encodeTerminalStreamText('held-output') + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: liveStreamId, + seq: 2, + payload: encodeTerminalStreamText('live-output') + }) + ) + + const immediateAckFrames = sendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Ack) + expect(immediateAckFrames).toHaveLength(1) + expect(immediateAckFrames[0]?.streamId).toBe(liveStreamId) + expect(gate?.snapshot()).toMatchObject({ + heldTerminalCount: 1, + heldStreamCount: 1, + heldAckChars: 'held-output'.length + }) + + gate?.release() + const allAckFrames = sendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Ack) + const releasedAck = allAckFrames.find((frame) => frame?.streamId === heldStreamId) + expect(releasedAck && decodeTerminalStreamJson(releasedAck.payload)).toEqual({ + bytes: 'held-output'.length + }) + expect(gate?.snapshot()).toMatchObject({ + heldTerminalCount: 0, + heldStreamCount: 0, + heldAckChars: 0, + releasedAckChars: 'held-output'.length + }) + + heldTerminal.close() + liveTerminal.close() + }) + + it('applies mid-session recovery snapshots without re-subscribing', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + resetRemoteRuntimeTerminalMultiplexersForTests() + + const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-recovery') + const onSnapshot = vi.fn() + const onSubscribed = vi.fn() + const stream = await multiplexer.subscribeTerminal({ + terminal: 'terminal-recovery', + client: { id: 'desktop-recovery', type: 'desktop' }, + callbacks: { + onData: vi.fn(), + onSnapshot, + onSubscribed + } + }) + await vi.waitFor(() => expect(sendBinary).toHaveBeenCalled()) + const streamId = stream.streamId + + const injectSnapshot = (info: Record, text: string): void => { + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 1, + payload: encodeTerminalStreamJson(info) + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 2, + payload: encodeTerminalStreamText(text) + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 3, + payload: new Uint8Array(0) + }) + ) + } + + injectSnapshot({ kind: 'scrollback', cols: 120, rows: 40, truncated: false }, 'initial state') + expect(onSnapshot).toHaveBeenCalledWith('initial state', { + pendingEscapeTailAnsi: undefined + }) + expect(onSubscribed).toHaveBeenCalledTimes(1) + + injectSnapshot( + { + kind: 'scrollback', + cols: 120, + rows: 40, + reason: 'ack-pending-overflow', + truncated: false + }, + 'recovered state' + ) + // Why: an unsolicited recovery snapshot replaces terminal state, so it + // clears screen and scrollback first and must not replay the subscribe + // lifecycle. + expect(onSnapshot).toHaveBeenCalledWith(`\x1b[2J\x1b[3J\x1b[H${'recovered state'}`, { + pendingEscapeTailAnsi: undefined + }) + expect(onSubscribed).toHaveBeenCalledTimes(1) + + // Why: an empty recovery snapshot means the model terminal is blank, so + // the client must still clear stale dropped output. + injectSnapshot( + { + kind: 'scrollback', + cols: 120, + rows: 40, + reason: 'ack-pending-overflow', + truncated: false + }, + '' + ) + expect(onSnapshot).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', { + pendingEscapeTailAnsi: undefined + }) + expect(onSubscribed).toHaveBeenCalledTimes(1) + + stream.close() + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts index b773a0d7721..bf005d398e7 100644 --- a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts +++ b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts @@ -214,6 +214,52 @@ describe('captureAllSleepingAgentSessions', () => { }) }) + it('skips rewriting an unchanged resume record on repeated capture', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) + const providerSession = { key: 'session_id' as const, id: 'codex-session-1' } + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'working', prompt: 'first task', agentType: 'codex' }, + 'Codex', + { updatedAt: 10, stateStartedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + + store.getState().captureAllSleepingAgentSessions() + const first = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1'] + expect(first).toMatchObject({ origin: 'quit' }) + + // Why: the periodic resume-record capture re-runs this action every + // minute; an unchanged agent must not dirty the store (and the debounced + // session write) with a record differing only by capturedAt. + store.getState().captureAllSleepingAgentSessions() + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(first) + + // A real status change must still refresh the record. + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'waiting', prompt: 'first task', agentType: 'codex' }, + 'Codex', + { updatedAt: 20, stateStartedAt: 20 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + store.getState().captureAllSleepingAgentSessions() + const refreshed = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1'] + expect(refreshed).not.toBe(first) + expect(refreshed).toMatchObject({ state: 'waiting', origin: 'quit' }) + }) + it('preserves hydrated launch config during live recapture without a registry entry', () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 182d113411d..10415550216 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -603,6 +603,34 @@ export function collectHibernatedCompletionEvidenceForWorktree( return retained } +// Why: the periodic resume-record capture re-runs on an interval; comparing +// everything except capturedAt lets an unchanged agent skip the store write +// entirely, so idle ticks never dirty the session persistence pipeline. +function sleepingRecordsEquivalentIgnoringCaptureTime( + existing: SleepingAgentSessionRecord | undefined, + next: SleepingAgentSessionRecord +): boolean { + if (!existing) { + return false + } + return ( + existing.paneKey === next.paneKey && + existing.tabId === next.tabId && + existing.worktreeId === next.worktreeId && + existing.agent === next.agent && + existing.providerSession.key === next.providerSession.key && + existing.providerSession.id === next.providerSession.id && + existing.prompt === next.prompt && + existing.state === next.state && + existing.updatedAt === next.updatedAt && + existing.terminalTitle === next.terminalTitle && + existing.lastAssistantMessage === next.lastAssistantMessage && + existing.interrupted === next.interrupted && + existing.origin === next.origin && + launchConfigsEqual(existing.launchConfig, next.launchConfig) + ) +} + function recoveryRecordMatches( existing: SleepingAgentSessionRecord | undefined, next: SleepingAgentSessionRecord @@ -2203,7 +2231,10 @@ export const createAgentStatusSlice: StateCreator { seedStore(store, { settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'env-1' }, worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })] + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', hostId: 'runtime:env-1' })] }, tabsByWorktree: {}, ptyIdsByTabId: {}, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 211dded1173..77d93f75d19 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -57,6 +57,10 @@ import { restorePtyDataHandlersAfterFailedShutdown, unregisterPtyDataHandlers } from '@/components/terminal-pane/pty-transport' +// Why: import the store-free registry, not terminal-parked-tab-watchers — +// that module imports @/store, and a slice importing it would re-enter store +// creation before this slice finishes evaluating. +import { disposeParkedTerminalWatchersForPtyIds } from '@/components/terminal-pane/terminal-parked-watcher-registry' import { normalizeTerminalLayoutSnapshot, resolvePtyBoundActiveLeafId @@ -2281,6 +2285,11 @@ export const createTerminalSlice: StateCreator // Removing the data handlers first ensures the final flush is a no-op. if (expectedRuntimePtyIds.length === 0) { unregisterPtyDataHandlers(shutdownPtyIds) + // Why: parked-tab byte watchers observe the same flush through dispatcher + // sidecars, which the call above does not touch — dispose them now or a + // just-slept/deleted worktree still gets unread marks and delayed + // bell/completion OS notifications from its teardown bytes. + disposeParkedTerminalWatchersForPtyIds(shutdownPtyIds) } // Why (ordering invariant — DESIGN_DOC §3.3.c): on sleep, capture every diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 1f2ce461ba2..e664e42f8b6 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -20,7 +20,7 @@ import type { WorktreeMeta, WorkspaceKey } from '../../../../shared/types' -import type { TerminalGitHubPRLink } from '@/lib/terminal-github-pr-link-detector' +import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { PendingWorktreeCreation, WorktreeCreationPhase diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 4bd602c7634..13fa9913334 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -51,6 +51,7 @@ import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../../shared/orca-profiles' import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result' +import { EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS } from '../../../shared/pty-delivery-diagnostics' import { createE2EConfig } from '../../../shared/e2e-config' import { relativePathInsideRoot } from '../../../shared/cross-platform-path' import { @@ -571,6 +572,9 @@ function createWebPreloadApi(): Partial { }, settings: { get: async () => getRuntimeBackedStoredSettings(), + // Why: localStorage-backed settings are synchronous in the web client, + // so the pre-hydration kill-switch read works the same as desktop. + getSync: () => getStoredSettings(), set: async (updates) => { if (updates.activeRuntimeEnvironmentId === null) { disconnectActiveRuntimeEnvironment() @@ -2707,9 +2711,21 @@ function createPtyApi(): NonNullable['pty']> { kill: () => Promise.resolve(), ackColdRestore: () => {}, ackData: () => {}, + onDeliveryResyncRequest: () => noopUnsubscribe, + respondDeliveryResync: () => {}, + // Why healthy stub: web terminals ride the remote-runtime transport, not + // main's delivery gate — a zero-in-flight reply keeps the watchdog idle. + reportRendererDeliveryState: () => + Promise.resolve({ inFlightTotalChars: 0, inFlightPtyCount: 0, msSinceLastAck: null }), + getPtyDataListenerCount: () => 0, rendererDispatcherReady: () => {}, setActiveRendererPty: () => {}, setRendererPtyVisible: () => {}, + setHiddenRendererPty: () => {}, + setPtyDeliveryInterest: () => {}, + // Why no-op: remote-runtime PTYs are never hidden-gate markable, so the + // web client has no main-side responder to feed. + publishTerminalViewAttributes: () => {}, hasChildProcesses: () => Promise.resolve(false), getForegroundProcess: () => Promise.resolve(null), getCwd: () => Promise.resolve('~'), @@ -2717,6 +2733,10 @@ function createPtyApi(): NonNullable['pty']> { listSessions: () => Promise.resolve([]), hasPty: () => Promise.resolve(null), getMainBufferSnapshot: () => Promise.resolve(null), + // Why: remote-runtime PTYs never transit local main, so the web client has + // no side-effect facts source; renderer byte parsing stays authoritative. + onSideEffect: () => noopUnsubscribe, + getSideEffectSnapshot: () => Promise.resolve(null), getRendererDeliveryDebugSnapshot: () => Promise.resolve({ pendingPtyCount: 0, @@ -2732,6 +2752,14 @@ function createPtyApi(): NonNullable['pty']> { peakRendererInFlightChars: 0, peakMaxRendererInFlightCharsByPty: 0, ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0, + diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, rendererLifecycleResetCount: 0, lastLifecycleResetClearedChars: 0, rendererPtyDispatcherReady: false, @@ -2740,6 +2768,7 @@ function createPtyApi(): NonNullable['pty']> { resetRendererDeliveryDebug: () => Promise.resolve(), onData: () => noopUnsubscribe, onReplay: () => noopUnsubscribe, + onModelRestoreNeeded: () => noopUnsubscribe, onExit: () => noopUnsubscribe, onSerializeBufferRequest: () => noopUnsubscribe, onClearBufferRequest: () => noopUnsubscribe, diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 90e72a93d90..dbb13895a1a 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -1,34 +1,35 @@ /** - * Compatibility barrel for terminal-title agent detection — used by the main - * process (stats collection), the renderer (activity indicators, unread - * badges), and shared siblings. + * Compatibility barrel for shared terminal agent-title detection. * - * The implementation was split into domain modules in Phase 3 of the title - * evidence work: identity/label detection → `terminal-title-agent-type`, status - * classification → `terminal-title-status`, and display normalization → - * `terminal-title-display`. This barrel is kept so the existing main/mobile/ - * renderer import paths that reference `agent-detection` stay stable. + * Why shared: main and renderer both consume OSC titles for facts, stats, and + * UI state. Keep existing imports stable while the implementation stays split + * into focused modules that satisfy max-lines. (main's #7612 split into + * `terminal-title-*` modules coexists — those files stay on disk for their + * direct `resolveTerminalTitleAgentType`/`synthetic-agent-title` consumers.) */ -export { titleHasAgentName } from './agent-name-token-match' +export type { AgentStatus } from './agent-title-core' +export { + isClaudeManagementTitle, + isCursorNativeAgentTitle, + isGeminiTerminalTitle, + isPiTerminalTitle, + STRONG_IDLE_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE +} from './agent-title-core' +export { getAgentLabel, isClaudeAgent } from './agent-title-identity' +export { + clearWorkingIndicators, + createAgentStatusTracker, + detectAgentStatusFromTitle, + normalizeTerminalTitle +} from './agent-title-status' + +// Re-export so existing `agent-detection` importers keep working. +export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match' export { extractAllOscTitles, extractLastOscTitle, MAX_OSC_TITLE_CHARS } from './osc-title-extraction' export { isShellProcess } from './shell-process-detection' -export { - getAgentLabel, - isClaudeAgent, - isClaudeManagementTitle, - isGeminiTerminalTitle, - isPiTerminalTitle -} from './terminal-title-agent-type' -export type { AgentStatus } from './terminal-title-status' -export { - createAgentStatusTracker, - detectAgentStatusFromTitle, - STRONG_IDLE_KEYWORDS_RE, - STRONG_WORKING_KEYWORDS_RE -} from './terminal-title-status' -export { clearWorkingIndicators, normalizeTerminalTitle } from './terminal-title-display' diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts new file mode 100644 index 00000000000..1bafae3cbd4 --- /dev/null +++ b/src/shared/agent-title-core.ts @@ -0,0 +1,110 @@ +import { + AGY_AGENT_NAME_RE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + titleHasAgentName, + titleHasAnyLegacyAgentName +} from './agent-name-token-match' +import { isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' + +export { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, titleHasAgentName } + +export type AgentStatus = 'working' | 'permission' | 'idle' + +export const CLAUDE_IDLE = '\u2733' // ✳ +const CLAUDE_COMMAND_RE = String.raw`(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?` +export const CLAUDE_MANAGEMENT_TITLE_RE = new RegExp( + String.raw`^\s*(?:"${CLAUDE_COMMAND_RE}"|'${CLAUDE_COMMAND_RE}'|${CLAUDE_COMMAND_RE})\s+agents\s*$`, + 'i' +) + +export const GEMINI_WORKING = '\u2726' // ✦ +export const GEMINI_SILENT_WORKING = '\u23f2' // ⏲ +export const GEMINI_IDLE = '\u25c7' // ◇ +export const GEMINI_PERMISSION = '\u270b' // ✋ + +const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const +const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const + +// Why: plain `\b` matches inside hyphenated tokens and cwd paths such as +// "~/codex/ready"; the left side also blocks path separators for Windows/Unix. +export const STRONG_IDLE_KEYWORDS_RE = new RegExp( + `(?= 0x2800 && codePoint <= 0x28ff) { + return true + } + } + return false +} + +export function containsLegacyAgentName(title: string): boolean { + return titleHasAnyLegacyAgentName(title) +} + +export function containsAgentName(title: string): boolean { + return ( + containsLegacyAgentName(title) || + AGY_AGENT_NAME_RE.test(title) || + DROID_AGENT_NAME_RE.test(title) || + HERMES_AGENT_NAME_RE.test(title) + ) +} + +export function containsAny(title: string, words: readonly string[]): boolean { + const lower = title.toLowerCase() + return words.some((word) => lower.includes(word)) +} + +export function isClaudeManagementTitle(title: string): boolean { + return CLAUDE_MANAGEMENT_TITLE_RE.test(title) +} + +export function isCursorNativeAgentTitle(title: string): boolean { + return title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER +} diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts new file mode 100644 index 00000000000..89dbded12f8 --- /dev/null +++ b/src/shared/agent-title-identity.ts @@ -0,0 +1,111 @@ +import { + AGY_AGENT_NAME_RE, + CLAUDE_IDLE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + containsBrailleSpinner, + isClaudeManagementTitle, + isGeminiTerminalTitle, + isPiAgentTitle, + titleHasAgentName +} from './agent-title-core' +import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title' + +/** + * Returns true when the terminal title matches Claude Code's title conventions. + * Used to scope prompt-cache-timer behavior to Claude sessions only. + */ +export function isClaudeAgent(title: string): boolean { + if (!title || isClaudeManagementTitle(title)) { + return false + } + const lower = title.toLowerCase() + + // Why: Claude title prefixes are stronger than task text, which can mention + // other agents without changing the owning CLI. + if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { + return true + } + if (title.startsWith('. ') || title.startsWith('* ')) { + return true + } + if (containsBrailleSpinner(title)) { + return !lower.includes('cursor') && !lower.includes('openclaude') + } + + const trimmedTitle = title.trimStart() + return ( + trimmedTitle.toLowerCase().startsWith('claude') && titleHasAgentName(trimmedTitle, 'claude') + ) +} + +export function getAgentLabel(title: string): string | null { + if (isClaudeManagementTitle(title)) { + return null + } + // Why: Claude task titles can mention another CLI; the prefix is the identity + // signal, not arbitrary task text. + if ( + title.startsWith(`${CLAUDE_IDLE} `) || + title === CLAUDE_IDLE || + title.startsWith('. ') || + title.startsWith('* ') + ) { + return 'Claude Code' + } + if (isGeminiTerminalTitle(title)) { + return 'Gemini CLI' + } + // Why: Pi-compatible synthetic titles can carry braille spinners, which the + // generic agent-title heuristics would otherwise claim first. + const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title) + if (piCompatibleSyntheticAgentLabel) { + return piCompatibleSyntheticAgentLabel + } + if (isPiAgentTitle(title)) { + return 'Pi' + } + + if (titleHasAgentName(title, 'codex')) { + return 'Codex' + } + if (titleHasAgentName(title, 'openclaude')) { + return 'OpenClaude' + } + if (titleHasAgentName(title, 'copilot')) { + return 'GitHub Copilot' + } + if (titleHasAgentName(title, 'grok')) { + return 'Grok' + } + if (titleHasAgentName(title, 'devin')) { + return 'Devin' + } + if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { + return 'Antigravity' + } + if (titleHasAgentName(title, 'opencode')) { + return 'OpenCode' + } + if (titleHasAgentName(title, 'mimo')) { + return 'MiMo Code' + } + if (titleHasAgentName(title, 'aider')) { + return 'Aider' + } + // Why: match explicit names before Claude's generic braille heuristic. + if (titleHasAgentName(title, 'cursor')) { + return 'Cursor' + } + if (DROID_AGENT_NAME_RE.test(title)) { + return 'Droid' + } + if (HERMES_AGENT_NAME_RE.test(title)) { + return 'Hermes' + } + if (isClaudeAgent(title)) { + return 'Claude Code' + } + + return null +} diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts new file mode 100644 index 00000000000..7c589e9935e --- /dev/null +++ b/src/shared/agent-title-status.ts @@ -0,0 +1,203 @@ +import { + AGY_AGENT_NAME_RE, + BRAILLE_SPINNER_RE, + CLAUDE_IDLE, + CURSOR_NATIVE_TITLE_LOWER, + DROID_AGENT_NAME_RE, + GEMINI_IDLE, + GEMINI_PERMISSION, + GEMINI_SILENT_WORKING, + GEMINI_WORKING, + HERMES_AGENT_NAME_RE, + STRONG_IDLE_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE_GLOBAL, + containsAgentName, + containsAny, + containsBrailleSpinner, + containsLegacyAgentName, + isClaudeManagementTitle, + isGeminiTerminalTitle, + isPiAgentTitle, + isPiTerminalTitle +} from './agent-title-core' +import type { AgentStatus } from './agent-title-core' +import { getPiCompatibleSyntheticAgentStatus } from './pi-compatible-synthetic-title' +import { isGrokRotatingWorkingTitle } from './terminal-title-agent-type' + +/** + * Strip working-status indicators so stale exit titles stop reporting working. + */ +export function clearWorkingIndicators(title: string): string { + let cleaned = title + + cleaned = cleaned.replace(GEMINI_WORKING, '') + cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') + cleaned = cleaned.replace(BRAILLE_SPINNER_RE, '') + if (cleaned.startsWith('. ')) { + cleaned = cleaned.slice(2) + } + if (containsAgentName(cleaned)) { + cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '') + } + + cleaned = cleaned.replace(/\s{2,}/g, ' ').trim() + return cleaned || title +} + +/** + * Tracks agent status transitions from terminal title changes. + */ +export function createAgentStatusTracker( + onBecameIdle: (title: string) => void, + onBecameWorking?: () => void, + onAgentExited?: () => void, + initialTitle?: string +): { + handleTitle: (title: string) => void + seedTitle: (title: string) => void + reset: () => void +} { + // Why: trackers restored mid-session need a last-known status without firing + // callbacks, or a hidden working agent can miss its later idle transition. + let lastStatus: AgentStatus | null = + initialTitle !== undefined ? detectAgentStatusFromTitle(initialTitle) : null + + return { + handleTitle(title: string): void { + const newStatus = detectAgentStatusFromTitle(title) + if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') { + onBecameIdle(title) + } + if (lastStatus !== 'working' && newStatus === 'working') { + onBecameWorking?.() + } + // Why: reverting to a plain shell prompt after idle/permission means the + // agent exited; while working it can just be a transient internal title. + if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) { + lastStatus = null + onAgentExited?.() + } + if (newStatus !== null) { + lastStatus = newStatus + } + }, + seedTitle(title: string): void { + lastStatus = detectAgentStatusFromTitle(title) + }, + reset(): void { + lastStatus = null + } + } +} + +/** + * Normalize high-churn agent titles into stable display labels before storage. + */ +export function normalizeTerminalTitle(title: string): string { + if (!title) { + return title + } + + if (isGeminiTerminalTitle(title)) { + const status = detectAgentStatusFromTitle(title) + if (status === 'permission') { + return `${GEMINI_PERMISSION} Gemini CLI` + } + if (status === 'working') { + return `${GEMINI_WORKING} Gemini CLI` + } + if (status === 'idle') { + return `${GEMINI_IDLE} Gemini CLI` + } + } + + // Why: Pi animates every 80ms; collapse frames while preserving status. + if (isPiAgentTitle(title)) { + const status = detectAgentStatusFromTitle(title) + if (status === 'working') { + return '\u280b Pi' + } + if (status === 'idle') { + return 'Pi' + } + } + + // Why: Grok Build interpolates a rotating status/tool phrase between the + // spinner and its name, so its working frames change the title many times per + // turn. Collapse them to one stable label; idle/session titles carry no + // spinner and pass through, so the meaningful final title still shows (#7863). + if (isGrokRotatingWorkingTitle(title)) { + return '\u280b Grok' + } + + return title +} + +export function detectAgentStatusFromTitle(title: string): AgentStatus | null { + if (!title || isClaudeManagementTitle(title)) { + return null + } + if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) { + return null + } + + if (title.includes(GEMINI_PERMISSION)) { + return 'permission' + } + if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) { + return 'working' + } + if (title.includes(GEMINI_IDLE)) { + return 'idle' + } + + // Why: resolve synthetic Pi/OMP permission/idle labels before the broader + // Pi and braille-spinner checks below. + const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title) + if (piCompatibleSyntheticAgentStatus) { + return piCompatibleSyntheticAgentStatus + } + + if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { + return 'idle' + } + if (isPiTerminalTitle(title)) { + return 'idle' + } + if (containsBrailleSpinner(title)) { + return 'working' + } + + const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title) + const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title) + const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title) + const hasLegacyAgentName = containsLegacyAgentName(title) + if (!hasLegacyAgentName && !hasDroidAgentName && !hasHermesAgentName && !hasAgyAgentName) { + return null + } + if (containsAny(title, ['action required', 'permission', 'waiting'])) { + return 'permission' + } + // Why: boundary-aware regexes avoid cwd/path and substring false positives. + if (STRONG_IDLE_KEYWORDS_RE.test(title)) { + return 'idle' + } + if (STRONG_WORKING_KEYWORDS_RE.test(title)) { + return 'working' + } + if (title.startsWith('. ')) { + return 'working' + } + if (title.startsWith('* ')) { + return 'idle' + } + + // Why: Droid hook events are authoritative; native name-only titles should + // not turn a still-sleeping execute tool into completion. + if (hasDroidAgentName && !hasLegacyAgentName) { + return null + } + + return 'idle' +} diff --git a/src/shared/agent-tui-ansi-fuzz-stream.ts b/src/shared/agent-tui-ansi-fuzz-stream.ts new file mode 100644 index 00000000000..afffcc0a411 --- /dev/null +++ b/src/shared/agent-tui-ansi-fuzz-stream.ts @@ -0,0 +1,279 @@ +// Seeded ANSI byte-stream generator for the terminal garble differential +// fuzz suites (headless-emulator-fidelity.fuzz.test.ts and +// hidden-reveal-reconciliation.fuzz.test.ts). The op mix models real agent +// TUI output (Claude Code / Codex): CR status-line redraws, cursor-up panel +// repaints, DEC 2026 synchronized frames, SGR color runs, wide CJK and ZWJ +// emoji, wrapped long lines, alt-screen sessions, and scroll regions. +// +// Deliberately excluded ops (they would fuzz behavior the snapshot/restore +// contract does not promise to preserve, so any diff would be noise, not a +// garble bug): +// - DECAWM (?7l) and IRM (CSI 4h): SerializeAddon does not re-emit them and +// rehydrateSequences (headless-emulator.ts buildRehydrateSequences) only +// covers alt-screen/bracketed-paste/app-cursor/mouse modes. +// - Terminal queries (DA/DSR/DECRQM): the emulator is write-only by contract +// (headless-emulator.ts onQueryReply gating); replies are a separate +// authority problem with its own pinned tests (session.test.ts). + +/** Same seeded PRNG as retained-tail-redraw-window.equivalence.test.ts. */ +export function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +export type AgentTuiStreamDims = { cols: number; rows: number } + +export type AgentTuiStreamProfile = { + /** Mouse-mode toggles require mirroring TerminalMouseModeMirror to build + * rehydrate parity; the renderer-side fuzz cannot import that main-only + * module (tsconfig.tc.web.json excludes src/main/daemon), so it opts out. */ + includeMouseModes: boolean + /** OSC 8 hyperlinks mark their cells underlined in the xterm buffer, but + * SerializeAddon never re-emits OSC 8 — production restores link ranges + * out-of-band via snapshot.oscLinks (collectHeadlessOscLinkRanges), so + * byte-replay fidelity legitimately drops that underline. The suites pin + * the metadata compensation in a targeted test instead of fuzzing it. */ + includeOscHyperlinks: boolean + opCount: number +} + +function int(rng: () => number, min: number, max: number): number { + return min + Math.floor(rng() * (max - min + 1)) +} + +function pick(rng: () => number, values: readonly T[]): T { + return values[Math.floor(rng() * values.length)]! +} + +const WORDS = [ + 'reading', + 'src/main/daemon/session.ts', + 'tokens 12.4k', + 'esc to interrupt', + 'Thinking…', + 'bash: pnpm typecheck', + '+142 -37', + 'PASS terminal.test.ts', + 'waiting for approval', + 'diff --git a/pty.ts' +] as const + +const WIDE_RUNS = [ + '你好世界', + '터미널 상태 확인', + '進捗を表示中', + '🟢 working', + '🤖 codex', + '✅ done ✨', + // ZWJ emoji join — the exact width divergence the Orca unicode provider + // exists for (shared/terminal-unicode-provider.ts). + '👨‍👩‍👧‍👦 team', + '🇰🇷 locale' +] as const + +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'] as const + +function sgr(rng: () => number): string { + const roll = rng() + if (roll < 0.2) { + return `\x1b[${pick(rng, ['0', '1', '2', '3', '4', '7', '9', '22', '24', '39', '49'])}m` + } + if (roll < 0.55) { + return `\x1b[${int(rng, 30, 37) + (rng() < 0.3 ? 60 : 0)}m` + } + if (roll < 0.8) { + return `\x1b[38;5;${int(rng, 0, 255)}m` + } + return `\x1b[38;2;${int(rng, 0, 255)};${int(rng, 0, 255)};${int(rng, 0, 255)}m` +} + +function textRun(rng: () => number): string { + const parts: string[] = [] + const count = int(rng, 1, 3) + for (let i = 0; i < count; i++) { + parts.push(rng() < 0.25 ? pick(rng, WIDE_RUNS) : pick(rng, WORDS)) + } + return parts.join(' ') +} + +function styledLine(rng: () => number): string { + return `${sgr(rng)}${textRun(rng)}\x1b[0m\r\n` +} + +function panelRedraw(rng: () => number, dims: AgentTuiStreamDims): string { + const height = int(rng, 1, Math.min(6, dims.rows - 2)) + const lines: string[] = [`\x1b[${height}A\r`] + if (rng() < 0.5) { + lines.push('\x1b[0J') + } + for (let i = 0; i < height; i++) { + lines.push(`\x1b[2K${sgr(rng)}│ ${textRun(rng)}\x1b[0m\r\n`) + } + return lines.join('') +} + +function statusLineRewrite(rng: () => number): string { + return `\r\x1b[2K${sgr(rng)}${pick(rng, SPINNER)} ${textRun(rng)}\x1b[0m` +} + +function cursorMotion(rng: () => number, dims: AgentTuiStreamDims): string { + const roll = rng() + if (roll < 0.4) { + return `\x1b[${int(rng, 1, dims.rows)};${int(rng, 1, dims.cols)}H` + } + if (roll < 0.55) { + return `\x1b[${int(rng, 1, dims.cols)}G` + } + return `\x1b[${int(rng, 1, 4)}${pick(rng, ['A', 'B', 'C', 'D'] as const)}` +} + +function eraseOp(rng: () => number): string { + return pick(rng, ['\x1b[K', '\x1b[1K', '\x1b[2K', '\x1b[0J', '\x1b[1J'] as const) +} + +function wrappedLongLine(rng: () => number, dims: AgentTuiStreamDims): string { + const unit = `${textRun(rng)} ` + const repeats = Math.ceil((dims.cols * int(rng, 1, 3)) / Math.max(unit.length, 1)) + 1 + return `${sgr(rng)}${unit.repeat(repeats)}\x1b[0m\r\n` +} + +function scrollRegionBurst(rng: () => number, dims: AgentTuiStreamDims): string { + const top = int(rng, 1, Math.max(1, dims.rows - 4)) + const bottom = int(rng, top + 1, dims.rows) + const body: string[] = [`\x1b[${top};${bottom}r`, `\x1b[${bottom};1H`] + for (let i = 0; i < int(rng, 1, 4); i++) { + body.push(`${textRun(rng)}\r\n`) + } + body.push('\x1b[r') + return body.join('') +} + +function altScreenFrame(rng: () => number, dims: AgentTuiStreamDims): string { + const rows = int(rng, 2, Math.min(8, dims.rows)) + const body: string[] = ['\x1b[?1049h', '\x1b[2J\x1b[H', '\x1b[?25l'] + for (let i = 0; i < rows; i++) { + body.push(`${sgr(rng)}│ ${textRun(rng)}\x1b[0m${i === rows - 1 ? '' : '\r\n'}`) + } + body.push(`\x1b[${int(rng, 1, dims.rows)};${int(rng, 1, dims.cols)}H\x1b[?25h`) + if (rng() < 0.5) { + body.push('\x1b[?1049l') + } + return body.join('') +} + +function synchronizedFrame(rng: () => number, dims: AgentTuiStreamDims): string { + return `\x1b[?2026h${rng() < 0.5 ? panelRedraw(rng, dims) : statusLineRewrite(rng)}\x1b[?2026l` +} + +function savedCursorDetour(rng: () => number, dims: AgentTuiStreamDims): string { + return `\x1b7${cursorMotion(rng, dims)}${sgr(rng)}${textRun(rng)}\x1b[0m\x1b8` +} + +function oscOp(rng: () => number, profile: AgentTuiStreamProfile): string { + const roll = rng() + if (roll < 0.5) { + return `\x1b]0;${textRun(rng)}\x07` + } + if (roll < 0.8) { + return profile.includeOscHyperlinks + ? `\x1b]8;;https://example.com/${int(rng, 1, 999)}\x07link\x1b]8;;\x07` + : `https://example.com/pr/${int(rng, 1, 999)}\r\n` + } + return '\x1b]133;A\x07' +} + +function modeToggle(rng: () => number, profile: AgentTuiStreamProfile): string { + const toggles = ['\x1b[?2004h', '\x1b[?2004l', '\x1b[?1h', '\x1b[?1l', '\x1b[?25l', '\x1b[?25h'] + if (profile.includeMouseModes) { + toggles.push( + '\x1b[?1000h', + '\x1b[?1002h', + '\x1b[?1003h', + '\x1b[?1006h', + '\x1b[?1000l', + '\x1b[?1006l' + ) + } + return pick(rng, toggles) +} + +/** One seeded agent-TUI-shaped op. Weights favor the redraw ops that have + * historically produced hidden-restore garble (CR rewrites, cursor-up panel + * repaints, DEC 2026 frames, alt-screen churn). */ +function nextOp( + rng: () => number, + dims: AgentTuiStreamDims, + profile: AgentTuiStreamProfile +): string { + const roll = rng() + if (roll < 0.18) { + return styledLine(rng) + } + if (roll < 0.32) { + return statusLineRewrite(rng) + } + if (roll < 0.46) { + return panelRedraw(rng, dims) + } + if (roll < 0.54) { + return synchronizedFrame(rng, dims) + } + if (roll < 0.62) { + return wrappedLongLine(rng, dims) + } + if (roll < 0.7) { + return cursorMotion(rng, dims) + eraseOp(rng) + } + if (roll < 0.76) { + return altScreenFrame(rng, dims) + } + if (roll < 0.82) { + return scrollRegionBurst(rng, dims) + } + if (roll < 0.88) { + return savedCursorDetour(rng, dims) + } + if (roll < 0.94) { + return oscOp(rng, profile) + } + return modeToggle(rng, profile) +} + +export function buildAgentTuiStreamOps( + rng: () => number, + dims: AgentTuiStreamDims, + profile: AgentTuiStreamProfile +): string[] { + const ops: string[] = [] + for (let i = 0; i < profile.opCount; i++) { + ops.push(nextOp(rng, dims, profile)) + } + return ops +} + +/** Splits a stream at random boundaries, including inside escape sequences + * (PTY chunking does that constantly). Never splits a surrogate pair: PTY + * bytes are decoded to complete code points before reaching JS strings. */ +export function splitIntoRandomChunks( + rng: () => number, + stream: string, + bounds: { minLen: number; maxLen: number } +): string[] { + const chunks: string[] = [] + let cursor = 0 + while (cursor < stream.length) { + let end = Math.min(stream.length, cursor + int(rng, bounds.minLen, bounds.maxLen)) + const boundaryCode = stream.charCodeAt(end - 1) + if (end < stream.length && boundaryCode >= 0xd800 && boundaryCode <= 0xdbff) { + end += 1 + } + chunks.push(stream.slice(cursor, end)) + cursor = end + } + return chunks +} diff --git a/src/renderer/src/components/terminal-pane/command-code-output-status.test.ts b/src/shared/command-code-output-status.test.ts similarity index 100% rename from src/renderer/src/components/terminal-pane/command-code-output-status.test.ts rename to src/shared/command-code-output-status.test.ts diff --git a/src/renderer/src/components/terminal-pane/command-code-output-status.ts b/src/shared/command-code-output-status.ts similarity index 95% rename from src/renderer/src/components/terminal-pane/command-code-output-status.ts rename to src/shared/command-code-output-status.ts index 97e5f00db8b..8bba8a7472b 100644 --- a/src/renderer/src/components/terminal-pane/command-code-output-status.ts +++ b/src/shared/command-code-output-status.ts @@ -1,3 +1,11 @@ +/** + * Command Code TUI output scrape — that CLI lacks hooks, so working/done + * agent-status rows are seeded from its rendered status words and idle + * composer. Shared because main runs this per-PTY under side-effect authority + * (emitting command-code facts) while the renderer keeps the byte path for + * remote-runtime PTYs and the kill switch + * (docs/reference/terminal-side-effect-authority.md). + */ import { cleanCommandCodePromptCandidate, isCommandCodeIdlePromptCandidate diff --git a/src/renderer/src/components/terminal-pane/command-code-prompt-text.ts b/src/shared/command-code-prompt-text.ts similarity index 100% rename from src/renderer/src/components/terminal-pane/command-code-prompt-text.ts rename to src/shared/command-code-prompt-text.ts diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d051d0500bb..f0b6c9dc04e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -309,6 +309,10 @@ export function getDefaultSettings(homedir: string): GlobalSettings { claudeManagedAccounts: [], activeClaudeManagedAccountId: null, terminalScopeHistoryByWorktree: true, + terminalHiddenViewParking: true, + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true, defaultTuiAgent: null, disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS], claudeAgentTeamsDefaultDisabledMigrated: true, diff --git a/src/shared/e2e-config.ts b/src/shared/e2e-config.ts index 54da72a45f0..e8f34d6bf51 100644 --- a/src/shared/e2e-config.ts +++ b/src/shared/e2e-config.ts @@ -3,23 +3,34 @@ export type E2EConfig = { headless: boolean exposeStore: boolean userDataDir: string | null + /** Test-only override (ORCA_E2E_TERMINAL_PARKING_DELAY_MS) shrinking the + * terminal hidden-view parking delays. null means use production timing. */ + terminalParkingDelayMs: number | null } type E2EConfigInput = { headless?: boolean exposeStore?: boolean userDataDir?: string | null + terminalParkingDelayMs?: number | null } export function createE2EConfig(input: E2EConfigInput): E2EConfig { const userDataDir = input.userDataDir?.trim() || null const headless = Boolean(input.headless) const exposeStore = Boolean(input.exposeStore) + const terminalParkingDelayMs = + typeof input.terminalParkingDelayMs === 'number' && + Number.isFinite(input.terminalParkingDelayMs) && + input.terminalParkingDelayMs > 0 + ? input.terminalParkingDelayMs + : null return { enabled: headless || exposeStore || userDataDir !== null, headless, exposeStore, - userDataDir + userDataDir, + terminalParkingDelayMs } } diff --git a/src/shared/github-links.ts b/src/shared/github-links.ts new file mode 100644 index 00000000000..a53562f5d5d --- /dev/null +++ b/src/shared/github-links.ts @@ -0,0 +1,102 @@ +// Why shared: main's terminal side-effect tracker emits pr-link facts +// (terminal-side-effect-authority.md, slice 3) and needs the same GitHub URL +// parsing core the renderer link picker uses. +const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i + +export type RepoSlug = { + owner: string + repo: string +} + +export type GitHubIssueOrPRLink = { + slug: RepoSlug + number: number + type: 'issue' | 'pr' +} + +export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null { + if (!slug?.owner || !slug.repo) { + return null + } + return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}` +} + +function matchGitHubItemPath(url: URL): RegExpExecArray | null { + return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, '')) +} + +function parseGitHubItemNumber(value: string): number | null { + const parsed = Number.parseInt(value, 10) + return parsed > 0 ? parsed : null +} + +/** + * Parses a GitHub issue/PR reference from plain input. + * Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs. + */ +export function parseGitHubIssueOrPRNumber(input: string): number | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed + if (/^\d+$/.test(numeric)) { + return parseGitHubItemNumber(numeric) + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + + const match = matchGitHubItemPath(url) + if (!match) { + return null + } + + return parseGitHubItemNumber(match[4]) +} + +/** + * Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns + * null for anything that isn't a recognizable GitHub-shaped issue or pull URL. + */ +export function parseGitHubIssueOrPRLink(input: string): GitHubIssueOrPRLink | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + + const match = matchGitHubItemPath(url) + if (!match) { + return null + } + const number = parseGitHubItemNumber(match[4]) + if (number === null) { + return null + } + + return { + slug: { owner: match[1], repo: match[2] }, + type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue', + number + } +} diff --git a/src/shared/osc-title-scan-tail.test.ts b/src/shared/osc-title-scan-tail.test.ts new file mode 100644 index 00000000000..f6afef56f48 --- /dev/null +++ b/src/shared/osc-title-scan-tail.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { extractOscTitleScanTail } from './osc-title-scan-tail' + +describe('extractOscTitleScanTail', () => { + it('keeps incomplete OSC title candidates only', () => { + expect(extractOscTitleScanTail('\x1b]0;Codex work')).toBe('\x1b]0;Codex work') + expect(extractOscTitleScanTail('\x1b]2;Codex working\x1b')).toBe('\x1b]2;Codex working\x1b') + expect(extractOscTitleScanTail('\x1b]')).toBe('\x1b]') + expect(extractOscTitleScanTail('\x1b]1')).toBe('\x1b]1') + }) + + it('does not carry non-title OSC payloads into the title scanner', () => { + expect(extractOscTitleScanTail('\x1b]133;D;13')).toBe('') + expect(extractOscTitleScanTail('\x1b]7;file://host/tmp')).toBe('') + expect(extractOscTitleScanTail('\x1b]133;D;0\x07\x1b')).toBe('\x1b') + }) +}) diff --git a/src/shared/osc-title-scan-tail.ts b/src/shared/osc-title-scan-tail.ts index e37bed4007c..62e49cf9236 100644 --- a/src/shared/osc-title-scan-tail.ts +++ b/src/shared/osc-title-scan-tail.ts @@ -1,18 +1,29 @@ const OSC_TITLE_SCAN_TAIL_LIMIT = 4096 const OSC_TITLE_PREFIX_LENGTH = 4 +const OSC_TITLE_CODES = new Set(['0', '1', '2']) export function extractOscTitleScanTail(input: string): string { const lastOsc = input.lastIndexOf('\x1b]') if (lastOsc !== -1) { const suffix = input.slice(lastOsc) if (!suffix.includes('\x07') && !suffix.includes('\x1b\\')) { - return trimOscTitleScanTail(suffix) + return extractIncompleteTitleOscTail(suffix) } return input.endsWith('\x1b') ? '\x1b' : '' } return input.endsWith('\x1b') ? '\x1b' : '' } +function extractIncompleteTitleOscTail(suffix: string): string { + const parameterEnd = suffix.indexOf(';', 2) + if (parameterEnd === -1) { + const partialParameter = suffix.slice(2) + return ['', '0', '1', '2'].includes(partialParameter) ? trimOscTitleScanTail(suffix) : '' + } + const parameter = suffix.slice(2, parameterEnd) + return OSC_TITLE_CODES.has(parameter) ? trimOscTitleScanTail(suffix) : '' +} + function trimOscTitleScanTail(value: string): string { if (value.length <= OSC_TITLE_SCAN_TAIL_LIMIT) { return value diff --git a/src/shared/pty-delivery-diagnostics.test.ts b/src/shared/pty-delivery-diagnostics.test.ts new file mode 100644 index 00000000000..c7d8b6140ec --- /dev/null +++ b/src/shared/pty-delivery-diagnostics.test.ts @@ -0,0 +1,77 @@ +// Why: pins the freeze-report primitives — the breadcrumb ring must stay +// bounded and coalesce repeat events (a flood costs one slot per second, not +// unbounded memory), and pty-id redaction must keep the correlatable suffix +// while dropping the path-bearing worktree prefix. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createPtyDeliveryBreadcrumbRing, + redactPtyIdForDiagnostics +} from './pty-delivery-diagnostics' + +describe('pty delivery breadcrumb ring', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('coalesces same-kind events inside the window and separates them outside it', () => { + const ring = createPtyDeliveryBreadcrumbRing(10, 1_000) + ring.record('gate-mark', { id: 'a' }) + vi.advanceTimersByTime(200) + ring.record('gate-mark', { id: 'b' }) + vi.advanceTimersByTime(200) + ring.record('gate-mark') + + let entries = ring.snapshot() + expect(entries).toHaveLength(1) + expect(entries[0].repeats).toBe(3) + // The freshest detail wins so the report shows the latest actor. + expect(entries[0].detail).toEqual({ id: 'b' }) + + vi.advanceTimersByTime(2_000) + ring.record('gate-mark', { id: 'c' }) + entries = ring.snapshot() + expect(entries).toHaveLength(2) + expect(entries[1].repeats).toBeUndefined() + }) + + it('does not coalesce across different kinds and stays bounded at capacity', () => { + const ring = createPtyDeliveryBreadcrumbRing(5, 1_000) + ring.record('gate-mark') + ring.record('gate-unmark') + expect(ring.snapshot()).toHaveLength(2) + + for (let i = 0; i < 20; i++) { + vi.advanceTimersByTime(2_000) + ring.record(`kind-${i}`) + } + const entries = ring.snapshot() + expect(entries).toHaveLength(5) + expect(entries[4].kind).toBe('kind-19') + }) + + it('snapshot returns copies and reset empties the ring', () => { + const ring = createPtyDeliveryBreadcrumbRing(5, 1_000) + ring.record('watchdog-heal', { healCount: 1 }) + const entries = ring.snapshot() + entries[0].kind = 'tampered' + expect(ring.snapshot()[0].kind).toBe('watchdog-heal') + ring.reset() + expect(ring.snapshot()).toHaveLength(0) + }) +}) + +describe('redactPtyIdForDiagnostics', () => { + it('keeps the @@ suffix and drops the path-bearing worktree prefix', () => { + expect(redactPtyIdForDiagnostics('/Users/someone/repo@@ab12cd34')).toBe('…@@ab12cd34') + }) + + it('truncates long ids without a separator and passes short ids through', () => { + expect(redactPtyIdForDiagnostics('pty-1')).toBe('pty-1') + const long = 'x'.repeat(40) + expect(redactPtyIdForDiagnostics(long)).toBe(`…${'x'.repeat(12)}`) + }) +}) diff --git a/src/shared/pty-delivery-diagnostics.ts b/src/shared/pty-delivery-diagnostics.ts new file mode 100644 index 00000000000..1b8b5111763 --- /dev/null +++ b/src/shared/pty-delivery-diagnostics.ts @@ -0,0 +1,107 @@ +/** + * One-paste terminal freeze diagnostics: shared shapes for the breadcrumb + * rings kept in BOTH processes and for the per-pty delivery table main embeds + * in its debug snapshot. The goal is that a single console command captures + * enough history + state to attribute any frozen-terminal report without + * asking the user for more logs. + */ + +export type PtyDeliveryBreadcrumb = { + atMs: number + kind: string + detail?: Record + /** Same-kind events within the coalesce window fold into this counter. */ + repeats?: number +} + +export type PtyDeliveryBreadcrumbRing = { + record: (kind: string, detail?: PtyDeliveryBreadcrumb['detail']) => void + snapshot: () => PtyDeliveryBreadcrumb[] + reset: () => void +} + +const BREADCRUMB_RING_CAPACITY = 100 +const BREADCRUMB_COALESCE_MS = 1_000 + +// Why a ring with same-kind coalescing: breadcrumbs record rare transitions, +// but a pathological loop (marker flood, gate flapping) must cost one array +// slot + counter bump per second, never unbounded memory or GC churn. +export function createPtyDeliveryBreadcrumbRing( + capacity = BREADCRUMB_RING_CAPACITY, + coalesceMs = BREADCRUMB_COALESCE_MS +): PtyDeliveryBreadcrumbRing { + let entries: PtyDeliveryBreadcrumb[] = [] + return { + record(kind, detail) { + const now = Date.now() + const last = entries.at(-1) + if (last && last.kind === kind && now - last.atMs < coalesceMs) { + last.repeats = (last.repeats ?? 1) + 1 + last.atMs = now + if (detail !== undefined) { + last.detail = detail + } + return + } + entries.push(detail === undefined ? { atMs: now, kind } : { atMs: now, kind, detail }) + if (entries.length > capacity) { + entries = entries.slice(entries.length - capacity) + } + }, + snapshot() { + return entries.map((entry) => ({ ...entry })) + }, + reset() { + entries = [] + } + } +} + +// Why redact: daemon session ids embed worktree paths +// (`${worktreeId}@@${shortUuid}`). The `@@` suffix is unique enough to +// correlate a pty across the renderer/main sections of one report without +// shipping the user's filesystem layout. +export function redactPtyIdForDiagnostics(id: string): string { + const separatorIdx = id.lastIndexOf('@@') + if (separatorIdx !== -1) { + return `…${id.slice(separatorIdx)}` + } + return id.length <= 12 ? id : `…${id.slice(-12)}` +} + +export type PtyPerPtyDeliveryDiagnostics = { + id: string + sentChars: number + ackedChars: number + inFlightChars: number + pendingChars: number + hidden: boolean + visible: boolean + active: boolean + msSinceLastSend: number | null + msSinceLastAck: number | null +} + +export type PtyMainDeliveryDiagnostics = { + appVersion: string + mainUptimeMs: number + windowFocused: boolean | null + windowVisible: boolean | null + windowMinimized: boolean | null + msSinceLastPowerSuspend: number | null + msSinceLastPowerResume: number | null + perPty: PtyPerPtyDeliveryDiagnostics[] + breadcrumbs: PtyDeliveryBreadcrumb[] +} + +export const EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS: PtyMainDeliveryDiagnostics = { + appVersion: '', + mainUptimeMs: 0, + windowFocused: null, + windowVisible: null, + windowMinimized: null, + msSinceLastPowerSuspend: null, + msSinceLastPowerResume: null, + perPty: [], + breadcrumbs: [] +} diff --git a/src/shared/pty-model-restore-marker.ts b/src/shared/pty-model-restore-marker.ts new file mode 100644 index 00000000000..a182226a0d0 --- /dev/null +++ b/src/shared/pty-model-restore-marker.ts @@ -0,0 +1,19 @@ +/** + * Out-of-band `pty:modelRestoreNeeded` (main → renderer) payload. + * + * Why a dedicated channel instead of an in-band sentinel chunk: an empty + * `pty:data` chunk is indistinguishable from a real chunk whose bytes were + * entirely stripped by renderer-side OSC-9999 cleaning, so an in-band marker + * could spuriously trigger full snapshot restores on visible panes. The + * marker is delivery machinery, not PTY data — remote-runtime transports + * never see it. + */ +export type PtyModelRestoreReason = 'hidden-drop' | 'unhide' | 'pending-cap' | 'delivery-heal' + +export type PtyModelRestoreNeededEvent = { + id: string + reason: PtyModelRestoreReason + /** Main's PTY output sequence at emit time — everything at or before this + * point is only recoverable from the model snapshot. */ + markerSeq?: number +} diff --git a/src/shared/pty-renderer-delivery-health.ts b/src/shared/pty-renderer-delivery-health.ts new file mode 100644 index 00000000000..987759ec464 --- /dev/null +++ b/src/shared/pty-renderer-delivery-health.ts @@ -0,0 +1,45 @@ +/** + * `pty:reportRendererDeliveryState` (renderer → main, invoke) payload/reply. + * + * Why invoke and renderer-initiated: field evidence (v1.4.121-rc.0 snapshot, + * 2026-07-06) proved a wedge where main→renderer push delivery (`pty:data`, + * `pty:requestDeliveryResync`, `pty:modelRestoreNeeded`) goes silently dead + * while renderer→main invoke IPC stays healthy. Every push-initiated recovery + * path (cumulative-ACK self-heal, solicited resync, wake relay) is unreachable + * in that state, so the delivery watchdog reports and heals over invoke — the + * direction proven alive. + */ +export type PtyRendererDeliveryStateReport = { + /** Cumulative chars received per PTY, counted at dispatcher enqueue — + * BEFORE parse-deferred ACK crediting. The gap between main's sentChars + * and this total is bytes provably lost in the push channel, distinct + * from bytes received but still queued for parsing. */ + receivedCharsByPty: Record + /** Cumulative processed (ACK-credited) chars per PTY — same totals the + * ACK path and resync response carry; merging them here is a free extra + * repair lane for the lost-ACK variant. */ + processedCharsByPty: Record + /** Set on the confirming tick: main may write off provably-lost bytes and + * answer with restore markers for the renderer to route locally. */ + heal?: boolean + /** `ipcRenderer.listenerCount('pty:data')` at heal time — discriminates + * "listener detached" from "channel dead" in field logs. */ + rendererPtyDataListenerCount?: number | null +} + +export type PtyDeliveryWriteOff = { + id: string + /** Main's PTY output sequence at write-off — everything at or before this + * is only recoverable from the model snapshot (pulled restore marker). */ + markerSeq?: number + writtenOffChars: number +} + +export type PtyRendererDeliveryHealthReply = { + inFlightTotalChars: number + inFlightPtyCount: number + /** null = no ACK received since main-side counters were (re)created. */ + msSinceLastAck: number | null + /** Present only on a heal report that actually wrote off lost bytes. */ + writtenOff?: PtyDeliveryWriteOff[] +} diff --git a/src/renderer/src/components/terminal-pane/bell-detector.test.ts b/src/shared/terminal-bell-detector.test.ts similarity index 93% rename from src/renderer/src/components/terminal-pane/bell-detector.test.ts rename to src/shared/terminal-bell-detector.test.ts index bd48ba98381..8f1ce7b0bfc 100644 --- a/src/renderer/src/components/terminal-pane/bell-detector.test.ts +++ b/src/shared/terminal-bell-detector.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createBellDetector } from './bell-detector' +import { createBellDetector } from './terminal-bell-detector' describe('createBellDetector', () => { it('skips ANSI chunks without losing later real bells', () => { diff --git a/src/renderer/src/components/terminal-pane/bell-detector.ts b/src/shared/terminal-bell-detector.ts similarity index 82% rename from src/renderer/src/components/terminal-pane/bell-detector.ts rename to src/shared/terminal-bell-detector.ts index 1afdb51e28c..cce08a7dbd0 100644 --- a/src/renderer/src/components/terminal-pane/bell-detector.ts +++ b/src/shared/terminal-bell-detector.ts @@ -2,6 +2,10 @@ * Stateful BEL detector that correctly ignores BEL (0x07) bytes * occurring inside OSC escape sequences. * + * Shared between the renderer transport processor and main's per-PTY + * side-effect tracker (docs/reference/terminal-side-effect-authority.md): + * bell semantics must not drift between the two parsing authorities. + * * Why stateful: PTY data arrives in arbitrary chunks, so an OSC sequence * may span multiple calls. The detector tracks in-progress escape state * across invocations so a BEL used as an OSC terminator is never @@ -17,7 +21,10 @@ * that ended mid-escape does not leak into the next stream. */ export type BellDetector = { - chunkContainsBell(data: string): boolean + /** `hints.containsOscIntroducer` lets a caller that already scanned for + * `\x1b]` (the title-extraction gate) share the result instead of paying + * a second includes() pass per chunk on the hot path. */ + chunkContainsBell(data: string, hints?: { containsOscIntroducer?: boolean }): boolean reset(): void } @@ -27,11 +34,11 @@ export function createBellDetector(): BellDetector { let pendingOscEscape = false return { - chunkContainsBell(data: string): boolean { + chunkContainsBell(data: string, hints: { containsOscIntroducer?: boolean } = {}): boolean { if (!inOsc && !pendingEscape && !data.includes('\x07')) { // Why: CSI/plain chunks with no BEL and no OSC start cannot affect // bell state; avoid walking every byte of normal terminal output. - if (!data.includes('\x1b]')) { + if (!(hints.containsOscIntroducer ?? data.includes('\x1b]'))) { pendingEscape = data.endsWith('\x1b') return false } diff --git a/src/renderer/src/lib/terminal-github-pr-link-detector.test.ts b/src/shared/terminal-github-pr-link-detector.test.ts similarity index 100% rename from src/renderer/src/lib/terminal-github-pr-link-detector.test.ts rename to src/shared/terminal-github-pr-link-detector.test.ts diff --git a/src/renderer/src/lib/terminal-github-pr-link-detector.ts b/src/shared/terminal-github-pr-link-detector.ts similarity index 91% rename from src/renderer/src/lib/terminal-github-pr-link-detector.ts rename to src/shared/terminal-github-pr-link-detector.ts index bd3cedbcc5b..ee7d92e60c6 100644 --- a/src/renderer/src/lib/terminal-github-pr-link-detector.ts +++ b/src/shared/terminal-github-pr-link-detector.ts @@ -1,3 +1,12 @@ +/** + * Chunk-boundary-safe GitHub PR URL scan over PTY output. + * + * Why shared: terminal-side-effect-authority.md (slice 3) makes main emit + * `pr-link` facts from its per-PTY tracker for local/SSH PTYs, while the + * renderer keeps byte-scanning for remote-runtime PTYs and the kill-switch-off + * path. Both paths must share the carry/dedupe semantics or links split across + * chunks would resolve differently per authority mode. + */ import type { RepoSlug } from './github-links' import { parseGitHubIssueOrPRLink } from './github-links' diff --git a/src/shared/terminal-osc133-command-finished.ts b/src/shared/terminal-osc133-command-finished.ts new file mode 100644 index 00000000000..e4b9cdb8129 --- /dev/null +++ b/src/shared/terminal-osc133-command-finished.ts @@ -0,0 +1,108 @@ +/** + * Chunk-boundary-safe OSC 133;D (command finished) scanner. + * + * Why shared: terminal-side-effect-authority.md (slice 3) makes main emit + * `command-finished` facts from its per-PTY tracker for local/SSH PTYs, while + * the renderer keeps byte-parsing for remote-runtime PTYs and the + * kill-switch-off path. The carry semantics (split prefixes, BEL/ST + * terminators, best-effort exit codes) must be identical in both. + */ + +type OscTerminator = { + index: number + length: number +} + +const OSC_133_PREFIX = '\x1b]133;' +const MAX_OSC_CARRY_LENGTH = 4096 + +function findOscTerminator(data: string, startIndex: number): OscTerminator | null { + const bel = data.indexOf('\x07', startIndex) + const st = data.indexOf('\x1b\\', startIndex) + + if (bel === -1 && st === -1) { + return null + } + if (bel !== -1 && (st === -1 || bel < st)) { + return { index: bel, length: 1 } + } + return { index: st, length: 2 } +} + +function parseBestEffortExitCode(value: string | undefined): number | null { + if (!value) { + return null + } + const parsed = Number.parseInt(value, 10) + return Number.isNaN(parsed) ? null : parsed +} + +function findPrefixCarry(data: string): string { + const maxCarryLength = Math.min(data.length, OSC_133_PREFIX.length - 1) + for (let length = maxCarryLength; length > 0; length -= 1) { + const suffix = data.slice(data.length - length) + if (OSC_133_PREFIX.startsWith(suffix)) { + return suffix + } + } + return '' +} + +export type Osc133CommandFinishedScanner = { + /** Feed one raw PTY chunk; fires once per complete OSC 133;D sequence. */ + scan: (data: string) => void + /** Drop the cross-chunk carry (transport teardown / parser reset). */ + reset: () => void +} + +export function createOsc133CommandFinishedScanner( + onCommandFinished: (bestEffortExitCode: number | null) => void, + /** OSC 133;C — the shell exec'd a command; the pane's foreground changed. */ + onCommandStarted?: () => void +): Osc133CommandFinishedScanner { + let carry = '' + + const handleOsc133 = (payload: string): void => { + const [sequence, exitCode] = payload.split(';') + if (sequence === 'C') { + onCommandStarted?.() + return + } + if (sequence === 'D') { + onCommandFinished(parseBestEffortExitCode(exitCode)) + } + } + + const scan = (data: string): void => { + let combined = carry + data + carry = '' + + while (combined.length > 0) { + const start = combined.indexOf(OSC_133_PREFIX) + if (start === -1) { + carry = findPrefixCarry(combined) + return + } + + const payloadStart = start + OSC_133_PREFIX.length + const terminator = findOscTerminator(combined, payloadStart) + if (!terminator) { + carry = combined.slice(start) + if (carry.length > MAX_OSC_CARRY_LENGTH) { + carry = carry.slice(carry.length - MAX_OSC_CARRY_LENGTH) + } + return + } + + handleOsc133(combined.slice(payloadStart, terminator.index)) + combined = combined.slice(terminator.index + terminator.length) + } + } + + return { + scan, + reset() { + carry = '' + } + } +} diff --git a/src/shared/terminal-output-side-effects.test.ts b/src/shared/terminal-output-side-effects.test.ts new file mode 100644 index 00000000000..d9da1b23928 --- /dev/null +++ b/src/shared/terminal-output-side-effects.test.ts @@ -0,0 +1,222 @@ +// Why: slice 3 of terminal-side-effect-authority.md adds OSC 133;D +// command-finished and GitHub pr-link scanning to the shared tracker so main +// emits those facts for local/SSH PTYs. These tests pin the chunk-boundary +// carry, exit-code best-effort, dedupe, and synthetic-frame isolation rules. +import { describe, expect, it } from 'vitest' +import { + createTerminalTitleTracker, + type TerminalTitleTrackerCallbacks +} from './terminal-output-side-effects' + +const ESC = '\x1b' +const BEL = '\x07' +const ST = `${ESC}\\` + +type RecordedEvent = + | ['title', string] + | ['bell'] + | ['finished', number | null] + | ['pr', string, number] + | ['2031-subscribe'] + +function createRecordingTracker(overrides: TerminalTitleTrackerCallbacks = {}): { + events: RecordedEvent[] + tracker: ReturnType +} { + const events: RecordedEvent[] = [] + const tracker = createTerminalTitleTracker({ + onTitle: (normalized) => events.push(['title', normalized]), + onBell: () => events.push(['bell']), + onCommandFinished: (exitCode) => events.push(['finished', exitCode]), + onPrLink: (link) => events.push(['pr', link.url, link.number]), + onMode2031Subscribe: () => events.push(['2031-subscribe']), + ...overrides + }) + return { events, tracker } +} + +describe('createTerminalTitleTracker command-finished facts', () => { + it('emits command-finished with best-effort exit codes', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`before${ESC}]133;A${BEL}prompt${ESC}]133;B${BEL}`) + tracker.handleChunk(`${ESC}]133;C${BEL}running${ESC}]133;D;0${BEL}`) + tracker.handleChunk(`${ESC}]133;D;130${BEL}`) + tracker.handleChunk(`${ESC}]133;D;not-a-number${BEL}`) + tracker.handleChunk(`${ESC}]133;D${BEL}`) + + expect(events).toEqual([ + ['finished', 0], + ['finished', 130], + ['finished', null], + ['finished', null] + ]) + }) + + it('detects OSC 133;D split across chunk boundaries (BEL and ST terminated)', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`chunk${ESC}]133`) + tracker.handleChunk(';D;1') + expect(events).toEqual([]) + tracker.handleChunk(`30${BEL}rest`) + tracker.handleChunk(`${ESC}]133;D;7${ST}`) + + expect(events).toEqual([ + ['finished', 130], + ['finished', 7] + ]) + }) + + it('orders chunk facts titles → command-finished → bell', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}]0;zsh${BEL}${ESC}]133;D;0${BEL}done${BEL}`) + + expect(events).toEqual([['title', 'zsh'], ['finished', 0], ['bell']]) + }) +}) + +describe('createTerminalTitleTracker pr-link facts', () => { + it('emits one fact per PR URL including multiple links in one chunk', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk( + 'see https://github.com/acme/orca/pull/42 and https://github.com/acme/orca/pull/43 \r\n' + ) + + expect(events).toEqual([ + ['pr', 'https://github.com/acme/orca/pull/42', 42], + ['pr', 'https://github.com/acme/orca/pull/43', 43] + ]) + }) + + it('waits for a boundary when a URL splits across chunks and dedupes repeats', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk('PR: https://github.com/acme/orca/pull/4') + expect(events).toEqual([]) + tracker.handleChunk('2\r\n') + tracker.handleChunk('again https://github.com/acme/orca/pull/42\r\n') + + expect(events).toEqual([['pr', 'https://github.com/acme/orca/pull/42', 42]]) + }) + + it('skips the 133/URL scans entirely when no consumer is registered', () => { + // Mirrors headless serve: no pty:sideEffect consumer means no callbacks, + // so the scanners must not be created (no carry state, no scan cost). + const titles: string[] = [] + const tracker = createTerminalTitleTracker({ + onTitle: (normalized) => titles.push(normalized) + }) + + tracker.handleChunk(`${ESC}]133;D;0${BEL}https://github.com/acme/orca/pull/42\r\n`) + + expect(titles).toEqual([]) + }) +}) + +describe('createTerminalTitleTracker 2031-subscribe facts', () => { + it('emits a fact per chunk containing a DECSET 2031 subscribe, before the bell', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?2031hready${BEL}`) + + expect(events).toEqual([['2031-subscribe'], ['bell']]) + }) + + it('detects a subscribe split across chunk boundaries', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?20`) + tracker.handleChunk('31h') + + expect(events).toEqual([['2031-subscribe']]) + }) + + it('ignores DECSET 2031 unsubscribes', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?2031l`) + + expect(events).toEqual([]) + }) + + it('skips the 2031 scan entirely when no consumer is registered', () => { + const { events, tracker } = createRecordingTracker({ onMode2031Subscribe: undefined }) + + tracker.handleChunk(`${ESC}[?2031h`) + + expect(events).toEqual([]) + }) +}) + +describe('createTerminalTitleTracker synthetic-frame isolation', () => { + it('never feeds synthetic frames to the 133/PR scanners', () => { + const { events, tracker } = createRecordingTracker() + + tracker.applySyntheticTitleFrame( + `${ESC}]0;⠋ Cursor Agent${BEL}${ESC}]133;D;0${BEL}https://github.com/acme/orca/pull/42\r\n` + ) + + expect(events).toEqual([['title', '⠋ Cursor Agent']]) + }) + + it('keeps a split 133 carry intact across an interleaved synthetic frame', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`out${ESC}]133;D;`) + // An 80ms spinner tick lands between the two halves of the real OSC. + tracker.applySyntheticTitleFrame(`${ESC}]0;⠋ Cursor Agent${BEL}`) + tracker.handleChunk(`130${BEL}`) + + expect(events).toEqual([ + ['title', '⠋ Cursor Agent'], + ['finished', 130] + ]) + }) +}) + +describe('createTerminalTitleTracker transient-fact scanning suppression', () => { + it('skips bell/133/pr-link/2031 while suppressed but keeps title processing', () => { + const { events, tracker } = createRecordingTracker() + + tracker.setTransientFactScanningSuppressed(true) + tracker.handleChunk( + `${ESC}]0;zsh${BEL}${ESC}]133;D;0${BEL}ding${BEL}https://github.com/acme/orca/pull/42\r\n${ESC}[?2031h` + ) + + expect(events).toEqual([['title', 'zsh']]) + }) + + it('resets cross-chunk carry on un-suppress so a pre-gap half-open OSC cannot swallow bells', () => { + const { events, tracker } = createRecordingTracker() + + // The OSC terminator (and everything after) is lost in a delivery gap + // while scanning is suppressed. + tracker.handleChunk(`${ESC}]0;my long title without terminator`) + tracker.setTransientFactScanningSuppressed(true) + tracker.setTransientFactScanningSuppressed(false) + // Post-gap: a real standalone bell. A stale "inside OSC" state would + // swallow it. + tracker.handleChunk(`done${BEL}`) + + expect(events).toEqual([['bell']]) + }) + + it('a handoff scan seed re-primes a split sequence so its fact fires once, without a phantom bell', () => { + const { events, tracker } = createRecordingTracker() + + // The 133 intro was consumed before scan authority moved away; the carry + // is reset on un-suppress. The transport hands back the emulator's + // partial escape tail as a seed, so the continuation completes normally: + // one command-finished, and its terminator BEL is NOT a standalone bell. + tracker.handleChunk(`out${ESC}]133;D;`) + tracker.setTransientFactScanningSuppressed(true) + tracker.setTransientFactScanningSuppressed(false) + tracker.handleChunk(`${ESC}]133;D;`, { titleScanData: '' }) + tracker.handleChunk(`130${BEL}`) + + expect(events).toEqual([['finished', 130]]) + }) +}) diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts new file mode 100644 index 00000000000..02bf28efa80 --- /dev/null +++ b/src/shared/terminal-output-side-effects.ts @@ -0,0 +1,326 @@ +/** + * Shared per-PTY terminal title side-effect tracking — the parser core behind + * both the renderer transport (`createPtyOutputProcessor`) and main's + * per-PTY tracker in `OrcaRuntimeService.onPtyData`. + * + * Why shared: docs/reference/terminal-side-effect-authority.md makes main the + * side-effect parser for every PTY whose bytes transit local main. Title + * semantics (all-titles ordering, cursor-agent literal drop, normalization, + * stale-working-title clearing) must not drift between the two paths. + */ + +import { + clearWorkingIndicators, + createAgentStatusTracker, + detectAgentStatusFromTitle, + extractAllOscTitles, + isCursorNativeAgentTitle, + normalizeTerminalTitle +} from './agent-detection' +import { createBellDetector } from './terminal-bell-detector' +import { scanMode2031Sequences } from './terminal-color-scheme-protocol' +import { + createTerminalGitHubPRLinkDetector, + type TerminalGitHubPRLink +} from './terminal-github-pr-link-detector' +import { createOsc133CommandFinishedScanner } from './terminal-osc133-command-finished' + +/** Ms of title-less output after a working title before it is cleared. */ +export const STALE_WORKING_TITLE_TIMEOUT_MS = 3000 + +// Braille spinner frame glyphs (U+2800–U+28FF) — the decorative animation +// class agents rotate through while working. Mirrors the range +// clearWorkingIndicators strips in agent-detection.ts. +// eslint-disable-next-line no-control-regex -- intentional unicode range +const BRAILLE_SPINNER_RE = /[\u2800-\u28FF]/g + +/** + * Strip decorative braille spinner frame glyphs for change comparisons. + * Two working titles that differ only by the animation frame (e.g. + * "⠋ Cursor Agent" vs "⠙ Cursor Agent") compare equal after stripping — + * the gate consumers use to avoid fan-out churn on spinner ticks. + */ +export function stripBrailleSpinnerGlyphs(title: string): string { + return title.replace(BRAILLE_SPINNER_RE, '').trim() +} + +/** Provenance for title/idle facts. `staleWorkingTitleClear` marks facts + * synthesized by the 3s stale-working-title timer rather than observed + * bytes — consumers must not treat them as genuine task completions. */ +export type TerminalTitleFactMeta = { + staleWorkingTitleClear?: boolean +} + +export type TerminalTitleTrackerCallbacks = { + /** + * Fired once per observed OSC title, in byte order — including the + * synthesized cleared title when the stale-working timer fires. + */ + onTitle?: (normalizedTitle: string, rawTitle: string, meta?: TerminalTitleFactMeta) => void + onAgentBecameIdle?: (title: string, meta?: TerminalTitleFactMeta) => void + onAgentBecameWorking?: () => void + onAgentExited?: () => void + /** + * Fired once per chunk containing a real BEL (OSC-aware, escape state kept + * across chunks), after the chunk's title facts — the renderer drain order. + */ + onBell?: () => void + /** + * Fired per complete OSC 133;D (chunk-boundary-safe) with the sequence's + * best-effort exit code — mirrors the renderer terminal-command-lifecycle + * semantics so the fact path drops stale agent rows exactly like byte mode. + */ + onCommandFinished?: (bestEffortExitCode: number | null) => void + /** Fired once per newly observed GitHub PR URL (chunk-boundary-safe, + * deduplicated per tracker like the renderer detector). */ + onPrLink?: (link: TerminalGitHubPRLink) => void + /** + * Fired per chunk containing a DECSET 2031 subscribe (chunk-boundary-safe). + * Lets hidden-delivery-gated renderer views answer the color-scheme query + * without byte access; the reply itself stays with the view. + */ + onMode2031Subscribe?: () => void +} + +export type TerminalTitleTracker = { + /** Feed one raw PTY chunk; titles are applied synchronously in byte order. */ + handleChunk: (data: string, options?: { titleScanData?: string }) => void + /** + * Apply a main-fabricated OSC title/BEL frame (agent hook spinner frames). + * Parsed statelessly — never through the chunk bell detector — so a + * synthetic tick landing between two real chunks that split an OSC cannot + * corrupt the cross-chunk escape state into phantom or swallowed bells. + */ + applySyntheticTitleFrame: (frame: string) => void + /** + * Seed the last-known title for a tracker created mid-session (app relaunch + * with persisted/snapshot titles). No-ops once any title has been observed + * or seeded — live state always wins. Fires no callbacks. + */ + seedInitialTitle: (rawTitle: string) => void + /** Last title surfaced through onTitle, after normalization. */ + getLastNormalizedTitle: () => string | null + /** + * While suppressed, handleChunk skips the four transient-fact scanners + * (bell/133/pr-link/2031) — a thinning transport holds scan authority for + * this PTY and relays their facts itself; feeding the (possibly gapped) + * delivered bytes here would mint phantom or duplicate facts. Title + * processing is unaffected. Un-suppressing resets the scanners' cross-chunk + * carry: their last-fed byte predates the gap. + */ + setTransientFactScanningSuppressed: (suppressed: boolean) => void + /** Cancel the stale-title timer and clear accumulated tracker state. */ + dispose: () => void +} + +export function createTerminalTitleTracker( + callbacks: TerminalTitleTrackerCallbacks, + options: { initialTitle?: string } = {} +): TerminalTitleTracker { + const { + onTitle, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onBell, + onCommandFinished, + onPrLink, + onMode2031Subscribe + } = callbacks + const bellDetector = onBell ? createBellDetector() : null + // Why: created only when a consumer exists (like the bell detector) so + // headless serve never pays the per-chunk 133/URL scans. + const commandFinishedScanner = onCommandFinished + ? createOsc133CommandFinishedScanner(onCommandFinished) + : null + let prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null + let transientFactScanningSuppressed = false + // Why: a DECSET 2031 subscribe can be split across PTY chunks; carry a + // bounded tail between chunks so split sequences still match. + let mode2031ScanTail = '' + // Why: seed both the emitted-title memory (stale-title probe) and the agent + // tracker so a mid-session tracker behaves as if it had observed the pane's + // last live title — parity with the renderer processor's seeding. + let lastEmittedTitle: string | null = + options.initialTitle !== undefined ? normalizeTerminalTitle(options.initialTitle) : null + let staleTitleTimer: ReturnType | null = null + // Why: set while the stale timer's cleared title flows through the agent + // tracker so the resulting idle callback carries timer provenance — the + // renderer must not turn a stale clear into a task-complete notification. + let applyingStaleWorkingTitleClear = false + const agentTracker = + onAgentBecameIdle || onAgentBecameWorking || onAgentExited + ? createAgentStatusTracker( + (title) => { + onAgentBecameIdle?.( + title, + applyingStaleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + ) + }, + onAgentBecameWorking, + onAgentExited, + options.initialTitle + ) + : null + + function clearStaleTitleTimer(): void { + if (staleTitleTimer) { + clearTimeout(staleTitleTimer) + staleTitleTimer = null + } + } + + function applyObservedTitle(rawTitle: string): void { + // Why: cursor-agent re-emits its bare native title many times per turn + // while still working; letting it through would stomp Orca's synthesized + // "⠋ Cursor Agent" spinner state back to agentless within a second. + if (isCursorNativeAgentTitle(rawTitle)) { + return + } + lastEmittedTitle = normalizeTerminalTitle(rawTitle) + onTitle?.(lastEmittedTitle, rawTitle) + agentTracker?.handleTitle(rawTitle) + } + + function handleChunk(data: string, options: { titleScanData?: string } = {}): void { + const titleScanData = options.titleScanData ?? data + // Why: this is main's per-chunk hot path — scan for the OSC introducer + // once and share the result with the bell detector's fast-path gate. + const containsOscIntroducer = data.includes('\x1b]') + // Why: the bell detector must consume EVERY chunk so OSC sequences that + // span chunk boundaries keep their escape state, even when the chunk has + // no title. The fact itself is surfaced after the chunk's titles, the + // renderer drain's order (payloads → titles → bell). While suppressed it + // must consume NONE: the delivered bytes may be gapped. + const containsBell = + bellDetector && !transientFactScanningSuppressed + ? bellDetector.chunkContainsBell(data, { containsOscIntroducer }) + : false + // Why: feed EVERY OSC title in the chunk in byte order, never just the + // last one. node-pty plus the main-process batch window commonly coalesce + // multiple title updates into a single payload; a last-title reader drops + // intra-chunk working→idle transitions (issue #1083). + const titles = titleScanData.includes('\x1b]') ? extractAllOscTitles(titleScanData) : [] + if (titles.length > 0) { + clearStaleTitleTimer() + for (const title of titles) { + applyObservedTitle(title) + } + } else if ( + // Why: agents that exit without resetting their title leave a stale + // working spinner behind. Any title-less output while the last title + // classifies as working restarts a 3s timer that rewrites the title to + // its cleared form — the renderer transport's stale-title semantics. + data.length > 0 && + lastEmittedTitle !== null && + detectAgentStatusFromTitle(lastEmittedTitle) === 'working' + ) { + clearStaleTitleTimer() + staleTitleTimer = setTimeout(() => { + staleTitleTimer = null + if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { + const cleared = clearWorkingIndicators(lastEmittedTitle) + lastEmittedTitle = cleared + // Why: tag timer-synthesized facts. Main's timer is unthrottled + // (unlike the renderer timers that previously damped this path in + // hidden windows), so a merely-paused agent must be distinguishable + // from a genuine working→idle completion downstream. + applyingStaleWorkingTitleClear = true + try { + onTitle?.(cleared, cleared, { staleWorkingTitleClear: true }) + agentTracker?.handleTitle(cleared) + } finally { + applyingStaleWorkingTitleClear = false + } + } + }, STALE_WORKING_TITLE_TIMEOUT_MS) + } + // Per-chunk fact order: titles → command-finished → pr-link → + // 2031-subscribe → bell. The bell stays last (the renderer drain's + // order); the byte scanners keep their own cross-chunk carry so split + // sequences/URLs still resolve. + if (!transientFactScanningSuppressed) { + commandFinishedScanner?.scan(data) + if (prLinkDetector) { + for (const link of prLinkDetector(data)) { + onPrLink?.(link) + } + } + if (onMode2031Subscribe) { + const mode2031Scan = scanMode2031Sequences(mode2031ScanTail, data) + mode2031ScanTail = mode2031Scan.tail + if (mode2031Scan.subscribe) { + onMode2031Subscribe() + } + } + } + if (containsBell) { + onBell?.() + } + } + + function applySyntheticTitleFrame(frame: string): void { + // Why: synthetic frames have an exact main-fabricated shape, so they are + // parsed statelessly here. Feeding them through handleChunk would run the + // stateful bell detector: a tick landing while a REAL OSC is split across + // two chunks would consume the pending escape state, minting a phantom + // bell from the continuation chunk or swallowing a real one. + const titles = extractAllOscTitles(frame) + if (titles.length > 0) { + clearStaleTitleTimer() + for (const title of titles) { + applyObservedTitle(title) + } + } + // The deliberate permission BEL rides outside the OSC title sequence. A + // FRESH detector instance keeps the OSC-terminator-vs-bell semantics + // while guaranteeing zero interaction with the chunk detector's state. + // Synthetic frames never reach the 133/PR-link scanners: fabricated bytes + // contain neither and must not perturb their cross-chunk carry state. + if (onBell && createBellDetector().chunkContainsBell(frame)) { + onBell() + } + } + + return { + handleChunk, + applySyntheticTitleFrame, + seedInitialTitle(rawTitle: string): void { + // Why: the cursor-agent literal drop applies to seeds too — restoring + // the bare native title would stomp synthesized spinner state exactly + // like emitting it live would. + if (lastEmittedTitle !== null || !rawTitle || isCursorNativeAgentTitle(rawTitle)) { + return + } + lastEmittedTitle = normalizeTerminalTitle(rawTitle) + agentTracker?.seedTitle(rawTitle) + }, + getLastNormalizedTitle: () => lastEmittedTitle, + setTransientFactScanningSuppressed(suppressed: boolean): void { + if (suppressed === transientFactScanningSuppressed) { + return + } + transientFactScanningSuppressed = suppressed + if (!suppressed) { + // Cross-chunk carry predates the suppressed (gapped) span — a stale + // half-open OSC would swallow real bells; a stale 133/2031 tail or + // URL fragment would mint phantom facts. The PR-link dedup memory is + // lost with the recreate; a re-printed link may re-fire (consumers + // treat pr-link as a latest-association update). + bellDetector?.reset() + commandFinishedScanner?.reset() + mode2031ScanTail = '' + if (prLinkDetector) { + prLinkDetector = createTerminalGitHubPRLinkDetector() + } + } + }, + dispose(): void { + clearStaleTitleTimer() + agentTracker?.reset() + bellDetector?.reset() + commandFinishedScanner?.reset() + mode2031ScanTail = '' + } + } +} diff --git a/src/shared/terminal-reply-query-extraction.ts b/src/shared/terminal-reply-query-extraction.ts new file mode 100644 index 00000000000..0ff95b943aa --- /dev/null +++ b/src/shared/terminal-reply-query-extraction.ts @@ -0,0 +1,160 @@ +// Terminal-output scanning for reply-eliciting query sequences (DSR/CPR, +// DA1/DA2, DECRQM, XTGETTCAP-adjacent CSI queries, OSC 10/11 color probes). +// Shared because both sides must salvage queries out of bytes they are about +// to drop: the renderer's hidden-output restore queue and main's pending-cap +// bulk drop. A swallowed query means the program that sent it waits forever +// for a reply (the bench DSR timeout). +import { parseTerminalOscColorQuery } from './terminal-osc-color-reply' + +export const HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS = 64 + +export type ExtractedRendererQueryData = { + statelessQueryData: string + statefulQueryData: string + oscColorQueryData: string + pending: string +} + +export function extractHiddenStartupRendererQueryData( + data: string, + pending: string +): ExtractedRendererQueryData { + const input = pending + data + let statelessQueryData = '' + let statefulQueryData = '' + let oscColorQueryData = '' + let offset = 0 + + while (offset < input.length) { + const candidateIndex = input.indexOf('\x1b', offset) + if (candidateIndex === -1) { + break + } + if (candidateIndex + 1 >= input.length) { + return { + statelessQueryData, + statefulQueryData, + oscColorQueryData, + pending: input.slice(candidateIndex) + } + } + if (input.startsWith('\x1b[', candidateIndex)) { + const finalByteIndex = findCsiFinalByteIndex(input, candidateIndex + 2) + if (finalByteIndex === -1) { + return { + statelessQueryData, + statefulQueryData, + oscColorQueryData, + pending: input.slice( + candidateIndex, + candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS + ) + } + } + const sequence = input.slice(candidateIndex, finalByteIndex + 1) + if (isStatelessRendererReplyCsiQuery(sequence)) { + statelessQueryData += sequence + } else if (isStatefulRendererReplyCsiQuery(sequence)) { + statefulQueryData += sequence + } + offset = finalByteIndex + 1 + continue + } + + if (input.startsWith('\x1b]', candidateIndex)) { + const query = parseTerminalOscColorQuery(input, candidateIndex) + if (query.kind === 'partial') { + return { + statelessQueryData, + statefulQueryData, + oscColorQueryData, + pending: input.slice( + candidateIndex, + candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS + ) + } + } + if (query.kind === 'none') { + offset = candidateIndex + 2 + continue + } + oscColorQueryData += input.slice(candidateIndex, query.endIndex) + offset = query.endIndex + continue + } + + if (parseTerminalOscColorQuery(input, candidateIndex).kind === 'partial') { + return { + statelessQueryData, + statefulQueryData, + oscColorQueryData, + pending: input.slice(candidateIndex) + } + } + + { + offset = candidateIndex + 1 + continue + } + } + + return { statelessQueryData, statefulQueryData, oscColorQueryData, pending: '' } +} + +export function containsCsiRendererQuery(data: string): boolean { + let offset = data.indexOf('\x1b[') + while (offset !== -1) { + const finalByteIndex = findCsiFinalByteIndex(data, offset + 2) + if (finalByteIndex === -1) { + return false + } + const sequence = data.slice(offset, finalByteIndex + 1) + if (isStatelessRendererReplyCsiQuery(sequence) || isStatefulRendererReplyCsiQuery(sequence)) { + return true + } + offset = data.indexOf('\x1b[', finalByteIndex + 1) + } + return false +} + +export function containsStatefulRendererQuery(data: string): boolean { + let offset = data.indexOf('\x1b[') + while (offset !== -1) { + const finalByteIndex = findCsiFinalByteIndex(data, offset + 2) + if (finalByteIndex === -1) { + return false + } + const sequence = data.slice(offset, finalByteIndex + 1) + if (isStatefulRendererReplyCsiQuery(sequence)) { + return true + } + offset = data.indexOf('\x1b[', finalByteIndex + 1) + } + return false +} + +export function findCsiFinalByteIndex(data: string, offset: number): number { + for (let index = offset; index < data.length; index++) { + const code = data.charCodeAt(index) + if (code >= 0x40 && code <= 0x7e) { + return index + } + } + return -1 +} + +export function isStatelessRendererReplyCsiQuery(sequence: string): boolean { + if (sequence.endsWith('c')) { + return true + } + return ( + sequence === '\x1b[5n' || + sequence === '\x1b[>q' || + sequence === '\x1b[14t' || + sequence === '\x1b[16t' + ) +} + +export function isStatefulRendererReplyCsiQuery(sequence: string): boolean { + return sequence === '\x1b[6n' || (sequence.startsWith('\x1b[?') && sequence.endsWith('$p')) +} diff --git a/src/shared/terminal-restore-parity-fixture.ts b/src/shared/terminal-restore-parity-fixture.ts new file mode 100644 index 00000000000..5444b701ec2 --- /dev/null +++ b/src/shared/terminal-restore-parity-fixture.ts @@ -0,0 +1,284 @@ +// Renderer-parity headless terminal + snapshot/replay mirrors for the garble +// differential fuzz suites (headless-emulator-fidelity.fuzz.test.ts and +// hidden-reveal-reconciliation.fuzz.test.ts). Lives in src/shared because the +// main-side and renderer-side fuzz suites both consume it and neither +// tsconfig (tsconfig.node.json / tsconfig.tc.web.json) includes the other +// side's sources. +import { Terminal } from '@xterm/headless' +import { SerializeAddon } from '@xterm/addon-serialize' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { activateOrcaTerminalUnicodeProvider } from './terminal-unicode-provider' +import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' +import { + readSavedCursorRegister, + serializeWithAbsoluteCursor +} from './terminal-serialize-absolute-cursor' + +export type ParityTerminal = { + terminal: Terminal + serializeAddon: SerializeAddon +} + +/** Builds an @xterm/headless terminal configured exactly like the renderer + * pane where buffer state is concerned: scrollback + kitty vtExtensions from + * buildDefaultTerminalOptions (pane-terminal-options.ts), Unicode11Addon + * (pane-dom-creation.ts) and the Orca ZWJ provider (pane-lifecycle.ts). + * Font/cursor/render options are omitted — they never alter buffer cells. */ +export function createRendererParityTerminal(dims: { cols: number; rows: number }): ParityTerminal { + const terminal = new Terminal({ + cols: dims.cols, + rows: dims.rows, + scrollback: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, + allowProposedApi: true, + vtExtensions: { kittyKeyboard: true } + }) + const serializeAddon = new SerializeAddon() + terminal.loadAddon(serializeAddon) + terminal.loadAddon(new Unicode11Addon()) + activateOrcaTerminalUnicodeProvider(terminal) + return { terminal, serializeAddon } +} + +export function writeToTerminal(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +export async function writeChunksToTerminal(terminal: Terminal, chunks: string[]): Promise { + for (const chunk of chunks) { + await writeToTerminal(terminal, chunk) + } +} + +/** Bottom-anchored visible screen rows (baseY, not viewportY — scroll intent + * is enforced separately by the production restore path). */ +export function visibleRows(terminal: Terminal): string[] { + const buffer = terminal.buffer.active + const rows: string[] = [] + for (let y = 0; y < terminal.rows; y++) { + rows.push(buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? '') + } + return rows +} + +// xterm attribute color modes (Attributes CM_* in xterm's buffer model). +const COLOR_MODE_P16 = 16777216 +const COLOR_MODE_P256 = 33554432 + +/** Known-legitimate serializer normalization: SerializeAddon re-emits palette + * indices 0-15 written via 38;5;N / 48;5;N as classic SGR 30-37/90-97, so a + * restored cell reports CM_P16 where the live cell reported CM_P256. Both + * modes resolve through the same 16 theme slots — no visual difference. */ +function canonicalColorMode(mode: number, color: number): number { + return mode === COLOR_MODE_P256 && color >= 0 && color < 16 ? COLOR_MODE_P16 : mode +} + +/** Per-cell descriptor rows so SGR runs that shift cells are caught even + * when the text matches. Encodes only VISUALLY EFFECTIVE state: + * - glyph cells: char, width, fg, bg, all attribute flags; + * - blank cells (null cells and spaces render identically): width, bg, + * underline/strikethrough (drawn across blanks), and fg only when inverse + * swaps it into the cell background. SerializeAddon legitimately skips + * null cells with cursor motion, dropping their invisible fg/bold/italic + * state, and may materialize a skipped run as plain spaces — neither can + * be seen, so neither may fail the garble gate. + * Trailing default blanks are trimmed: the serializer does not re-emit + * pristine cells past the last written column. */ +export function visibleRowStyles(terminal: Terminal): string[] { + const buffer = terminal.buffer.active + const out: string[] = [] + for (let y = 0; y < terminal.rows; y++) { + const line = buffer.getLine(buffer.baseY + y) + const cells: string[] = [] + for (let x = 0; line && x < line.length; x++) { + const cell = line.getCell(x) + if (!cell) { + continue + } + const chars = cell.getChars() + const fgMode = canonicalColorMode(cell.getFgColorMode(), cell.getFgColor()) + const bgMode = canonicalColorMode(cell.getBgColorMode(), cell.getBgColor()) + if (chars === '' || chars === ' ') { + const blankFlags = [cell.isUnderline(), cell.isStrikethrough()] + .map((flag) => (flag ? '1' : '0')) + .join('') + const inverseFg = cell.isInverse() ? `·if${fgMode}:${cell.getFgColor()}` : '' + cells.push( + `▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${blankFlags}${inverseFg}` + ) + continue + } + const flags = [ + cell.isBold(), + cell.isDim(), + cell.isItalic(), + cell.isUnderline(), + cell.isInverse(), + cell.isStrikethrough() + ] + .map((flag) => (flag ? '1' : '0')) + .join('') + cells.push( + `${chars}·w${cell.getWidth()}·f${fgMode}:${cell.getFgColor()}·b${bgMode}:${cell.getBgColor()}·${flags}` + ) + } + const defaultBlank = `▯·w1·b0:-1·00` + while (cells.length > 0 && cells.at(-1) === defaultBlank) { + cells.pop() + } + out.push(cells.join('|')) + } + return out +} + +export function cursorPosition(terminal: Terminal): { x: number; y: number } { + return { x: terminal.buffer.active.cursorX, y: terminal.buffer.active.cursorY } +} + +/** KNOWN UPSTREAM BUG predicate (@xterm/addon-serialize 0.15.0-beta.287): + * null cells touching a soft-wrap boundary do not round-trip. Two confirmed + * variants (see the skipped repros in headless-emulator-fidelity.fuzz.test.ts): + * - V1 (cell loss): a wrapped continuation row starting with a NULL cell + * (only erasure creates those — typed spaces have chars ' ') passes the + * addon's wrap-validity ternary (SerializeAddon.ts ~L214 binds as + * `(chars && isDoubleWidth) ? ...`), so the blank is skipped with CUF — + * which clamps at the right margin instead of crossing the wrap boundary, + * overwriting the previous row's last cell and shifting the tail left. + * - V2 (filler artifact): a wrapped pair whose SOURCE row is entirely null + * takes the forced-wrap "magic" path, whose cleanup emits `ESC[0C`; CSI + * param 0 means 1, so the erase lands one cell right and the first filler + * '-' stays visible on the restored screen. + * The fuzz suites use this predicate to tolerate (and count) exactly these + * divergences without masking unknown ones. */ +export function bufferHasSerializeHostileWrappedRow(terminal: Terminal): boolean { + const buffer = terminal.buffer.active + for (let y = 1; y < buffer.length; y++) { + const line = buffer.getLine(y) + if (!line?.isWrapped) { + continue + } + if (line.getCell(0)?.getChars() === '') { + return true + } + const previous = buffer.getLine(y - 1) + let previousIsAllNull = previous !== undefined + for (let x = 0; previous && x < previous.length; x++) { + if (previous.getCell(x)?.getChars() !== '') { + previousIsAllNull = false + break + } + } + if (previousIsAllNull) { + return true + } + } + return false +} + +/** Full normal-buffer text with trailing blank rows trimmed (SerializeAddon + * restores content rows; both sides may differ only in trailing blanks). */ +export function normalBufferRowsTrimmed(terminal: Terminal): string[] { + const buffer = terminal.buffer.normal + const rows: string[] = [] + for (let y = 0; y < buffer.length; y++) { + rows.push(buffer.getLine(y)?.translateToString(true) ?? '') + } + while (rows.length > 0 && rows.at(-1) === '') { + rows.pop() + } + return rows +} + +// Mirror of applyMainBufferSnapshot's clear preamble (pty-connection.ts): +// normal-buffer restores wipe screen+scrollback+home; alt-screen restores +// clear only the alt screen so the normal buffer's scrollback survives. +export const SNAPSHOT_REPLAY_PREAMBLE_NORMAL = '\x1b[2J\x1b[3J\x1b[H' +export const SNAPSHOT_REPLAY_PREAMBLE_ALT = '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H' + +// Twin of POST_REPLAY_LIVE_SNAPSHOT_RESET (layout-serialization.ts) — the +// renderer suite pins equality against the real constant so drift fails fast. +export const POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY = '\x1b[0 q\x1b[?25h\x1b[?1004l' + +export type ParityMainSnapshot = { + data: string + scrollbackAnsi?: string + cols: number + rows: number + seq: number + alternateScreen: boolean + pendingDeliveryStartSeq?: number + /** Mirror of TerminalSnapshot.pendingEscapeTailAnsi: the trailing + * incomplete escape of the hidden byte stream. The restorer writes it + * LAST, after its post-replay resets (Bug E fix). */ + pendingEscapeTailAnsi?: string +} + +/** Mirror of the production main-buffer snapshot the renderer restore path + * consumes: HeadlessEmulator.getSnapshot (snapshotAnsi normalization + + * rehydrateSequences + absolute-cursor/DECSC epilogue) composed exactly like + * OrcaRuntime.serializeHeadlessTerminalBuffer (normal buffer separated from + * an active alt frame). The renderer fuzz cannot import + * HeadlessEmulator itself — tsconfig.tc.web.json excludes src/main/daemon. */ +export function buildParityMainBufferSnapshot( + parity: ParityTerminal, + seq: number, + opts: { + pendingDeliveryStartSeq?: number + scrollbackRows?: number + /** The hidden byte stream's trailing incomplete escape, exactly as the + * emulator's ingest tracker would have accumulated it. */ + pendingEscapeTail?: string + } = {} +): ParityMainSnapshot { + const { terminal } = parity + const alternateScreen = terminal.buffer.active.type === 'alternate' + const scrollback = opts.scrollbackRows ?? DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT + // Same composition as HeadlessEmulator.getSnapshot: absolute-cursor CUP for + // the wrap-pending relative-restore defect plus the DECSC register epilogue. + let snapshotAnsi = serializeWithAbsoluteCursor( + parity.serializeAddon, + terminal, + { scrollback }, + readSavedCursorRegister(terminal) + ) + let scrollbackAnsi: string | undefined + if (alternateScreen) { + // Why: HeadlessEmulator splits the normal buffer from the active alt frame; + // rehydrateSequences owns the transition between them. + const marker = '\x1b[?1049h' + const start = snapshotAnsi.lastIndexOf(marker) + if (start !== -1) { + scrollbackAnsi = snapshotAnsi.slice(0, start) + snapshotAnsi = snapshotAnsi.slice(start + marker.length) + } + } + const seqs: string[] = [] + if (alternateScreen) { + seqs.push('\x1b[0m\x1b[?1049h') + } + if (terminal.modes.bracketedPasteMode) { + seqs.push('\x1b[?2004h') + } + // Why normal-buffer-only: HeadlessEmulator.getModes reports + // applicationCursor false while the alternate buffer is active, so the + // production rehydrate omits ?1h for alt-screen snapshots. + if (!alternateScreen && terminal.modes.applicationCursorKeysMode) { + seqs.push('\x1b[?1h') + } + // Mouse-mode rehydrate omitted: TerminalMouseModeMirror is main-only and + // mouse reporting is input encoding — it cannot alter rendered output. + const snapshot: ParityMainSnapshot = { + data: seqs.join('') + snapshotAnsi, + cols: terminal.cols, + rows: terminal.rows, + seq, + alternateScreen, + ...(scrollbackAnsi !== undefined ? { scrollbackAnsi } : {}) + } + if (opts.pendingDeliveryStartSeq !== undefined) { + snapshot.pendingDeliveryStartSeq = opts.pendingDeliveryStartSeq + } + if (opts.pendingEscapeTail) { + snapshot.pendingEscapeTailAnsi = opts.pendingEscapeTail + } + return snapshot +} diff --git a/src/shared/terminal-scrollback-policy.test.ts b/src/shared/terminal-scrollback-policy.test.ts index 77bbec61c94..26187b11f77 100644 --- a/src/shared/terminal-scrollback-policy.test.ts +++ b/src/shared/terminal-scrollback-policy.test.ts @@ -6,7 +6,9 @@ import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN, legacyTerminalScrollbackBytesToRows, normalizeDesktopTerminalScrollbackRows, - normalizeDesktopTerminalSnapshotRows + normalizeDesktopTerminalSnapshotRows, + TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS, + terminalOutputBacklogCapChars } from './terminal-scrollback-policy' describe('terminal scrollback policy', () => { @@ -35,6 +37,18 @@ describe('terminal scrollback policy', () => { expect(normalizeDesktopTerminalSnapshotRows(100_000)).toBe(50_000) }) + it('scales the output backlog cap with scrollback rows above a 2 MB floor', () => { + // Default and small scrollbacks stay on the floor; large scrollbacks get + // proportionally more so the cap never drops lines scrollback would keep. + expect(terminalOutputBacklogCapChars(undefined)).toBe(TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS) + expect(terminalOutputBacklogCapChars(5_000)).toBe(TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS) + expect(terminalOutputBacklogCapChars('garbage')).toBe(TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS) + expect(terminalOutputBacklogCapChars(25_000)).toBe(3_000_000) + expect(terminalOutputBacklogCapChars(50_000)).toBe(6_000_000) + // Values beyond the settings max clamp like the setting itself does. + expect(terminalOutputBacklogCapChars(1_000_000)).toBe(6_000_000) + }) + it('migrates legacy decimal MB buckets by intent, not byte-to-row math', () => { expect(legacyTerminalScrollbackBytesToRows(undefined)).toBe(5_000) expect(legacyTerminalScrollbackBytesToRows(0)).toBe(5_000) diff --git a/src/shared/terminal-scrollback-policy.ts b/src/shared/terminal-scrollback-policy.ts index 22296036cc8..3c831234113 100644 --- a/src/shared/terminal-scrollback-policy.ts +++ b/src/shared/terminal-scrollback-policy.ts @@ -28,6 +28,23 @@ export function normalizeDesktopTerminalScrollbackRows(value: unknown): number { return clampRows(value, DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN) } +// Why the backlog cap scales with scrollback: pending-output caps exist to +// bound memory while a starved display catches up, but a user who raised +// scrollback to 50k rows can retain more history than the flat 2 MB floor — +// dropping at the floor would discard lines their scrollback would have kept. +// 120 chars/row ≈ 80-col text plus escape-sequence overhead; the cap is a +// memory bound, not an exact retention guarantee. +export const TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS = 2 * 1024 * 1024 +const OUTPUT_BACKLOG_CHARS_PER_SCROLLBACK_ROW = 120 + +export function terminalOutputBacklogCapChars(scrollbackRows: unknown): number { + const rows = normalizeDesktopTerminalScrollbackRows(scrollbackRows) + return Math.max( + TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS, + rows * OUTPUT_BACKLOG_CHARS_PER_SCROLLBACK_ROW + ) +} + export function normalizeDesktopTerminalSnapshotRows(value: unknown): number | undefined { if (!isFiniteNumber(value)) { return undefined diff --git a/src/shared/terminal-serialize-absolute-cursor.ts b/src/shared/terminal-serialize-absolute-cursor.ts new file mode 100644 index 00000000000..18902b27426 --- /dev/null +++ b/src/shared/terminal-serialize-absolute-cursor.ts @@ -0,0 +1,95 @@ +// Why this module exists: @xterm/addon-serialize restores the cursor with +// RELATIVE moves (CUD/CUB) computed from where it assumes replay leaves the +// cursor. When the final content row is filled exactly to the right margin, +// replay leaves the fresh terminal wrap-pending (internal x == cols), so the +// relative math lands one column short of the real cursor. Every Orca buffer +// snapshot that will be replayed into another terminal must therefore end +// with an absolute CUP derived from the SOURCE terminal's authoritative +// cursor position. Snapshot producers that also need the VT100 DECSC +// saved-cursor register carried across the restore compose it here too. + +type SerializeCursorTerminal = { + cols: number + rows: number + buffer: { active: { cursorX: number; cursorY: number } } +} + +type BufferSerializer = { + serialize: (opts?: TOpts) => string +} + +/** VT100 DECSC saved-cursor register (0-based, viewport-relative row). */ +export type SavedCursorRegister = { x: number; y: number } + +// xterm keeps the DECSC register on each Buffer (savedY is absolute: +// ybase-included). It is not exposed through the public API, so snapshot +// producers read the core buffer directly — `_core.buffer` is the ACTIVE +// buffer, so an alt-screen TUI yields the alternate screen's own register, +// matching the one a post-restore DECRC would consult. +type TerminalWithSavedCursorCore = SerializeCursorTerminal & { + _core?: { buffer?: { savedX?: number; savedY?: number; ybase?: number } } +} + +/** Reads the source terminal's active-buffer DECSC register, or null when it + * is unavailable or indistinguishable from the never-saved default. */ +export function readSavedCursorRegister( + terminal: SerializeCursorTerminal +): SavedCursorRegister | null { + const core = (terminal as TerminalWithSavedCursorCore)._core?.buffer + if ( + typeof core?.savedX !== 'number' || + typeof core.savedY !== 'number' || + typeof core.ybase !== 'number' + ) { + return null + } + // savedY is absolute; DECRC restores it relative to the ybase current at + // restore time, clamping at the top — mirror that clamp here. savedX can be + // cols (DECSC during wrap-pending); CUP cannot re-create pending, so clamp. + const y = Math.min(Math.max(core.savedY - core.ybase, 0), terminal.rows - 1) + const x = Math.min(Math.max(core.savedX, 0), terminal.cols - 1) + if (x === 0 && y === 0) { + // Home is xterm's never-saved default: a fresh restore terminal already + // sends DECRC to home, and skipping the injection avoids overwriting the + // fresh terminal's default saved SGR/charset when nothing was ever saved. + return null + } + return { x, y } +} + +export function serializeWithAbsoluteCursor( + serializer: BufferSerializer, + terminal: SerializeCursorTerminal, + opts?: TOpts, + savedCursor?: SavedCursorRegister | null +): string { + const serialized = serializer.serialize(opts) + // Why skip empty snapshots: several callers treat '' as "nothing to + // restore" (e.g. shutdown layout capture drops empty buffers); a bare CUP + // would turn every idle pane into a persisted snapshot. + if (serialized.length === 0) { + return serialized + } + const { cursorX, cursorY } = terminal.buffer.active + // Why skip wrap-pending sources (cursorX == cols): plain replay already + // reproduces that state exactly, while CUP would clamp to the last column + // and clear the pending-wrap flag, changing how the next byte renders. + // The remaining bounds checks are defensive: never emit a clamping CUP. + // The saved-cursor injection is skipped with it — it moves the cursor, so + // it may only ride along when the absolute CUP restores the position after. + if (cursorX < 0 || cursorX >= terminal.cols || cursorY < 0 || cursorY >= terminal.rows) { + return serialized + } + // Why the DECSC injection: the serialized screen cannot carry the VT100 + // saved-cursor register, so a hidden DECSC followed by a post-reveal DECRC + // restored to home and clobbered live cells (Bug D in + // notes/garble-fuzz-divergences.md). Re-establish the register by saving at + // the source's saved position, then CUP back to the real cursor. Saved SGR/ + // charset are not carried — the synthetic ESC 7 saves the serializer's + // final pen, a deliberate position-only fidelity trade. + const savedRestore = savedCursor ? `\x1b[${savedCursor.y + 1};${savedCursor.x + 1}H\x1b7` : '' + // cursorY is viewport-relative (0 at the buffer's base row), which is the + // same coordinate space CUP addresses after replay; scrollback length + // differences between source and destination do not shift it. + return `${serialized}${savedRestore}\x1b[${cursorY + 1};${cursorX + 1}H` +} diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts new file mode 100644 index 00000000000..1a546324006 --- /dev/null +++ b/src/shared/terminal-side-effect-facts.ts @@ -0,0 +1,56 @@ +/** + * Derived terminal side-effect facts carried on the `pty:sideEffect` channel + * (main → renderer). Events are facts, not decisions: main parses every + * local-daemon/SSH PTY byte exactly once and emits what it observed; the + * renderer store handler owns notification/unread policy. + * See docs/reference/terminal-side-effect-authority.md. + */ + +import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector' + +/** Why tagged: stale-clear facts come from main's unthrottled 3s timer, not + * observed bytes. Renderer policy clears title/cache state from them but + * must not schedule task-complete notifications or unread attention — a + * merely-paused agent (>3s silent mid-task) is not a completion. */ +export type TerminalSideEffectFact = + | { kind: 'title'; normalizedTitle: string; rawTitle: string; staleWorkingTitleClear?: boolean } + | { kind: 'bell' } + | { kind: 'agent-working' } + | { kind: 'agent-idle'; title: string; staleWorkingTitleClear?: boolean } + | { kind: 'agent-exited' } + /** OSC 133;D — foreground shell command exited (exit code best-effort). */ + | { kind: 'command-finished'; exitCode: number | null } + /** Carries the parsed link so the renderer store consumer never re-parses + * the URL (parse drift would break the per-PTY dedupe contract). */ + | { kind: 'pr-link'; link: TerminalGitHubPRLink } + /** Command Code output scrape (that CLI lacks hooks). Working seeds the + * agent-status row immediately; done is a hint the renderer settle-checks + * against its live status row before completing the turn. */ + | { kind: 'command-code-working'; prompt: string } + | { kind: 'command-code-done'; prompt: string } + /** DECSET 2031 color-scheme subscribe observed in the byte stream. Emitted + * so hidden-delivery-gated views (whose bytes never arrive) can still send + * the theme reply — the reply stays renderer-side because query authority + * belongs to the view (model/view contract invariant 6). */ + | { kind: '2031-subscribe' } + +export type TerminalSideEffectBatch = { + ptyId: string + /** PTY output byte sequence at emission. Replay batches carry the sequence + * their title state was current at, so the handler can drop a replay title + * older than the last live title fact it applied. */ + seq: number + /** Facts from one chunk, in byte order: titles in sequence, then bell. + * Command Code scrape facts trail the chunk's parser facts — their policy + * (status-row seeding) never interacts with title/bell ordering. */ + facts: TerminalSideEffectFact[] + /** True for (re)attach snapshots. Replay batches restore title state only — + * attention facts (bell, agent transitions) never replay. */ + replay?: boolean + /** Main-known attribution from runtime leaf/PTY records (same resolution as + * agent-status events). Absent when main has no binding for the PTY yet. */ + worktreeId?: string + tabId?: string + paneKey?: string + connectionId?: string | null +} diff --git a/src/shared/terminal-stream-protocol.test.ts b/src/shared/terminal-stream-protocol.test.ts index 58a8a0458a5..07de145031f 100644 --- a/src/shared/terminal-stream-protocol.test.ts +++ b/src/shared/terminal-stream-protocol.test.ts @@ -123,6 +123,21 @@ describe('terminal-stream-protocol', () => { expect(unsubscribe?.streamId).toBe(12) }) + it('round-trips output acknowledgement frames', () => { + const ack = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 12, + seq: 4, + payload: encodeTerminalStreamJson({ bytes: 4096 }) + }) + ) + + expect(ack?.opcode).toBe(TerminalStreamOpcode.Ack) + expect(ack?.streamId).toBe(12) + expect(ack && decodeTerminalStreamJson(ack.payload)).toEqual({ bytes: 4096 }) + }) + it('rejects unknown frame versions and opcodes', () => { const encoded = encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 2f8f4e04247..0011463babe 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -14,7 +14,10 @@ export enum TerminalStreamOpcode { Subscribe = 9, Unsubscribe = 10, SnapshotRequest = 11, - Metadata = 12 + Metadata = 12, + // Why 13: Metadata=12 shipped to mobile clients in v1.4.120; Ack (branch-only + // remote-multiplex flow control) renumbers to stay wire-compatible. + Ack = 13 } export type TerminalStreamFrame = { @@ -94,6 +97,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.Subscribe || value === TerminalStreamOpcode.Unsubscribe || value === TerminalStreamOpcode.SnapshotRequest || - value === TerminalStreamOpcode.Metadata + value === TerminalStreamOpcode.Metadata || + value === TerminalStreamOpcode.Ack ) } diff --git a/src/shared/terminal-view-attributes.test.ts b/src/shared/terminal-view-attributes.test.ts new file mode 100644 index 00000000000..17c77c08d09 --- /dev/null +++ b/src/shared/terminal-view-attributes.test.ts @@ -0,0 +1,119 @@ +/** + * View-attribute bridge (terminal-query-authority.md §View-attribute bridge): + * the XParseColor mirrors must match the bundled xterm grammar exactly — + * main's replies for hidden PTYs must be byte-identical to a visible + * renderer xterm's. + */ +import { describe, expect, it } from 'vitest' +import { + formatXColorRgbSpec, + parseXColorSpec, + terminalViewAttributesEqual, + validateTerminalViewAttributes, + type TerminalViewAttributes, + type TerminalViewRgb +} from './terminal-view-attributes' + +describe('parseXColorSpec', () => { + // Scaling fixtures mirror XParseColor.parseColor: h|hh|hhh|hhhh channels + // scale from their base (15/255/4095/65535) to 8 bit. + it.each([ + ['rgb:f/f/f', [255, 255, 255]], + ['rgb:0/8/f', [0, 136, 255]], + ['rgb:ff/00/80', [255, 0, 128]], + ['rgb:fff/000/888', [255, 0, 136]], + ['rgb:ffff/0000/8888', [255, 0, 136]], + ['RGB:FF/00/80', [255, 0, 128]], + ['#abc', [0xa0, 0xb0, 0xc0]], + ['#aabbcc', [0xaa, 0xbb, 0xcc]], + ['#aaabbbccc', [0xaa, 0xbb, 0xcc]], + ['#aaaabbbbcccc', [0xaa, 0xbb, 0xcc]] + ])('parses %s like xterm', (spec, expected) => { + expect(parseXColorSpec(spec)).toEqual(expected) + }) + + it.each([ + ['', 'empty'], + ['red', 'named colors (xterm rejects them too)'], + ['rgb:ff/ff', 'missing channel'], + ['rgb:ggg/000/000', 'non-hex'], + ['#abcd', 'hash length 4 is not a valid xparsecolor width'], + ['rgbi:1/1/1', 'rgbi is unsupported'] + ])('rejects %s — %s', (spec) => { + expect(parseXColorSpec(spec)).toBeNull() + }) +}) + +describe('formatXColorRgbSpec', () => { + it('reports 16-bit channels by doubling the 8-bit byte (toRgbString parity)', () => { + expect(formatXColorRgbSpec([0x1e, 0x1e, 0x2e])).toBe('rgb:1e1e/1e1e/2e2e') + expect(formatXColorRgbSpec([0, 8, 255])).toBe('rgb:0000/0808/ffff') + }) +}) + +describe('validateTerminalViewAttributes', () => { + const valid = (): TerminalViewAttributes => ({ + foreground: [1, 2, 3], + background: [4, 5, 6], + cursor: [7, 8, 9], + ansi: Array.from({ length: 256 }, (_, i) => [i % 256, 0, 0] as TerminalViewRgb), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: true + }) + + it('accepts and normalizes a well-formed payload', () => { + const attrs = validateTerminalViewAttributes(valid()) + expect(attrs).not.toBeNull() + expect(attrs?.ansi).toHaveLength(256) + expect(attrs?.colorSchemeMode).toBe('dark') + }) + + it.each([ + ['null payload', null], + ['missing foreground', { ...valid(), foreground: undefined }], + ['short triple', { ...valid(), background: [1, 2] }], + ['out-of-range channel', { ...valid(), cursor: [0, 0, 300] }], + ['non-integer channel', { ...valid(), cursor: [0, 0, 1.5] }], + ['short palette', { ...valid(), ansi: valid().ansi.slice(0, 16) }], + ['bad palette entry', { ...valid(), ansi: [...valid().ansi.slice(0, 255), 'red'] }], + ['bad mode', { ...valid(), colorSchemeMode: 'auto' }], + ['bad cursor style', { ...valid(), cursorStyle: 'beam' }], + ['non-boolean blink', { ...valid(), cursorBlink: 1 }] + ])('rejects %s', (_label, payload) => { + expect(validateTerminalViewAttributes(payload)).toBeNull() + }) +}) + +describe('terminalViewAttributesEqual', () => { + // The store's idempotence gate: a deep-equal snapshot from a fresh renderer + // process must compare equal so the re-push never fans out as a theme apply. + const snapshot = (): TerminalViewAttributes => ({ + foreground: [1, 2, 3], + background: [4, 5, 6], + cursor: [7, 8, 9], + ansi: Array.from({ length: 256 }, (_, i) => [i % 256, 0, 0] as TerminalViewRgb), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: true + }) + + it('treats two independently built identical snapshots as equal', () => { + expect(terminalViewAttributesEqual(snapshot(), snapshot())).toBe(true) + }) + + it.each([ + ['foreground', { ...snapshot(), foreground: [1, 2, 4] as TerminalViewRgb }], + ['background', { ...snapshot(), background: [0, 0, 0] as TerminalViewRgb }], + ['cursor', { ...snapshot(), cursor: [7, 8, 10] as TerminalViewRgb }], + [ + 'an ansi entry', + { ...snapshot(), ansi: snapshot().ansi.map((rgb, i) => (i === 200 ? [9, 9, 9] : rgb)) } + ], + ['colorSchemeMode', { ...snapshot(), colorSchemeMode: 'light' as const }], + ['cursorStyle', { ...snapshot(), cursorStyle: 'bar' as const }], + ['cursorBlink', { ...snapshot(), cursorBlink: false }] + ])('detects a change in %s', (_label, changed) => { + expect(terminalViewAttributesEqual(snapshot(), changed as TerminalViewAttributes)).toBe(false) + }) +}) diff --git a/src/shared/terminal-view-attributes.ts b/src/shared/terminal-view-attributes.ts new file mode 100644 index 00000000000..fa587a17b5c --- /dev/null +++ b/src/shared/terminal-view-attributes.ts @@ -0,0 +1,188 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): payload contract for the renderer→main `pty:terminalViewAttributes` + * push, plus main/renderer mirrors of xterm's XParseColor color-spec grammar + * so main's responder replies byte-identically to a visible renderer xterm. + */ + +/** 8-bit-per-channel RGB triple — the same resolution xterm's theme service + * stores internally (`color.toColorRGB`). */ +export type TerminalViewRgb = [number, number, number] + +export const TERMINAL_VIEW_ANSI_COLOR_COUNT = 256 + +export type TerminalViewCursorStyle = 'bar' | 'block' | 'underline' + +/** One app-global snapshot of the renderer's composed terminal appearance — + * per-pane font zoom never affects these, and terminalColorOverrides / + * cursor settings are global, so one push covers all PTYs. */ +export type TerminalViewAttributes = { + foreground: TerminalViewRgb + background: TerminalViewRgb + /** Already blended over the background (xterm ThemeService blends the + * cursor color's alpha at theme-set time, e.g. terminalCursorOpacity). */ + cursor: TerminalViewRgb + /** Full 256-entry palette: theme's 16 named colors + extendedAnsi/default + * tail, exactly as the renderer ThemeService resolves them. */ + ansi: TerminalViewRgb[] + /** Resolved APP color-scheme mode (the 2031/997 flip source). NOT the DSR + * ?996n answer: that is computed from background/foreground relative + * luminance like a visible xterm (_reportColorScheme), and the two can + * disagree (e.g. dark terminal theme in light app mode). */ + colorSchemeMode: 'dark' | 'light' + cursorStyle: TerminalViewCursorStyle + cursorBlink: boolean +} + +// Mirror of @xterm XParseColor RGB_REX: r/g/b channels in 1-4 hex digits. +const X_RGB_SPEC_RE = + /^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/ +const X_HASH_SPEC_RE = /^[\da-f]+$/ + +/** Mirror of xterm's XParseColor `parseColor` (the grammar the renderer + * accepts for OSC 4/10/11/12 SET payloads): `rgb:h/h/h`..`rgb:hhhh/hhhh/hhhh` + * and `#RGB|#RRGGBB|#RRRGGGBBB|#RRRRGGGGBBBB`. Anything else (named colors, + * rgbi:) is rejected exactly like the renderer rejects it. */ +export function parseXColorSpec(spec: string): TerminalViewRgb | null { + if (!spec) { + return null + } + let low = spec.toLowerCase() + if (low.startsWith('rgb:')) { + low = low.slice(4) + const m = X_RGB_SPEC_RE.exec(low) + if (m) { + const base = m[1] ? 15 : m[4] ? 255 : m[7] ? 4095 : 65535 + return [ + Math.round((Number.parseInt(m[1] || m[4] || m[7] || m[10], 16) / base) * 255), + Math.round((Number.parseInt(m[2] || m[5] || m[8] || m[11], 16) / base) * 255), + Math.round((Number.parseInt(m[3] || m[6] || m[9] || m[12], 16) / base) * 255) + ] + } + return null + } + if (low.startsWith('#')) { + low = low.slice(1) + if (X_HASH_SPEC_RE.exec(low) && [3, 6, 9, 12].includes(low.length)) { + const adv = low.length / 3 + const result: TerminalViewRgb = [0, 0, 0] + for (let i = 0; i < 3; ++i) { + const c = Number.parseInt(low.slice(adv * i, adv * i + adv), 16) + result[i] = adv === 1 ? c << 4 : adv === 2 ? c : adv === 3 ? c >> 4 : c >> 8 + } + return result + } + } + return null +} + +function padChannelTo16Bit(value: number): string { + const hex = value.toString(16) + const byte = hex.length < 2 ? `0${hex}` : hex + // Why doubled: xterm reports 16-bit channels by repeating the 8-bit byte + // (XParseColor.toRgbString with bits=16) — pinned reply-format parity. + return byte + byte +} + +/** Mirror of xterm's `toRgbString(color, 16)` — the exact channel format a + * visible renderer xterm uses in OSC 4/10/11/12 query replies. */ +export function formatXColorRgbSpec(rgb: TerminalViewRgb): string { + return `rgb:${padChannelTo16Bit(rgb[0])}/${padChannelTo16Bit(rgb[1])}/${padChannelTo16Bit(rgb[2])}` +} + +function rgbEqual(a: TerminalViewRgb, b: TerminalViewRgb): boolean { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] +} + +/** Value equality over the whole snapshot. Lets main's store treat a + * re-push of identical attributes (fresh renderer process: second window, + * reload, macOS re-activation) as a no-op instead of a theme apply. */ +export function terminalViewAttributesEqual( + a: TerminalViewAttributes, + b: TerminalViewAttributes +): boolean { + if (a === b) { + return true + } + if ( + !rgbEqual(a.foreground, b.foreground) || + !rgbEqual(a.background, b.background) || + !rgbEqual(a.cursor, b.cursor) || + a.colorSchemeMode !== b.colorSchemeMode || + a.cursorStyle !== b.cursorStyle || + a.cursorBlink !== b.cursorBlink || + a.ansi.length !== b.ansi.length + ) { + return false + } + for (let i = 0; i < a.ansi.length; i++) { + if (!rgbEqual(a.ansi[i], b.ansi[i])) { + return false + } + } + return true +} + +function isRgbChannel(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 255 +} + +function validateRgbTriple(value: unknown): TerminalViewRgb | null { + if (!Array.isArray(value) || value.length !== 3) { + return null + } + const [r, g, b] = value + if (!isRgbChannel(r) || !isRgbChannel(g) || !isRgbChannel(b)) { + return null + } + return [r, g, b] +} + +/** IPC-boundary validation for the `pty:terminalViewAttributes` push. Returns + * a normalized copy or null — main must never store a malformed palette (a + * wrong color reply is worse than silence, the OSC-11 lesson). */ +export function validateTerminalViewAttributes(payload: unknown): TerminalViewAttributes | null { + if (typeof payload !== 'object' || payload === null) { + return null + } + const candidate = payload as Record + const foreground = validateRgbTriple(candidate.foreground) + const background = validateRgbTriple(candidate.background) + const cursor = validateRgbTriple(candidate.cursor) + if (!foreground || !background || !cursor) { + return null + } + if (!Array.isArray(candidate.ansi) || candidate.ansi.length !== TERMINAL_VIEW_ANSI_COLOR_COUNT) { + return null + } + const ansi: TerminalViewRgb[] = [] + for (const entry of candidate.ansi) { + const triple = validateRgbTriple(entry) + if (!triple) { + return null + } + ansi.push(triple) + } + if (candidate.colorSchemeMode !== 'dark' && candidate.colorSchemeMode !== 'light') { + return null + } + if ( + candidate.cursorStyle !== 'bar' && + candidate.cursorStyle !== 'block' && + candidate.cursorStyle !== 'underline' + ) { + return null + } + if (typeof candidate.cursorBlink !== 'boolean') { + return null + } + return { + foreground, + background, + cursor, + ansi, + colorSchemeMode: candidate.colorSchemeMode, + cursorStyle: candidate.cursorStyle, + cursorBlink: candidate.cursorBlink + } +} diff --git a/src/shared/terminal-webgl-diagnostics.ts b/src/shared/terminal-webgl-diagnostics.ts new file mode 100644 index 00000000000..f63c34f1807 --- /dev/null +++ b/src/shared/terminal-webgl-diagnostics.ts @@ -0,0 +1,26 @@ +/** + * Lib-safe sink for WebGL renderer breadcrumbs (context loss/restore, atlas + * resets). The renderer breadcrumb ring lives in the components layer + * (terminal-freeze-breadcrumbs.ts), which may import from lib/pane-manager but + * not the reverse. This indirection lets lib-layer WebGL code record a crumb + * without a backward import: components registers the recorder at startup; + * until then (and in non-renderer contexts) recording is a silent no-op. + */ + +export type WebglDiagnosticRecorder = ( + kind: string, + detail?: Record +) => void + +let recorder: WebglDiagnosticRecorder | null = null + +export function setTerminalWebglDiagnosticRecorder(next: WebglDiagnosticRecorder | null): void { + recorder = next +} + +export function recordTerminalWebglDiagnostic( + kind: string, + detail?: Record +): void { + recorder?.(kind, detail) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 75f23a7c306..b9df77778c2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2722,6 +2722,28 @@ export type GlobalSettings = { * does not surface commands from other worktrees. Defaults to true. * Disable to revert to shared global shell history. */ terminalScopeHistoryByWorktree: boolean + /** Kill switch for hidden terminal view parking — unmounting long-hidden + * terminal panes while a pane-less watcher keeps PTY side effects alive. + * Defaults to true; `false` disables parking entirely. + * See docs/reference/terminal-hidden-view-parking.md. */ + terminalHiddenViewParking?: boolean + /** Kill switch for main-process terminal side-effect authority: when true + * (default), local-daemon/SSH PTY title/bell/agent facts are consumed from + * the `pty:sideEffect` channel and renderer byte parsers stay unregistered + * for those PTYs; `false` restores renderer byte parsing. + * See docs/reference/terminal-side-effect-authority.md. */ + terminalMainSideEffectAuthority?: boolean + /** Kill switch for main's hidden-delivery gate (Phase 4): when true + * (default) AND terminalMainSideEffectAuthority is on, main drops PTY byte + * delivery to hidden renderer views after model ingestion; reveal restores + * from the model snapshot. `false` restores hidden byte delivery. */ + terminalHiddenDeliveryGate?: boolean + /** Kill switch for the main model query responder (Phase 5): when true + * (default) AND both Phase-4 gate switches are on, main answers terminal + * queries (DA1/CPR/DECRPM, …) embedded in hidden-dropped chunks from the + * runtime emulator. `false` silences the responder without changing drops. + * See docs/reference/terminal-query-authority.md. */ + terminalModelQueryAuthority?: boolean /** Which agent to pre-select in the new-workspace composer. * - null: auto (first detected agent) * - 'blank': blank terminal (no agent launched) diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index 90fa83b8741..7e856c8dfae 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -1,8 +1,12 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' import { expect } from '@stablyai/playwright-test' import { randomUUID } from 'node:crypto' -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { rmSync } from 'node:fs' import path from 'node:path' +import { + type HiddenPressureOutputMode, + writePressureOutputScript +} from './artificial-opencode-hidden-pressure-script' import { ensureTerminalVisible, getActiveWorktreeId, @@ -47,13 +51,13 @@ type HiddenPressureDeps Promise releaseTerminalAckGate: (page: Page) => Promise resetTerminalPtyOutputDebug: (page: Page) => Promise - waitForMainPtyPressureBacklog: (page: Page) => Promise writeInteractivePromptScript: (scriptPath: string, runId: string) => void } +// Why: the renderer hidden-skip counters are gone with the skip grammar — +// withheld hidden output is observed via main's delivery-drop counters only. type HiddenPressureDebug = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number + hiddenRendererMode2031ReplyCount: number } type HiddenPressureMeasurement = { @@ -66,66 +70,45 @@ type HiddenPressureMainSnapshot = { peakPendingChars: number peakRendererInFlightChars: number ackGatedFlushSkipCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryGatedPtyCount: number +} + +type HiddenPressureSchedulerSnapshot = { + peakQueuedChars: number + droppedBacklogCount: number } type HiddenPressureAckGate = { heldAckChars: number } -// Why: this is a throughput/drain metric — the time to switch back and replay the -// full 8MB+ held backlog into xterm, measured through repeated (expensive) terminal -// serialization polls. The real responsiveness guards are the typing-latency -// asserts above (median/worst), which hold. Under 8MB of in-flight backpressure on -// a loaded OSS runner the drain-plus-poll overhead was seen at ~3.2s, so keep a -// ceiling with headroom that still catches an order-of-magnitude regression. -const MAX_HIDDEN_RESTORE_LATENCY_MS = 4_000 +// Why: restore still has to finish promptly, but parallel Electron workers on +// Linux CI can overshoot the 1s product target without a responsiveness regression. +// Main relaxed this to 4s for drain-plus-poll overhead on loaded OSS runners; +// this branch KEEPS the strict budget — the background keep-tail global budget +// bounds the aggregate a reveal drains, so a slow restore here is a regression. +const MAX_HIDDEN_RESTORE_LATENCY_MS = 1_500 +// Why: Phase-4 hidden-delivery gate contract — hidden PTY bytes are dropped in +// main after model ingestion, so renderer-delivery pressure must stay FAR +// below the old 2 MB ACK-backpressure target instead of reaching it. +const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 // Why: in this hidden real-PTY pressure case, maxTimerDriftMs and worst-key // latency catch the same isolated CI starvation spike; median remains strict. const MAX_HIDDEN_PRESSURE_TIMER_DRIFT_MS = 3_000 -export function pressureOutputScript(runId: string): string { - return ` -const paneIndex = process.argv[2] ?? '0' -const targetChars = Number(process.argv[3] ?? '0') -const delayMs = Number(process.argv[4] ?? '0') -const header = 'OPENCODE_PRESSURE_START_${runId}_' + paneIndex + '\\n' -const chunkBody = '#'.repeat(8192) -let written = 0 -process.stdout.write(header) -function writeMore() { - let canContinue = true - while (canContinue && written < targetChars) { - const frame = String(written).padStart(8, '0') - const chunk = '\\x1b[?2026h\\x1b[1;1Hpressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x1b[?2026l\\n' - written += chunk.length - canContinue = process.stdout.write(chunk) - } - if (written < targetChars) { - process.stdout.once('drain', writeMore) - return - } - process.stdout.write('OPENCODE_PRESSURE_DONE_${runId}_' + paneIndex + '\\n') -} -setTimeout(writeMore, Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0) -` -} - -export function writePressureOutputScript(scriptPath: string, runId: string): void { - mkdirSync(path.dirname(scriptPath), { recursive: true }) - writeFileSync(scriptPath, pressureOutputScript(runId)) -} - export async function runHiddenRealPtyPressureScenario< TMeasurement extends HiddenPressureMeasurement, TDebug extends HiddenPressureDebug, TMainPressure extends HiddenPressureMainSnapshot, TAckGate extends HiddenPressureAckGate, - TScheduler + TScheduler extends HiddenPressureSchedulerSnapshot >({ deps, annotationSuffix, hiddenPaneCount, pressureOutputChars, + pressureOutputMode = 'tui', pressureStartDelayMs, testInfo, testRepoPath, @@ -135,6 +118,7 @@ export async function runHiddenRealPtyPressureScenario< annotationSuffix?: string hiddenPaneCount: number pressureOutputChars: number + pressureOutputMode?: HiddenPressureOutputMode pressureStartDelayMs: number testInfo: TestInfo testRepoPath: string @@ -164,7 +148,7 @@ export async function runHiddenRealPtyPressureScenario< `.orca-opencode-hidden-pressure-load-${runId}.mjs` ) deps.writeInteractivePromptScript(typingScriptPath, runId) - writePressureOutputScript(pressureScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, pressureOutputMode) await deps.resetTerminalPtyOutputDebug(orcaPage) await deps.holdTerminalAckGate( @@ -182,7 +166,11 @@ export async function runHiddenRealPtyPressureScenario< await switchToTypingWorkspace(orcaPage, firstWorktreeId) const typingPtyId = await waitForActivePanePtyId(orcaPage) - const pressureBeforeTyping = await deps.waitForMainPtyPressureBacklog(orcaPage) + // Why: under the Phase-4 hidden-delivery gate the hidden panes' bytes are + // dropped in main after model ingestion, so renderer-delivery pressure + // never builds. Wait for the gate to drop at least one pane's worth of + // output instead of the old 2 MB ACK-backpressure target. + await waitForMainHiddenDeliveryDrops(orcaPage, deps, pressureOutputChars) const measurement = await deps.measureTypingDuringLoad( orcaPage, typingScriptPath, @@ -190,6 +178,7 @@ export async function runHiddenRealPtyPressureScenario< runId ) const debug = await deps.readTerminalPtyOutputDebug(orcaPage) + const scheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) const mainPressure = await deps.readMainPtyPressureDebug(orcaPage) const ackGate = await deps.readTerminalAckGateDebug(orcaPage) deps.annotateTypingMeasurement( @@ -198,19 +187,26 @@ export async function runHiddenRealPtyPressureScenario< hiddenPanes.length + 1, measurement, debug, - await deps.readTerminalOutputSchedulerDebug(orcaPage), + scheduler, mainPressure, ackGate ) - expect(debug?.hiddenRendererSkipCount ?? 0).toBe(0) - expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0) - expect(pressureBeforeTyping.peakPendingChars).toBeGreaterThan(0) - expect(pressureBeforeTyping.ackGatedFlushSkipCount).toBeGreaterThan(0) - expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual(8 * 1024 * 1024) - expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(0) - // Why: median is the robust responsiveness guard — it proves typing stays - // instant even while hidden PTYs replay 8MB+ of ACK-backpressured output. + // Hidden-delivery contract (all pressure modes): bytes never reach the + // renderer — main's drop counter is the withheld-output signal (the + // renderer skip counters were deleted with the skip grammar) — and main's + // renderer-delivery pressure must stay clearly below the old 2 MB + // backpressure target. + expect(mainPressure?.hiddenDeliveryDroppedChars ?? 0).toBeGreaterThanOrEqual( + pressureOutputChars + ) + expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeLessThan( + MAIN_RENDERER_PRESSURE_TARGET_CHARS + ) + // Why: the renderer scheduler queue must stay ~empty (no hidden bytes to + // queue) and must never drop a backlog — strict, per the gate contract. + expect(scheduler?.peakQueuedChars ?? 0).toBeLessThan(pressureOutputChars) + expect(scheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) expect(measurement.medianLatencyMs).toBeLessThan(75) // Why: worst *single-key echo* under 8MB synthetic backpressure lands behind // whichever flush it collides with, so on a contended OSS shard it is @@ -230,9 +226,11 @@ export async function runHiddenRealPtyPressureScenario< type: `opencode-hidden-real-pty-restore${annotationSuffix ?? ''}`, description: `panes=${hiddenPanes.length + 1} restore=${restoreLatencyMs.toFixed( 1 - )}ms hiddenSkippedChars=${debug?.hiddenRendererSkippedChars ?? 0} mainPeakInFlightChars=${ - mainPressure?.peakRendererInFlightChars ?? 0 - } heldAckChars=${ackGate?.heldAckChars ?? 0}` + )}ms hiddenDeliveryDroppedChars=${ + mainPressure?.hiddenDeliveryDroppedChars ?? 0 + } mainPeakInFlightChars=${mainPressure?.peakRendererInFlightChars ?? 0} heldAckChars=${ + ackGate?.heldAckChars ?? 0 + }` }) expect(restoreLatencyMs).toBeLessThan(MAX_HIDDEN_RESTORE_LATENCY_MS) } finally { @@ -248,6 +246,22 @@ export async function runHiddenRealPtyPressureScenario< } } +// Why: replaces the old waitForMainPtyPressureBacklog premise — the Phase-4 +// gate drops hidden bytes in main, so renderer-delivery pressure never builds; +// readiness is the gate reporting one pane's worth of dropped output. +async function waitForMainHiddenDeliveryDrops( + orcaPage: Page, + deps: { readMainPtyPressureDebug: (page: Page) => Promise }, + pressureOutputChars: number +): Promise { + await expect + .poll( + async () => (await deps.readMainPtyPressureDebug(orcaPage))?.hiddenDeliveryDroppedChars ?? 0, + { timeout: 30_000, message: 'Main hidden-delivery gate did not drop hidden PTY output' } + ) + .toBeGreaterThanOrEqual(pressureOutputChars) +} + async function measureHiddenOutputRestoreLatency( orcaPage: Page, worktreeId: string, diff --git a/tests/e2e/artificial-opencode-hidden-pressure-script.ts b/tests/e2e/artificial-opencode-hidden-pressure-script.ts new file mode 100644 index 00000000000..1eb94b3de89 --- /dev/null +++ b/tests/e2e/artificial-opencode-hidden-pressure-script.ts @@ -0,0 +1,52 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +export type HiddenPressureOutputMode = 'tui' | 'plain' | 'title' | 'latin' | 'rich-model' + +export function pressureOutputScript(runId: string, mode: HiddenPressureOutputMode): string { + const headerPrefix = mode === 'tui' || mode === 'rich-model' ? '\\x1b[0m' : '' + const donePrefix = mode === 'tui' || mode === 'rich-model' ? '\\x1b[0m' : '' + const chunkExpression = + mode === 'plain' + ? "'plain pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\n'" + : mode === 'latin' + ? "'latin pressure café déjà vu São Tomé Żubrówka pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\n'" + : mode === 'title' + ? "'\\x1b]0;title pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x07'" + : mode === 'rich-model' + ? "'\\x1b[?2026h\\x1b[?1049h\\x1b[2J\\x1b[H\\x1b[?25l\\x1b[2;36m╭────────────────────────────────────────╮\\x1b[0m\\r\\n\\x1b[2;36m│ rich model pane=' + paneIndex + ' frame=' + frame + ' 😀 ███░ │\\x1b[0m\\r\\n\\x1b[2;36m│ ' + chunkBody + ' │\\x1b[0m\\r\\n\\x1b[2;36m╰────────────────────────────────────────╯\\x1b[0m\\x1b[6;4H\\x1b[?25h\\x1b[?2026l\\n'" + : "'\\x1b[?2026h\\x1b[1;1Hpressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x1b[?2026l\\n'" + return ` +const paneIndex = process.argv[2] ?? '0' +const targetChars = Number(process.argv[3] ?? '0') +const delayMs = Number(process.argv[4] ?? '0') +const header = '${headerPrefix}OPENCODE_PRESSURE_START_${runId}_' + paneIndex + '\\n' +const chunkBody = '#'.repeat(8192) +let written = 0 +process.stdout.write(header) +function writeMore() { + let canContinue = true + while (canContinue && written < targetChars) { + const frame = String(written).padStart(8, '0') + const chunk = ${chunkExpression} + written += chunk.length + canContinue = process.stdout.write(chunk) + } + if (written < targetChars) { + process.stdout.once('drain', writeMore) + return + } + process.stdout.write('${donePrefix}OPENCODE_PRESSURE_DONE_${runId}_' + paneIndex + '\\n') +} +setTimeout(writeMore, Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0) +` +} + +export function writePressureOutputScript( + scriptPath: string, + runId: string, + mode: HiddenPressureOutputMode +): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync(scriptPath, pressureOutputScript(runId, mode)) +} diff --git a/tests/e2e/artificial-opencode-main-pressure-scenario.ts b/tests/e2e/artificial-opencode-main-pressure-scenario.ts index da2bd0f9a0f..1c13a5d26a0 100644 --- a/tests/e2e/artificial-opencode-main-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-main-pressure-scenario.ts @@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto' import { rmSync } from 'node:fs' import path from 'node:path' import { sendToTerminal } from './helpers/terminal' -import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-scenario' +import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-script' import { annotateScrollMeasurement, getResponsiveScrollPath, @@ -42,6 +42,7 @@ type MainPressureSchedulerSnapshot = { // Why: peak queued chars is noisy at the byte level on CI, but a coarse cap // still catches renderer queue growth that dropped-backlog/latency checks miss. const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 5 * 1024 * 1024 +const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 type MainPressureDeps< TMeasurement, @@ -124,7 +125,7 @@ export async function runMainPressureScenario< const pressureScriptPath = path.join(testRepoPath, `.orca-opencode-pressure-load-${runId}.mjs`) await seedActiveTerminalScrollback(orcaPage, typingPane.ptyId, scrollRunId) deps.writeInteractivePromptScript(typingScriptPath, runId) - writePressureOutputScript(pressureScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, 'tui') await deps.resetTerminalPtyOutputDebug(orcaPage) await deps.holdTerminalAckGate( orcaPage, @@ -272,7 +273,9 @@ function expectMainPressureAndTyping { + await page.evaluate(() => { + const store = window.__store + const state = store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const textarea = pane?.container.querySelector('.xterm-helper-textarea') + if (!pane || !textarea) { + throw new Error('Active terminal input is unavailable') + } + pane.terminal.focus() + textarea.focus() + }) +} + +export async function focusPane(page: Page, paneKey: string): Promise { + const separator = paneKey.indexOf(':') + const tabId = paneKey.slice(0, separator) + const leafId = paneKey.slice(separator + 1) + await page.evaluate( + ({ tabId, leafId }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getPanes?.().find((candidate) => candidate.leafId === leafId) + if (!manager || !pane) { + throw new Error(`Unable to focus pane ${tabId}:${leafId}`) + } + manager.setActivePane?.(pane.id, { focus: true }) + }, + { tabId, leafId } + ) +} + +export async function ensureActiveWorktreePaneLoad( + page: Page, + paneCount: number +): Promise { + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + let snapshot = await waitForPaneIdentitySnapshot(page, 1) + while (snapshot.panes.length < paneCount) { + await splitActiveTerminalPane(page, snapshot.panes.length % 2 === 0 ? 'horizontal' : 'vertical') + snapshot = await waitForPaneIdentitySnapshot(page, snapshot.panes.length + 1) + } + return snapshot.panes.slice(0, paneCount).map((pane) => ({ + paneKey: `${snapshot.tabId}:${pane.leafId}`, + ptyId: pane.ptyId ?? '' + })) +} + +export async function waitForMarkerLatency( + page: Page, + marker: string, + timeoutMs: number +): Promise { + const start = performance.now() + while (performance.now() - start < timeoutMs) { + if ((await getTerminalContent(page, 12_000)).includes(marker)) { + return performance.now() - start + } + await page.waitForTimeout(5) + } + throw new Error(`Timed out waiting for terminal marker ${marker}`) +} + +export async function getTerminalContentForPtyId( + page: Page, + ptyId: string, + charLimit = 12_000 +): Promise { + return page.evaluate( + ({ ptyId, charLimit }) => { + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of manager.getPanes?.() ?? []) { + if (pane.container?.dataset?.ptyId === ptyId) { + return (pane.serializeAddon?.serialize?.() ?? '').slice(-charLimit) + } + } + } + return '' + }, + { ptyId, charLimit } + ) +} + +export async function waitForTerminalOutputForPtyId( + page: Page, + ptyId: string, + expected: string, + timeoutMs: number +): Promise { + await expect + .poll(async () => (await getTerminalContentForPtyId(page, ptyId)).includes(expected), { + timeout: timeoutMs, + message: `Terminal PTY ${ptyId} did not contain "${expected}"` + }) + .toBe(true) +} diff --git a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts new file mode 100644 index 00000000000..c6a9d6e35a4 --- /dev/null +++ b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts @@ -0,0 +1,311 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { rmSync } from 'node:fs' +import path from 'node:path' +import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-script' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +type RevisitPressurePane = { paneKey: string; ptyId: string } + +type RevisitPressureMeasurement = { + medianLatencyMs: number + worstLatencyMs: number + maxTimerDriftMs: number +} + +// Why: the renderer hidden-skip counters were deleted with the skip grammar; +// only the mode-2031 fact-reply counter still exists renderer-side. +type RevisitPressureDebug = { hiddenRendererMode2031ReplyCount: number } + +type RevisitPressureSchedulerSnapshot = { + peakQueuedChars: number + droppedBacklogCount: number +} + +type RevisitPressureMainSnapshot = { + peakPendingChars: number + peakRendererInFlightChars: number + ackGatedFlushSkipCount: number +} + +type RevisitPressureAckGate = { heldAckChars: number } + +type RevisitPressureDeps< + TMeasurement extends RevisitPressureMeasurement, + TDebug extends RevisitPressureDebug, + TScheduler extends RevisitPressureSchedulerSnapshot, + TMainPressure extends RevisitPressureMainSnapshot, + TAckGate extends RevisitPressureAckGate +> = { + annotateTypingMeasurement: ( + testInfo: TestInfo, + type: string, + paneCount: number, + measurement: TMeasurement, + debug: TDebug | null, + scheduler: TScheduler | null, + mainPressure: TMainPressure | null, + ackGate: TAckGate | null + ) => void + ensureActiveWorktreePaneLoad: (page: Page, paneCount: number) => Promise + focusPane: (page: Page, paneKey: string) => Promise + holdTerminalAckGate: (page: Page, ptyIds: string[]) => Promise + measureTypingDuringLoad: ( + page: Page, + scriptPath: string, + ptyId: string, + runId: string + ) => Promise + readMainPtyPressureDebug: (page: Page) => Promise + readTerminalAckGateDebug: (page: Page) => Promise + readTerminalOutputSchedulerDebug: (page: Page) => Promise + readTerminalPtyOutputDebug: (page: Page) => Promise + releaseTerminalAckGate: (page: Page) => Promise + resetTerminalPtyOutputDebug: (page: Page) => Promise + waitForMainPtyPressureBacklog: (page: Page) => Promise + writeInteractivePromptScript: (scriptPath: string, runId: string) => void +} + +export async function runRendererBackpressureRevisitScenario< + TMeasurement extends RevisitPressureMeasurement, + TDebug extends RevisitPressureDebug, + TScheduler extends RevisitPressureSchedulerSnapshot, + TMainPressure extends RevisitPressureMainSnapshot, + TAckGate extends RevisitPressureAckGate +>({ + backgroundPaneCount, + deps, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + mainRendererPressureTargetChars, + pressureOutputChars, + orcaPage, + testInfo, + testRepoPath +}: { + backgroundPaneCount: number + deps: RevisitPressureDeps + maxMedianKeyLatencyMs: number + maxRendererSchedulerQueuedChars: number + maxTimerDriftMs: number + maxWorstKeyLatencyMs: number + mainRendererPressureTargetChars: number + pressureOutputChars: number + orcaPage: Page + testInfo: TestInfo + testRepoPath: string +}): Promise { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== firstWorktreeId) + expect(Boolean(secondWorktreeId), 'renderer backpressure revisit needs a second worktree').toBe( + true + ) + if (!secondWorktreeId) { + return + } + + const runId = randomUUID() + const typingPtyReadyMarker = `OPENCODE_REVISIT_TYPING_PTY_READY_${runId}` + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const typingPtyId = await waitForActivePanePtyId(orcaPage) + await sendToTerminal(orcaPage, typingPtyId, `printf '\\n${typingPtyReadyMarker}\\n'\r`) + await waitForMarkerLatency(orcaPage, typingPtyReadyMarker, 10_000) + + await switchToWorktree(orcaPage, firstWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const panes = await deps.ensureActiveWorktreePaneLoad(orcaPage, backgroundPaneCount + 1) + const [revisitPane, ...loadPanes] = panes + await deps.focusPane(orcaPage, revisitPane.paneKey) + + const typingScriptPath = path.join(testRepoPath, `.orca-revisit-typing-${runId}.mjs`) + const pressureScriptPath = path.join(testRepoPath, `.orca-revisit-pressure-${runId}.mjs`) + const revisitMarker = `OPENCODE_REVISIT_READY_${runId}` + const pressureDoneMarker = `OPENCODE_PRESSURE_DONE_${runId}_0` + deps.writeInteractivePromptScript(typingScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, 'tui') + await deps.resetTerminalPtyOutputDebug(orcaPage) + await deps.holdTerminalAckGate( + orcaPage, + loadPanes.map((pane) => pane.ptyId) + ) + try { + await startRealPtyPressureCommands({ + loadPanes, + orcaPage, + pressureOutputChars, + pressureScriptPath + }) + const pressureBeforeSwitch = await deps.waitForMainPtyPressureBacklog(orcaPage) + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const measurement = await deps.measureTypingDuringLoad( + orcaPage, + typingScriptPath, + typingPtyId, + runId + ) + const duringPressure = await deps.readMainPtyPressureDebug(orcaPage) + const ackGate = await deps.readTerminalAckGateDebug(orcaPage) + const scheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) + const hiddenDebug = await deps.readTerminalPtyOutputDebug(orcaPage) + deps.annotateTypingMeasurement( + testInfo, + 'opencode-main-pressure-worktree-revisit-typing', + panes.length + 1, + measurement, + hiddenDebug, + scheduler, + duringPressure, + ackGate + ) + + expectPressureStayedBounded({ + ackGate, + mainRendererPressureTargetChars, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + measurement, + pressureBeforeSwitch, + scheduler, + duringPressure + }) + + await switchToWorktree(orcaPage, firstWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await deps.focusPane(orcaPage, revisitPane.paneKey) + await sendToTerminal(orcaPage, revisitPane.ptyId, `printf '\\n${revisitMarker}\\n'\r`) + const revisitLatencyMs = await waitForMarkerLatency(orcaPage, revisitMarker, 10_000) + testInfo.annotations.push({ + type: 'opencode-main-pressure-worktree-revisit-marker', + description: `panes=${panes.length + 1} revisit=${revisitLatencyMs.toFixed( + 1 + )}ms heldAckChars=${ackGate?.heldAckChars ?? 0}` + }) + expect(revisitLatencyMs).toBeLessThan(maxWorstKeyLatencyMs) + + await deps.releaseTerminalAckGate(orcaPage) + await deps.focusPane(orcaPage, loadPanes[0]?.paneKey ?? revisitPane.paneKey) + const pressureDrainLatencyMs = await waitForMarkerLatency(orcaPage, pressureDoneMarker, 20_000) + const finalScheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) + testInfo.annotations.push({ + type: 'opencode-main-pressure-worktree-revisit-drain', + description: `panes=${panes.length + 1} drain=${pressureDrainLatencyMs.toFixed( + 1 + )}ms rendererPeakQueuedChars=${finalScheduler?.peakQueuedChars ?? 0} rendererDroppedBacklogs=${ + finalScheduler?.droppedBacklogCount ?? 0 + }` + }) + expect(finalScheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) + expect(finalScheduler?.peakQueuedChars ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual( + maxRendererSchedulerQueuedChars + ) + } finally { + await deps.releaseTerminalAckGate(orcaPage) + await sendToTerminal(orcaPage, typingPtyId, '\x03').catch(() => undefined) + await sendToTerminal(orcaPage, revisitPane.ptyId, '\x03').catch(() => undefined) + await Promise.all( + loadPanes.map((pane) => sendToTerminal(orcaPage, pane.ptyId, '\x03').catch(() => undefined)) + ) + rmSync(typingScriptPath, { force: true }) + rmSync(pressureScriptPath, { force: true }) + } +} + +async function startRealPtyPressureCommands({ + loadPanes, + orcaPage, + pressureOutputChars, + pressureScriptPath +}: { + loadPanes: RevisitPressurePane[] + orcaPage: Page + pressureOutputChars: number + pressureScriptPath: string +}): Promise { + await Promise.all( + loadPanes.map((pane, paneIndex) => + sendToTerminal( + orcaPage, + pane.ptyId, + `node ${JSON.stringify(pressureScriptPath)} ${paneIndex} ${pressureOutputChars}\r` + ) + ) + ) +} + +async function waitForMarkerLatency( + page: Page, + marker: string, + timeoutMs: number +): Promise { + const start = performance.now() + while (performance.now() - start < timeoutMs) { + if ((await getTerminalContent(page, 12_000)).includes(marker)) { + return performance.now() - start + } + await page.waitForTimeout(5) + } + throw new Error(`Timed out waiting for terminal marker ${marker}`) +} + +function expectPressureStayedBounded({ + ackGate, + mainRendererPressureTargetChars, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + measurement, + pressureBeforeSwitch, + scheduler, + duringPressure +}: { + ackGate: RevisitPressureAckGate | null + mainRendererPressureTargetChars: number + maxMedianKeyLatencyMs: number + maxRendererSchedulerQueuedChars: number + maxTimerDriftMs: number + maxWorstKeyLatencyMs: number + measurement: TMeasurement + pressureBeforeSwitch: RevisitPressureMainSnapshot + scheduler: RevisitPressureSchedulerSnapshot | null + duringPressure: RevisitPressureMainSnapshot | null +}): void { + expect(pressureBeforeSwitch.peakPendingChars).toBeGreaterThan(0) + expect(pressureBeforeSwitch.ackGatedFlushSkipCount).toBeGreaterThan(0) + expect(duringPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual( + mainRendererPressureTargetChars + ) + expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(0) + expect(scheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) + expect(scheduler?.peakQueuedChars ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual( + maxRendererSchedulerQueuedChars + ) + expect(measurement.medianLatencyMs).toBeLessThan(maxMedianKeyLatencyMs) + expect(measurement.worstLatencyMs).toBeLessThan(maxWorstKeyLatencyMs) + expect(measurement.maxTimerDriftMs).toBeLessThan(maxTimerDriftMs) +} diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index d1e8cbec3aa..99e31cd96db 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -12,22 +12,23 @@ import { waitForSessionReady } from './helpers/store' import { - getTerminalContent, sendToTerminal, - splitActiveTerminalPane, waitForActivePanePtyId, - waitForActiveTerminalManager, - waitForPaneIdentitySnapshot + waitForActiveTerminalManager } from './helpers/terminal' +import { + ensureActiveWorktreePaneLoad, + focusActiveTerminalInput, + focusPane, + waitForMarkerLatency, + waitForTerminalOutputForPtyId +} from './artificial-opencode-pane-interactions' import { runHiddenRealPtyPressureScenario } from './artificial-opencode-hidden-pressure-scenario' +import type { HiddenPressureOutputMode } from './artificial-opencode-hidden-pressure-script' import { runMainPressureScenario } from './artificial-opencode-main-pressure-scenario' +import { runRendererBackpressureRevisitScenario } from './artificial-opencode-revisit-pressure-scenario' import { startSyntheticOpenCodeInjection } from './artificial-opencode-synthetic-injection' -type TerminalLoadPane = { - paneKey: string - ptyId: string -} - type TypingMeasurement = { latencies: number[] medianLatencyMs: number @@ -55,9 +56,10 @@ type SyntheticOpenCodeWindow = Window & { } } +// Why: the renderer hidden-skip grammar is deleted — hidden bytes are dropped +// in main (gate) or ride the background queue. Only the mode-2031 fact-reply +// counter still has a renderer-side producer. type TerminalPtyOutputDebugSnapshot = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number hiddenRendererMode2031ReplyCount: number } @@ -98,6 +100,12 @@ type MainPtyPressureDebugSnapshot = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + // Phase-4 hidden-delivery gate: bytes dropped in main after model ingestion. + hiddenDeliveryGatedPtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number } const KEY_LATENCY_SAMPLES = 'abcdefghijklmnop' @@ -110,6 +118,7 @@ const HIDDEN_PRESSURE_START_DELAY_MS = 1200 const DEFAULT_FRAME_COUNT = 180 const DEFAULT_FRAME_INTERVAL_MS = 6 const TIMER_SAMPLE_MS = 16 +const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 // Why: these are regression budgets, not observed baselines. Repeated local // 100-pane OpenCode-scale runs are below 50ms worst-key latency; keep enough // CI headroom while still failing changes that make typing visibly sluggish. @@ -126,6 +135,7 @@ const MAX_WORST_KEY_LATENCY_UNDER_LOAD_MS = 3_000 // without visible typing lag. Keep this as a smoke gate, not a CPU lottery. const MAX_TIMER_DRIFT_MS = 250 const MAX_SCROLL_LATENCY_MS = 150 +const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 3 * 1024 * 1024 function readPositiveInt(name: string, fallback: number): number { const raw = process.env[name] @@ -212,111 +222,6 @@ function writeInteractivePromptScript(scriptPath: string, runId: string): void { writeFileSync(scriptPath, interactivePromptScript(runId)) } -async function focusActiveTerminalInput(page: Page): Promise { - await page.evaluate(() => { - const store = window.__store - const state = store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const textarea = pane?.container.querySelector('.xterm-helper-textarea') - if (!pane || !textarea) { - throw new Error('Active terminal input is unavailable') - } - pane.terminal.focus() - textarea.focus() - }) -} - -async function focusPane(page: Page, paneKey: string): Promise { - const separator = paneKey.indexOf(':') - const tabId = paneKey.slice(0, separator) - const leafId = paneKey.slice(separator + 1) - await page.evaluate( - ({ tabId, leafId }) => { - const manager = window.__paneManagers?.get(tabId) - const pane = manager?.getPanes?.().find((candidate) => candidate.leafId === leafId) - if (!manager || !pane) { - throw new Error(`Unable to focus pane ${tabId}:${leafId}`) - } - manager.setActivePane?.(pane.id, { focus: true }) - }, - { tabId, leafId } - ) -} - -async function ensureActiveWorktreePaneLoad( - page: Page, - paneCount: number -): Promise { - await ensureTerminalVisible(page) - await waitForActiveTerminalManager(page, 30_000) - let snapshot = await waitForPaneIdentitySnapshot(page, 1) - while (snapshot.panes.length < paneCount) { - await splitActiveTerminalPane(page, snapshot.panes.length % 2 === 0 ? 'horizontal' : 'vertical') - snapshot = await waitForPaneIdentitySnapshot(page, snapshot.panes.length + 1) - } - return snapshot.panes.slice(0, paneCount).map((pane) => ({ - paneKey: `${snapshot.tabId}:${pane.leafId}`, - ptyId: pane.ptyId ?? '' - })) -} - -async function waitForMarkerLatency( - page: Page, - marker: string, - timeoutMs: number -): Promise { - const start = performance.now() - while (performance.now() - start < timeoutMs) { - if ((await getTerminalContent(page, 12_000)).includes(marker)) { - return performance.now() - start - } - await page.waitForTimeout(5) - } - throw new Error(`Timed out waiting for terminal marker ${marker}`) -} - -async function getTerminalContentForPtyId( - page: Page, - ptyId: string, - charLimit = 12_000 -): Promise { - return page.evaluate( - ({ ptyId, charLimit }) => { - for (const manager of window.__paneManagers?.values() ?? []) { - for (const pane of manager.getPanes?.() ?? []) { - if (pane.container?.dataset?.ptyId === ptyId) { - return (pane.serializeAddon?.serialize?.() ?? '').slice(-charLimit) - } - } - } - return '' - }, - { ptyId, charLimit } - ) -} - -async function waitForTerminalOutputForPtyId( - page: Page, - ptyId: string, - expected: string, - timeoutMs: number -): Promise { - await expect - .poll(async () => (await getTerminalContentForPtyId(page, ptyId)).includes(expected), { - timeout: timeoutMs, - message: `Terminal PTY ${ptyId} did not contain "${expected}"` - }) - .toBe(true) -} - function median(values: number[]): number { const sorted = [...values].sort((a, b) => a - b) return sorted[Math.floor(sorted.length / 2)] ?? 0 @@ -429,7 +334,7 @@ async function waitForMainPtyPressureBacklog(page: Page): Promise { lastSnapshot = await readMainPtyPressureDebug(page) return ( - (lastSnapshot?.peakRendererInFlightChars ?? 0) >= 8 * 1024 * 1024 && + (lastSnapshot?.peakRendererInFlightChars ?? 0) >= MAIN_RENDERER_PRESSURE_TARGET_CHARS && (lastSnapshot?.peakPendingChars ?? 0) > 0 && (lastSnapshot?.ackGatedFlushSkipCount ?? 0) > 0 ) @@ -456,14 +361,12 @@ function annotateTypingMeasurement( mainPressure: MainPtyPressureDebugSnapshot | null = null, ackGate: TerminalPtyAckGateSnapshot | null = null ): void { - const hiddenSkipSummary = debug - ? ` hiddenSkips=${debug.hiddenRendererSkipCount} hiddenSkippedChars=${debug.hiddenRendererSkippedChars} mode2031Replies=${debug.hiddenRendererMode2031ReplyCount}` - : '' + const mode2031Summary = debug ? ` mode2031Replies=${debug.hiddenRendererMode2031ReplyCount}` : '' const schedulerSummary = scheduler ? ` deferredForegroundEnqueue=${scheduler.deferredForegroundEnqueueCount} deferredForegroundWrite=${scheduler.deferredForegroundWriteCount} scheduledDrains=${scheduler.scheduledDrainCount} rendererQueuedTerminals=${scheduler.queuedTerminalCount} rendererQueuedChars=${scheduler.queuedChars} rendererPeakQueuedTerminals=${scheduler.peakQueuedTerminalCount} rendererPeakQueuedChars=${scheduler.peakQueuedChars} rendererPeakQueuedCharsByTerminal=${scheduler.peakQueuedCharsByTerminal} rendererDroppedBacklogs=${scheduler.droppedBacklogCount}` : '' const mainPressureSummary = mainPressure - ? ` mainPendingPtys=${mainPressure.pendingPtyCount} mainPendingChars=${mainPressure.pendingChars} mainMaxPendingChars=${mainPressure.maxPendingCharsByPty} mainInFlightPtys=${mainPressure.rendererInFlightPtyCount} mainInFlightChars=${mainPressure.rendererInFlightChars} mainMaxInFlightChars=${mainPressure.maxRendererInFlightCharsByPty} mainActivePtys=${mainPressure.activeRendererPtyCount} mainFlushScheduled=${mainPressure.flushScheduled} mainPeakPendingChars=${mainPressure.peakPendingChars} mainPeakMaxPendingChars=${mainPressure.peakMaxPendingCharsByPty} mainPeakInFlightChars=${mainPressure.peakRendererInFlightChars} mainPeakMaxInFlightChars=${mainPressure.peakMaxRendererInFlightCharsByPty} mainAckGatedFlushSkips=${mainPressure.ackGatedFlushSkipCount}` + ? ` mainPendingPtys=${mainPressure.pendingPtyCount} mainPendingChars=${mainPressure.pendingChars} mainMaxPendingChars=${mainPressure.maxPendingCharsByPty} mainInFlightPtys=${mainPressure.rendererInFlightPtyCount} mainInFlightChars=${mainPressure.rendererInFlightChars} mainMaxInFlightChars=${mainPressure.maxRendererInFlightCharsByPty} mainActivePtys=${mainPressure.activeRendererPtyCount} mainFlushScheduled=${mainPressure.flushScheduled} mainPeakPendingChars=${mainPressure.peakPendingChars} mainPeakMaxPendingChars=${mainPressure.peakMaxPendingCharsByPty} mainPeakInFlightChars=${mainPressure.peakRendererInFlightChars} mainPeakMaxInFlightChars=${mainPressure.peakMaxRendererInFlightCharsByPty} mainAckGatedFlushSkips=${mainPressure.ackGatedFlushSkipCount} mainHiddenGatedPtys=${mainPressure.hiddenDeliveryGatedPtyCount} mainHiddenDroppedChars=${mainPressure.hiddenDeliveryDroppedChars} mainPendingDroppedChars=${mainPressure.pendingDroppedChars}` : '' const ackGateSummary = ackGate ? ` heldAckPtys=${ackGate.heldAckCount} heldAckChars=${ackGate.heldAckChars} gatedAckPtys=${ackGate.gatedPtyCount}` @@ -476,7 +379,7 @@ function annotateTypingMeasurement( 1 )}ms maxTimerDrift=${measurement.maxTimerDriftMs.toFixed(1)}ms samples=${measurement.latencies .map((value) => value.toFixed(1)) - .join(',')}${hiddenSkipSummary}${schedulerSummary}${mainPressureSummary}${ackGateSummary}` + .join(',')}${mode2031Summary}${schedulerSummary}${mainPressureSummary}${ackGateSummary}` }) } @@ -535,8 +438,7 @@ async function measureCrossWorkspaceTypingDuringHiddenLoad({ scheduler, mainPressure ) - expect(debug?.hiddenRendererSkipCount ?? 0).toBe(0) - expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0) + expect(scheduler?.rendererDroppedBacklogs ?? 0).toBe(0) expect(measurement.medianLatencyMs).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS) expect(measurement.worstLatencyMs).toBeLessThan(MAX_WORST_KEY_LATENCY_UNDER_LOAD_MS) expect(measurement.maxTimerDriftMs).toBeLessThan(MAX_TIMER_DRIFT_MS) @@ -571,26 +473,28 @@ async function runConfiguredMainPressureScenario({ maxScrollLatencyMs: MAX_SCROLL_LATENCY_MS, maxTimerDriftMs: MAX_TIMER_DRIFT_MS, maxWorstKeyLatencyMs: MAX_WORST_KEY_LATENCY_UNDER_LOAD_MS, - deps: { - annotateTypingMeasurement, - ensureActiveWorktreePaneLoad, - focusPane, - holdTerminalAckGate, - measureTypingDuringLoad, - readMainPtyPressureDebug, - readTerminalAckGateDebug, - readTerminalOutputSchedulerDebug, - readTerminalPtyOutputDebug, - releaseTerminalAckGate, - resetTerminalPtyOutputDebug, - waitForActiveWorktree, - waitForMainPtyPressureBacklog, - waitForSessionReady, - writeInteractivePromptScript - } + deps: terminalLoadScenarioDeps }) } +const terminalLoadScenarioDeps = { + annotateTypingMeasurement, + ensureActiveWorktreePaneLoad, + focusPane, + holdTerminalAckGate, + measureTypingDuringLoad, + readMainPtyPressureDebug, + readTerminalAckGateDebug, + readTerminalOutputSchedulerDebug, + readTerminalPtyOutputDebug, + releaseTerminalAckGate, + resetTerminalPtyOutputDebug, + waitForActiveWorktree, + waitForMainPtyPressureBacklog, + waitForSessionReady, + writeInteractivePromptScript +} + test.describe('Artificial OpenCode terminal load', () => { test.describe.configure({ mode: 'serial' }) @@ -690,6 +594,25 @@ test.describe('Artificial OpenCode terminal load', () => { }) }) + test('keeps renderer backpressure bounded across worktree revisit', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + await runRendererBackpressureRevisitScenario({ + backgroundPaneCount: PRESSURE_BACKGROUND_PANES, + deps: terminalLoadScenarioDeps, + mainRendererPressureTargetChars: MAIN_RENDERER_PRESSURE_TARGET_CHARS, + maxMedianKeyLatencyMs: MAX_MEDIAN_KEY_LATENCY_MS, + maxRendererSchedulerQueuedChars: MAX_RENDERER_SCHEDULER_QUEUED_CHARS, + maxTimerDriftMs: MAX_TIMER_DRIFT_MS, + maxWorstKeyLatencyMs: MAX_WORST_KEY_LATENCY_MS, + orcaPage, + pressureOutputChars: PRESSURE_OUTPUT_CHARS, + testInfo, + testRepoPath + }) + }) + for (const paneCount of SCALE_PRESSURE_PANES) { test(`keeps active interactions responsive at ${paneCount} ACK-backpressured OpenCode PTYs`, async ({ orcaPage, @@ -770,7 +693,8 @@ test.describe('Artificial OpenCode terminal load', () => { testRepoPath: string, testInfo: TestInfo, hiddenPaneCount: number, - annotationSuffix?: string + annotationSuffix?: string, + pressureOutputMode?: HiddenPressureOutputMode ): Promise { await runHiddenRealPtyPressureScenario({ orcaPage, @@ -778,35 +702,56 @@ test.describe('Artificial OpenCode terminal load', () => { annotationSuffix, hiddenPaneCount, pressureOutputChars: PRESSURE_OUTPUT_CHARS, + pressureOutputMode, + // Why: the 10s codex startup renderer-query window is deleted — every + // pressure mode measures steady-state model restore with one delay. pressureStartDelayMs: HIDDEN_PRESSURE_START_DELAY_MS, testInfo, - deps: { - annotateTypingMeasurement, - ensureActiveWorktreePaneLoad, - holdTerminalAckGate, - measureTypingDuringLoad, - readMainPtyPressureDebug, - readTerminalAckGateDebug, - readTerminalOutputSchedulerDebug, - readTerminalPtyOutputDebug, - releaseTerminalAckGate, - resetTerminalPtyOutputDebug, - waitForMainPtyPressureBacklog, - writeInteractivePromptScript - } + deps: terminalLoadScenarioDeps + }) + } + const hiddenPressureCases: { + title: string + suffix?: string + mode?: HiddenPressureOutputMode + }[] = [ + { title: 'keeps typing responsive while hidden real PTYs are ACK-backpressured' }, + // Why: "withholds renderer delivery" — hidden bytes are dropped in main by + // the delivery gate; the renderer no longer skip-scans chunks (Phase 6). + { + title: 'withholds renderer delivery for plain hidden PTY output while preserving restore', + suffix: '-plain', + mode: 'plain' + }, + { + title: 'withholds renderer delivery for Latin hidden PTY output while preserving restore', + suffix: '-latin', + mode: 'latin' + }, + { + title: + 'withholds renderer delivery for title-only hidden PTY output while preserving restore', + suffix: '-title', + mode: 'title' + }, + { + title: 'restores rich hidden model output under ACK-backpressured PTY output', + suffix: '-rich-model', + mode: 'rich-model' + } + ] + for (const hiddenPressureCase of hiddenPressureCases) { + test(hiddenPressureCase.title, async ({ orcaPage, testRepoPath }, testInfo) => { + await runConfiguredHiddenRealPtyPressureScenario( + orcaPage, + testRepoPath, + testInfo, + HIDDEN_PRESSURE_PANES, + hiddenPressureCase.suffix, + hiddenPressureCase.mode + ) }) } - test('keeps typing responsive while hidden real PTYs are ACK-backpressured', async ({ - orcaPage, - testRepoPath - }, testInfo) => { - await runConfiguredHiddenRealPtyPressureScenario( - orcaPage, - testRepoPath, - testInfo, - HIDDEN_PRESSURE_PANES - ) - }) for (const paneCount of SCALE_HIDDEN_PRESSURE_PANES) { test(`keeps hidden restore responsive with ${paneCount} ACK-backpressured real PTYs`, async ({ orcaPage, diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index da3e58cf3b5..6cddd6399ba 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -26,11 +26,11 @@ export default function globalSetup(): void { // ── 1. Build the Electron app ────────────────────────────────────── if (process.env.SKIP_BUILD && existsSync(outMain)) { - console.log('[e2e] SKIP_BUILD set and out/main/index.js exists — skipping build') + console.error('[e2e] SKIP_BUILD set and out/main/index.js exists — skipping build') } else { // Why: --mode e2e is the build-time signal that exposes window.__store; // the explicit env var keeps older local overrides working too. - console.log('[e2e] Building Electron app with electron-vite build --mode e2e...') + console.error('[e2e] Building Electron app with electron-vite build --mode e2e...') execSync('npx electron-vite build --mode e2e', { env: { ...process.env, VITE_EXPOSE_STORE: 'true' }, cwd: root, @@ -39,13 +39,13 @@ export default function globalSetup(): void { // when healthy; global setup should not fail before specs can run. timeout: ELECTRON_E2E_BUILD_TIMEOUT_MS }) - console.log('[e2e] Build complete.') + console.error('[e2e] Build complete.') } if (process.env.ORCA_E2E_SSH_LOCALHOST === '1' || process.env.ORCA_E2E_SSH_DOCKER === '1') { // Why: the SSH specs deploy Orca's relay from out/relay. The // normal Electron E2E build does not produce that bundle, so build it only // for explicit SSH runs. - console.log('[e2e] Building SSH relay bundle for SSH E2E...') + console.error('[e2e] Building SSH relay bundle for SSH E2E...') execSync('pnpm run build:relay', { cwd: root, stdio: 'inherit', @@ -90,9 +90,9 @@ export default function globalSetup(): void { cwd: testRepoDir, stdio: 'pipe' }) - console.log(`[e2e] Secondary worktree created at ${worktreeDir}`) + console.error(`[e2e] Secondary worktree created at ${worktreeDir}`) // Write the test repo path so the fixture can read it writeFileSync(TEST_REPO_PATH_FILE, testRepoDir) - console.log(`[e2e] Test repo created at ${testRepoDir}`) + console.error(`[e2e] Test repo created at ${testRepoDir}`) } diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 61d4a2315d7..abb797878af 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -32,7 +32,7 @@ export default function globalTeardown(): void { } rmSync(testRepoDir, { recursive: true, force: true }) - console.log(`[e2e] Cleaned up test repo at ${testRepoDir}`) + console.error(`[e2e] Cleaned up test repo at ${testRepoDir}`) } rmSync(TEST_REPO_PATH_FILE, { force: true }) diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index 0ed8ed57370..eb49dfeba5e 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -21,23 +21,14 @@ import { type ElectronApplication, type TestInfo } from '@stablyai/playwright-test' -import { - existsSync, - mkdtempSync, - mkdirSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync -} from 'node:fs' -import { execSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { TEST_REPO_PATH_FILE } from '../global-setup' import { cleanupE2EDaemons, closeElectronAppForE2E } from './electron-process-shutdown' import { getOrcaElectronLaunchArgs } from './electron-launch-args' import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile' +import { createSeededTestRepo, isValidGitRepo } from './seeded-test-repo' type OrcaTestFixtures = { electronApp: ElectronApplication @@ -51,6 +42,14 @@ type OrcaTestFixtures = { // Why: most E2E specs need a ready project before assertions start. Golden // first-run specs opt out so they can prove the zero-project onboarding path. seedTestRepo: boolean + // Why: spec-scoped launch env. Mutating process.env at spec module scope + // leaks into other specs when a worker reloads files without replaying the + // first spec's afterAll; per-test launch env cannot leak. + orcaAppExtraEnv: Record + // Why: spec-scoped Chromium switches (e.g. --enable-precise-memory-info for + // memory benchmarks). Prepended before the main entry so Electron forwards + // them to Chromium without affecting other specs' launches. + orcaAppExtraArgs: string[] // Why: a few IPC repro specs need to launch the Electron app with a scoped // PATH/token environment. Keep this fixture-owned so tests never mutate the // developer's shell or already-running Orca instance. @@ -122,62 +121,6 @@ function forwardElectronProcessLogs(app: ElectronApplication, testInfo: TestInfo }) } -function isValidGitRepo(repoPath: string): boolean { - if (!repoPath || !existsSync(repoPath)) { - return false - } - - try { - return ( - execSync('git rev-parse --is-inside-work-tree', { - cwd: repoPath, - stdio: 'pipe', - encoding: 'utf8' - }).trim() === 'true' - ) - } catch { - return false - } -} - -function createSeededTestRepo(): string { - // Why: realpathSync so the seeded path matches the store's repo.path on - // macOS, where os.tmpdir() (/var/...) symlinks to /private/var/... and the - // app canonicalizes repo.path via `git rev-parse --show-toplevel` on add. - const testRepoDir = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-repo-'))) - - execSync('git init', { cwd: testRepoDir, stdio: 'pipe' }) - execSync('git config user.email "e2e@test.local"', { cwd: testRepoDir, stdio: 'pipe' }) - execSync('git config user.name "E2E Test"', { cwd: testRepoDir, stdio: 'pipe' }) - - writeFileSync( - path.join(testRepoDir, 'README.md'), - '# Orca E2E Test Repo\n\nThis repo was created automatically for Playwright tests.\n' - ) - writeFileSync(path.join(testRepoDir, 'CLAUDE.md'), '# CLAUDE.md\n\nTest instructions for E2E.\n') - writeFileSync( - path.join(testRepoDir, 'package.json'), - `${JSON.stringify({ name: 'orca-e2e-test', version: '0.0.0', private: true }, null, 2)}\n` - ) - writeFileSync(path.join(testRepoDir, '.gitignore'), 'node_modules/\n') - mkdirSync(path.join(testRepoDir, 'src'), { recursive: true }) - writeFileSync(path.join(testRepoDir, 'src', 'index.ts'), 'export const hello = "world"\n') - - execSync('git add -A', { cwd: testRepoDir, stdio: 'pipe' }) - execSync('git commit -m "Initial commit for E2E tests"', { cwd: testRepoDir, stdio: 'pipe' }) - - // Why: worker-scoped fixture fallbacks can run in parallel; UUIDs avoid - // colliding on the same temp repo/worktree when workers start together. - const worktreeDir = path.join(testRepoDir, '..', `orca-e2e-worktree-${randomUUID()}`) - execSync(`git worktree add "${worktreeDir}" -b e2e-secondary`, { - cwd: testRepoDir, - stdio: 'pipe' - }) - - writeFileSync(TEST_REPO_PATH_FILE, testRepoDir) - return testRepoDir -} - /** * Extended Playwright test with Orca-specific fixtures. * @@ -203,7 +146,11 @@ export const test = base.extend({ ], // Test-scoped: one Electron app per test - electronApp: async ({ dismissOnboarding, launchEnv }, provideFixture, testInfo) => { + electronApp: async ( + { dismissOnboarding, launchEnv, orcaAppExtraEnv, orcaAppExtraArgs }, + provideFixture, + testInfo + ) => { const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js') const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-userdata-')) @@ -240,7 +187,7 @@ export const test = base.extend({ mkdirSync(recordVideoDir, { recursive: true }) } const app = await electron.launch({ - args: getOrcaElectronLaunchArgs(mainPath, headful), + args: [...orcaAppExtraArgs, ...getOrcaElectronLaunchArgs(mainPath, headful)], ...(slowMo > 0 ? { slowMo } : {}), ...(recordVideoDir ? { recordVideo: { dir: recordVideoDir } } : {}), // Why: keep NODE_ENV=development so window.__store is exposed and @@ -265,7 +212,8 @@ export const test = base.extend({ !cleanEnv.ORCA_RELAY_PATH ? { ORCA_RELAY_PATH: path.join(process.cwd(), 'out', 'relay') } : {}), - ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) + ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }), + ...orcaAppExtraEnv } }) forwardElectronProcessLogs(app, testInfo) @@ -281,6 +229,8 @@ export const test = base.extend({ dismissOnboarding: [true, { option: true }], seedTestRepo: [true, { option: true }], launchEnv: [{}, { option: true }], + orcaAppExtraEnv: [{}, { option: true }], + orcaAppExtraArgs: [[], { option: true }], // Test-scoped: grab the first BrowserWindow, add the test repo, and wait // until the session is fully ready with a worktree active. diff --git a/tests/e2e/helpers/seeded-test-repo.ts b/tests/e2e/helpers/seeded-test-repo.ts new file mode 100644 index 00000000000..3ae984193fd --- /dev/null +++ b/tests/e2e/helpers/seeded-test-repo.ts @@ -0,0 +1,67 @@ +/** + * Seeded git repo for Orca E2E fixtures: creation and validity checks for the + * disposable test repo (plus its secondary worktree) that specs operate on. + */ + +import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' +import { execSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import { TEST_REPO_PATH_FILE } from '../global-setup' + +export function isValidGitRepo(repoPath: string): boolean { + if (!repoPath || !existsSync(repoPath)) { + return false + } + + try { + return ( + execSync('git rev-parse --is-inside-work-tree', { + cwd: repoPath, + stdio: 'pipe', + encoding: 'utf8' + }).trim() === 'true' + ) + } catch { + return false + } +} + +export function createSeededTestRepo(): string { + // Why: realpathSync so the seeded path matches the store's repo.path on + // macOS, where os.tmpdir() (/var/...) symlinks to /private/var/... and the + // app canonicalizes repo.path via `git rev-parse --show-toplevel` on add. + const testRepoDir = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-repo-'))) + + execSync('git init', { cwd: testRepoDir, stdio: 'pipe' }) + execSync('git config user.email "e2e@test.local"', { cwd: testRepoDir, stdio: 'pipe' }) + execSync('git config user.name "E2E Test"', { cwd: testRepoDir, stdio: 'pipe' }) + + writeFileSync( + path.join(testRepoDir, 'README.md'), + '# Orca E2E Test Repo\n\nThis repo was created automatically for Playwright tests.\n' + ) + writeFileSync(path.join(testRepoDir, 'CLAUDE.md'), '# CLAUDE.md\n\nTest instructions for E2E.\n') + writeFileSync( + path.join(testRepoDir, 'package.json'), + `${JSON.stringify({ name: 'orca-e2e-test', version: '0.0.0', private: true }, null, 2)}\n` + ) + writeFileSync(path.join(testRepoDir, '.gitignore'), 'node_modules/\n') + mkdirSync(path.join(testRepoDir, 'src'), { recursive: true }) + writeFileSync(path.join(testRepoDir, 'src', 'index.ts'), 'export const hello = "world"\n') + + execSync('git add -A', { cwd: testRepoDir, stdio: 'pipe' }) + execSync('git commit -m "Initial commit for E2E tests"', { cwd: testRepoDir, stdio: 'pipe' }) + + // Why: worker-scoped fixture fallbacks can run in parallel; UUIDs avoid + // colliding on the same temp repo/worktree when workers start together. + const worktreeDir = path.join(testRepoDir, '..', `orca-e2e-worktree-${randomUUID()}`) + execSync(`git worktree add "${worktreeDir}" -b e2e-secondary`, { + cwd: testRepoDir, + stdio: 'pipe' + }) + + writeFileSync(TEST_REPO_PATH_FILE, testRepoDir) + return testRepoDir +} diff --git a/tests/e2e/helpers/terminal-input-probes.ts b/tests/e2e/helpers/terminal-input-probes.ts new file mode 100644 index 00000000000..82fdd2678c4 --- /dev/null +++ b/tests/e2e/helpers/terminal-input-probes.ts @@ -0,0 +1,290 @@ +/** + * Layer-discriminating input probes for frozen-terminal repro specs. + * + * The field failure (Discord #performance / GitHub #2836 family) is a pane + * that shows content while keystrokes silently vanish. Both drop layers are + * silent today: + * - renderer: transport.sendInput returns false when `!connected || !ptyId` + * (pty-transport.ts) + * - main: pty:write drops when `ptyOwnership` misses the id or the provider + * lookup fails (src/main/ipc/pty.ts writePtyInput) + * + * Direct `window.api.pty.write` bypasses the renderer transport, so: + * direct dead → MAIN-side drop (ownership/provider routing) + * direct alive, transport dead → RENDERER transport unbound + * The ownership-rebuild probe invokes pty:listSessions, which repopulates + * `ptyOwnership` as a side effect — input reviving after it is a smoking gun + * for the missing-ownership drop path. + * + * Two probe families: + * - Page-based (Playwright CDP): for specs whose renderer never crashes. + * - Main-process-based (webContents.executeJavaScript): for post-crash + * phases — a crashed target severs Playwright's CDP session even though + * the app recovers, so the harness must drive the renderer from main. + */ + +import { expect, type ElectronApplication, type Page } from '@stablyai/playwright-test' +import { sendToTerminal, waitForTerminalOutput } from './terminal' + +// ─── Page-based probes (healthy CDP session) ──────────────────────── + +export async function probeDirectWrite( + page: Page, + ptyId: string, + marker: string, + timeoutMs = 10_000 +): Promise { + // \x03\x15 = ETX+NAK (interrupt + kill-line) so a TUI or half-typed line on + // the shell doesn't swallow the probe — same trick discoverActivePtyId uses. + await sendToTerminal(page, ptyId, `\x03\x15echo ${marker}\r`) + try { + await waitForTerminalOutput(page, marker, timeoutMs) + return true + } catch { + return false + } +} + +/** Probe the full chain: focus the visible xterm and type through the keyboard. */ +export async function probeKeyboardType( + page: Page, + marker: string, + timeoutMs = 10_000 +): Promise { + await page.locator('.xterm:visible').first().click() + await page.keyboard.type(`echo ${marker}`, { delay: 20 }) + await page.keyboard.press('Enter') + try { + // Any appearance of the marker proves the roundtrip: xterm does not local- + // echo, so typed characters only render after the PTY echoes them back. + await waitForTerminalOutput(page, marker, timeoutMs) + return true + } catch { + return false + } +} + +export async function probeOwnershipRebuildRevival( + page: Page, + ptyId: string, + marker: string +): Promise { + await page.evaluate(async () => { + await window.api.pty.listSessions() + }) + return probeDirectWrite(page, ptyId, marker) +} + +export async function getStorePtyIds(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return [] + } + return Object.values(store.getState().ptyIdsByTabId).flat() + }) +} + +// ─── Main-process-based probes (post-renderer-crash) ──────────────── + +async function mainRendererEval( + electronApp: ElectronApplication, + expression: string +): Promise { + return electronApp.evaluate(async ({ BrowserWindow }, expr) => { + const win = BrowserWindow.getAllWindows()[0] + if (!win || win.isDestroyed() || win.webContents.isDestroyed()) { + throw new Error('no live window for executeJavaScript probe') + } + return (await win.webContents.executeJavaScript(expr, true)) as T + }, expression) as Promise +} + +export async function mainRendererStoreReady(electronApp: ElectronApplication): Promise { + try { + return await mainRendererEval( + electronApp, + `Boolean(window.__store && window.__store.getState().workspaceSessionReady === true)` + ) + } catch { + // executeJavaScript rejects while the document is loading or the window + // is mid-recovery; callers poll, so a false here is just "not yet". + return false + } +} + +export async function mainGetStorePtyIds(electronApp: ElectronApplication): Promise { + try { + return await mainRendererEval( + electronApp, + `(() => { + const store = window.__store + if (!store) { return [] } + return Object.values(store.getState().ptyIdsByTabId).flat() + })()` + ) + } catch { + return [] + } +} + +/** Serialize every mounted pane's buffer; marker search doesn't need per-pane precision. */ +export async function mainGetAllTerminalContent(electronApp: ElectronApplication): Promise { + try { + return await mainRendererEval( + electronApp, + `(() => { + const managers = window.__paneManagers + if (!managers) { return '' } + let combined = '' + for (const manager of managers.values()) { + for (const pane of manager.getPanes?.() ?? []) { + combined += '\\n' + (pane.serializeAddon?.serialize?.() ?? '') + } + } + return combined.slice(-8000) + })()` + ) + } catch { + return '' + } +} + +export async function mainWaitForPaneMounted( + electronApp: ElectronApplication, + timeoutMs = 30_000 +): Promise { + await expect + .poll( + async () => { + try { + return await mainRendererEval( + electronApp, + `(() => { + const managers = window.__paneManagers + if (!managers) { return 0 } + let count = 0 + for (const manager of managers.values()) { + count += (manager.getPanes?.() ?? []).length + } + return count + })()` + ) + } catch { + return 0 + } + }, + { timeout: timeoutMs, message: 'no terminal pane mounted after renderer recovery' } + ) + .toBeGreaterThan(0) +} + +async function mainWaitForMarker( + electronApp: ElectronApplication, + marker: string, + timeoutMs: number +): Promise { + try { + await expect + .poll(async () => (await mainGetAllTerminalContent(electronApp)).includes(marker), { + timeout: timeoutMs + }) + .toBe(true) + return true + } catch { + return false + } +} + +/** + * Full-chain probe without CDP: xterm's input() feeds terminal.onData → + * transport.sendInput → pty:write, the identical path keystrokes take past + * the DOM keyboard layer (which the pre-crash Playwright baseline covers). + * Why input() and not paste(): bracketed paste mode would wrap the payload + * and make the shell insert the control chars literally instead of executing. + */ +export async function mainProbeTransportPaste( + electronApp: ElectronApplication, + marker: string, + timeoutMs = 10_000 +): Promise { + try { + const fed = await mainRendererEval( + electronApp, + `(() => { + const managers = window.__paneManagers + if (!managers) { return false } + for (const manager of managers.values()) { + const pane = manager.getActivePane?.() ?? (manager.getPanes?.() ?? [])[0] + if (pane?.terminal?.input) { + pane.terminal.input('\\x03\\x15echo ${marker}\\r', true) + return true + } + } + return false + })()` + ) + if (!fed) { + return false + } + } catch { + return false + } + return mainWaitForMarker(electronApp, marker, timeoutMs) +} + +export async function mainProbeDirectWrite( + electronApp: ElectronApplication, + ptyId: string, + marker: string, + timeoutMs = 10_000 +): Promise { + try { + await mainRendererEval( + electronApp, + `window.api.pty.write(${JSON.stringify(ptyId)}, ${JSON.stringify(`\x03\x15echo ${marker}\r`)})` + ) + } catch { + return false + } + return mainWaitForMarker(electronApp, marker, timeoutMs) +} + +export async function mainProbeOwnershipRebuildRevival( + electronApp: ElectronApplication, + ptyId: string, + marker: string +): Promise { + try { + await mainRendererEval(electronApp, `window.api.pty.listSessions()`) + } catch { + return false + } + return mainProbeDirectWrite(electronApp, ptyId, marker) +} + +// ─── Failure report ───────────────────────────────────────────────── + +/** + * Assemble the failure report for a reproduced frozen pane. Kept in one place + * so every repro spec reports the same layer discrimination. + */ +export function buildFrozenPaneReport( + context: string, + probes: { + directAlive: boolean + transportAlive: boolean + revivedByOwnershipRebuild: boolean + ptyIds: string[] + terminalTail: string + } +): string { + return [ + `REPRODUCED frozen terminal (${context}):`, + ` direct pty:write probe alive: ${probes.directAlive} (false ⇒ MAIN-side drop: ptyOwnership/provider)`, + ` transport (onData→sendInput) probe alive: ${probes.transportAlive} (false with direct alive ⇒ RENDERER transport unbound)`, + ` revived by pty:listSessions ownership rebuild: ${probes.revivedByOwnershipRebuild}`, + ` pane ptyIds: ${JSON.stringify(probes.ptyIds)}`, + ` terminal tail:\n${probes.terminalTail.slice(-600)}` + ].join('\n') +} diff --git a/tests/e2e/renderer-crash-recovery-terminal-input.spec.ts b/tests/e2e/renderer-crash-recovery-terminal-input.spec.ts new file mode 100644 index 00000000000..621842e54e8 --- /dev/null +++ b/tests/e2e/renderer-crash-recovery-terminal-input.spec.ts @@ -0,0 +1,172 @@ +/** + * Repro spec for the frozen-terminal-after-renderer-recovery report + * (Discord #performance, GitHub #2836 family). + * + * Field evidence: pane shows content, shell is alive, daemon output.log does + * not grow while typing — i.e. keystrokes silently vanish somewhere between + * xterm and the PTY. This spec forces the suspected trigger — renderer + * process death followed by the automatic recovery reload + * (createMainWindow.ts scheduleRendererRecovery) — then discriminates which + * layer drops input via the probes in helpers/terminal-input-probes.ts. + * + * Post-crash phases are driven from the MAIN process: a crashed target + * severs Playwright's CDP session even though the app recovers, so page.* + * calls can never observe the recovered renderer (verified empirically — + * main saw did-finish-load while every window handle stayed dead). + */ + +import type { ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + discoverActivePtyId, + waitForActiveTerminalManager, + waitForPaneCount +} from './helpers/terminal' +import { + buildFrozenPaneReport, + mainGetAllTerminalContent, + mainGetStorePtyIds, + mainProbeDirectWrite, + mainProbeOwnershipRebuildRevival, + mainProbeTransportPaste, + mainRendererStoreReady, + mainWaitForPaneMounted, + probeKeyboardType +} from './helpers/terminal-input-probes' + +// Why 2, not more: the renderer recovery circuit breaker allows 3 recoveries +// per 60s window (DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES); a third forced +// crash risks tripping it and testing the breaker instead of the reattach. +const CRASH_CYCLES = 2 + +// Recovery = 250ms reload timer + full document reload + session hydration. +const RECOVERY_TIMEOUT_MS = 60_000 + +type CrashProbe = { + processGone: { reason: string; exitCode: number } | null + recoveredLoads: number +} + +declare global { + // eslint-disable-next-line no-var -- main-process global probe for this spec + var __crashProbe: CrashProbe | undefined +} + +/** + * Observe the crash/recovery lifecycle from the MAIN process. Re-arming + * replaces the probe object, so counts reset per cycle; stale listeners from + * a previous arm write only to their own superseded probe object. + */ +async function armMainProcessCrashProbe(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(({ BrowserWindow }) => { + const probe: CrashProbe = { processGone: null, recoveredLoads: 0 } + globalThis.__crashProbe = probe + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.on('render-process-gone', (_event, details) => { + probe.processGone = { reason: details.reason, exitCode: details.exitCode ?? -1 } + }) + win.webContents.on('did-finish-load', () => { + probe.recoveredLoads += 1 + }) + } + }) +} + +async function readMainProcessCrashProbe(electronApp: ElectronApplication): Promise { + return electronApp.evaluate( + () => globalThis.__crashProbe ?? { processGone: null, recoveredLoads: 0 } + ) +} + +async function waitForRendererRecovery(electronApp: ElectronApplication): Promise { + await expect + .poll(async () => (await readMainProcessCrashProbe(electronApp)).recoveredLoads, { + timeout: RECOVERY_TIMEOUT_MS, + message: + 'Main process never observed a recovery reload (did-finish-load) after the forced crash — scheduleRendererRecovery did not fire' + }) + .toBeGreaterThan(0) + await expect + .poll(async () => mainRendererStoreReady(electronApp), { + timeout: RECOVERY_TIMEOUT_MS, + message: 'Recovered renderer never reached workspaceSessionReady' + }) + .toBe(true) + await mainWaitForPaneMounted(electronApp) +} + +test.describe('Renderer crash recovery keeps terminal input alive', () => { + test('typing still reaches the PTY after forced renderer crash + auto-reload', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(300_000) + + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 1, 30_000) + + // Baseline: both the DOM keyboard layer (Playwright-driven, only possible + // pre-crash) and the PTY roundtrip must work before we crash anything, + // otherwise a post-crash failure would be uninterpretable. + const baselinePtyId = await discoverActivePtyId(orcaPage) + expect( + await probeKeyboardType(orcaPage, 'KB_BASELINE_OK'), + 'baseline keyboard input must reach the PTY before any crash is forced' + ).toBe(true) + + for (let cycle = 0; cycle < CRASH_CYCLES; cycle++) { + await armMainProcessCrashProbe(electronApp) + await electronApp.evaluate(({ BrowserWindow }) => { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.forcefullyCrashRenderer() + } + }) + + await expect + .poll(async () => (await readMainProcessCrashProbe(electronApp)).processGone?.reason, { + timeout: 15_000, + message: 'forcefullyCrashRenderer never produced a render-process-gone event' + }) + .toBeTruthy() + + await waitForRendererRecovery(electronApp) + + // Daemon-backed sessions survive renderer death by design, so the pane + // should reattach to the same session rather than spawn a fresh shell. + const postPtyIds = await mainGetStorePtyIds(electronApp) + + const transportAlive = await mainProbeTransportPaste(electronApp, `PASTE_POST_${cycle}_OK`) + const directAlive = + postPtyIds.length > 0 && + (await mainProbeDirectWrite(electronApp, postPtyIds[0], `DIRECT_POST_${cycle}_OK`)) + + if (!transportAlive || !directAlive) { + const revived = + postPtyIds.length > 0 && + (await mainProbeOwnershipRebuildRevival( + electronApp, + postPtyIds[0], + `REVIVED_POST_${cycle}_OK` + )) + const crashReason = + (await readMainProcessCrashProbe(electronApp)).processGone?.reason ?? 'unknown' + throw new Error( + buildFrozenPaneReport( + `crash cycle ${cycle}, baseline ptyId ${baselinePtyId}, crash reason ${crashReason}`, + { + directAlive, + transportAlive, + revivedByOwnershipRebuild: revived, + ptyIds: postPtyIds, + terminalTail: await mainGetAllTerminalContent(electronApp) + } + ) + ) + } + } + }) +}) diff --git a/tests/e2e/restart-restore-terminal-input.spec.ts b/tests/e2e/restart-restore-terminal-input.spec.ts new file mode 100644 index 00000000000..2ded33f0c0a --- /dev/null +++ b/tests/e2e/restart-restore-terminal-input.spec.ts @@ -0,0 +1,259 @@ +/** + * Repro spec for the "starts frozen right after an update" report + * (Discord #performance, GitHub #2836 family). + * + * Field evidence: after an app update + relaunch the terminal pane shows + * restored content but typing produces nothing — daemon output.log never + * grows. Restore paints the persisted buffer synchronously BEFORE the + * deferred PTY reattach runs (use-terminal-pane-lifecycle.ts → + * pty-connection.ts), and every reattach-failure branch swallows into null, + * so a failed/stalled attach leaves a live-looking, input-dead pane. + * + * Existing coverage (daemon-live-session-preservation.spec.ts, + * terminal-restart-persistence.spec.ts) asserts restored CONTENT after + * relaunch, but never that INPUT still works. These tests close that gap for + * three real relaunch shapes: + * 1. clean restart with a live daemon session (the update model) + * 2. daemon wedged (SIGSTOP) across the relaunch — the attach stalls while + * restore has already painted; input must recover once the daemon does + * 3. daemon killed between launches — cold-restore + fresh spawn must yield + * a typeable pane + */ + +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { + discoverActivePtyId, + execInTerminal, + getTerminalContent, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { + buildFrozenPaneReport, + getStorePtyIds, + probeDirectWrite, + probeKeyboardType, + probeOwnershipRebuildRevival +} from './helpers/terminal-input-probes' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' + +function readDaemonPid(userDataDir: string): number { + const raw = readFileSync( + path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`), + 'utf8' + ) + const parsed = JSON.parse(raw) as { pid?: unknown } + if (typeof parsed.pid !== 'number') { + throw new Error(`Daemon pid file did not contain a numeric pid: ${raw}`) + } + return parsed.pid +} + +function seededRepoPathOrSkip(): string { + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + : '' + test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo') + return repoPath +} + +async function bootstrapFirstLaunch( + page: Page, + repoPath: string +): Promise<{ ptyId: string; marker: string }> { + await attachRepoAndOpenTerminal(page, repoPath) + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) + const ptyId = await discoverActivePtyId(page) + // Why: the separator only appears in daemon session ids. If e2e silently + // ran the local provider, these tests would exercise the wrong restore path. + expect(ptyId, 'expected a daemon-backed PTY session').toContain(PTY_SESSION_ID_SEPARATOR) + const marker = `RESTORE_INPUT_PRE_${Date.now()}` + await execInTerminal(page, ptyId, `echo ${marker}`) + await waitForTerminalOutput(page, marker) + return { ptyId, marker } +} + +async function settleRestoredLaunch(page: Page): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) +} + +/** + * The shared assertion: a restored pane must accept input. Reports layer + * discrimination when it doesn't. + */ +async function expectRestoredPaneAcceptsInput(page: Page, context: string): Promise { + const ptyIds = await getStorePtyIds(page) + const kbAlive = await probeKeyboardType(page, 'KB_RESTORED_OK', 15_000) + const directAlive = + ptyIds.length > 0 && (await probeDirectWrite(page, ptyIds[0], 'DIRECT_RESTORED_OK', 15_000)) + if (!kbAlive || !directAlive) { + const revived = + ptyIds.length > 0 && + (await probeOwnershipRebuildRevival(page, ptyIds[0], 'REVIVED_RESTORED_OK')) + throw new Error( + buildFrozenPaneReport(context, { + directAlive, + transportAlive: kbAlive, + revivedByOwnershipRebuild: revived, + ptyIds, + terminalTail: await getTerminalContent(page) + }) + ) + } +} + +test.describe.configure({ mode: 'serial' }) + +test('restored pane accepts typing after a clean restart with a live daemon session', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + test.setTimeout(300_000) + const repoPath = seededRepoPathOrSkip() + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + const { marker } = await bootstrapFirstLaunch(first.page, repoPath) + const daemonPid = readDaemonPid(session.userDataDir) + + await session.close(firstApp) + firstApp = null + + const second = await session.launch() + secondApp = second.app + await settleRestoredLaunch(second.page) + await waitForTerminalOutput(second.page, marker, 15_000) + expect(readDaemonPid(session.userDataDir), 'daemon must survive the restart').toBe(daemonPid) + + await expectRestoredPaneAcceptsInput(second.page, 'clean restart, live daemon session') + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) + +test('restored pane recovers input after the daemon un-wedges', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + test.skip(process.platform === 'win32', 'SIGSTOP/SIGCONT are POSIX-only') + test.setTimeout(300_000) + const repoPath = seededRepoPathOrSkip() + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + let stoppedDaemonPid: number | null = null + try { + const first = await session.launch() + firstApp = first.app + const { marker } = await bootstrapFirstLaunch(first.page, repoPath) + const daemonPid = readDaemonPid(session.userDataDir) + + await session.close(firstApp) + firstApp = null + + // Wedge the daemon: it stays alive (socket exists, sessions retained) but + // cannot accept or answer — the shape of a busy/hung daemon during launch. + process.kill(daemonPid, 'SIGSTOP') + stoppedDaemonPid = daemonPid + + const second = await session.launch() + secondApp = second.app + await settleRestoredLaunch(second.page) + + // Field-fidelity check, not a hard gate: does the pane paint restored + // content while its PTY attach cannot complete? That visible-but-dead + // window is exactly what the reporter sees at startup. + const paintedWhileWedged = (await getTerminalContent(second.page)).includes(marker) + + // The relaunching app may classify the stopped daemon as unreachable and + // kill+replace it (daemon hardening) — then SIGCONT throws ESRCH and the + // old sessions are gone. Both shapes must leave the pane typeable, so + // record which one we're in and keep probing. + let daemonReplacedWhileWedged = false + try { + process.kill(daemonPid, 'SIGCONT') + } catch { + daemonReplacedWhileWedged = true + } + stoppedDaemonPid = null + + await expectRestoredPaneAcceptsInput( + second.page, + `daemon wedged during relaunch (painted while wedged: ${paintedWhileWedged}, ` + + `wedged daemon killed+replaced by relaunch: ${daemonReplacedWhileWedged})` + ) + } finally { + if (stoppedDaemonPid !== null) { + try { + process.kill(stoppedDaemonPid, 'SIGCONT') + } catch { + // daemon already gone + } + } + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) + +test('cold-restored pane accepts typing after the daemon died between launches', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + test.skip(process.platform === 'win32', 'POSIX signal semantics keep this deterministic') + test.setTimeout(300_000) + const repoPath = seededRepoPathOrSkip() + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + await bootstrapFirstLaunch(first.page, repoPath) + const daemonPid = readDaemonPid(session.userDataDir) + + await session.close(firstApp) + firstApp = null + + // The daemon dies uncleanly between runs (crash, reboot, force-kill). The + // persisted session now references sessions no living daemon holds. + process.kill(daemonPid, 'SIGKILL') + + const second = await session.launch() + secondApp = second.app + await settleRestoredLaunch(second.page) + + await expectRestoredPaneAcceptsInput(second.page, 'daemon killed between launches') + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index c8e3beab6e5..f36f44e9445 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -3,6 +3,8 @@ import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { execInTerminal, + focusLastTerminalPane, + splitActiveTerminalPane, waitForActivePanePtyId, waitForActiveTerminalManager, waitForTerminalOutput @@ -18,6 +20,7 @@ const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' const KEY_LATENCY_SAMPLES = 'abcdefghij' const MAX_MEDIAN_KEY_LATENCY_MS = 500 const MAX_WORST_KEY_LATENCY_MS = 2_000 +const MIN_HELD_SSH_ACK_CHARS = 256 * 1024 type TypingMeasurement = { latencies: number[] @@ -25,6 +28,20 @@ type TypingMeasurement = { worstLatencyMs: number } +type SshPtyAckGateSnapshot = { + gatedPtyCount: number + heldAckCount: number + heldAckChars: number +} + +type SshPtyAckGateWindow = Window & { + __terminalPtyAckGate?: { + hold: (ptyIds: string[]) => void + release: () => void + snapshot: () => SshPtyAckGateSnapshot + } +} + type ConnectedDockerRemote = { targetId: string repoId: string @@ -56,6 +73,22 @@ function remoteTypingLoadScript(runId: string): string { ].join(';') } +function remoteBackgroundFloodScript(runId: string): string { + return [ + "process.stdin.setEncoding('utf8')", + 'if (process.stdin.isTTY) process.stdin.setRawMode(true)', + 'process.stdin.resume()', + `process.stdout.write('REMOTE_ACK_FLOOD_READY_${runId}\\n')`, + 'let frame = 0', + 'let timer = null', + "const chunk = 'R'.repeat(8192)", + 'function stop() { if (timer) clearInterval(timer); process.exit(0) }', + "function start() { if (timer) return; timer = setInterval(() => { frame += 1; process.stdout.write('REMOTE_ACK_FLOOD_' + frame + '_' + chunk + '\\n') }, 2) }", + "process.stdin.on('data', (chunk) => { if (chunk.includes(String.fromCharCode(3))) stop(); if (chunk.includes('g')) start() })", + "process.on('SIGINT', stop)" + ].join(';') +} + async function connectDockerRemote( page: Page, target: DockerSshRelayTarget @@ -148,6 +181,28 @@ async function measureRemoteTyping( } } +async function holdSshPtyAckGate(page: Page, ptyIds: string[]): Promise { + await page.evaluate((heldPtyIds) => { + const gate = (window as SshPtyAckGateWindow).__terminalPtyAckGate + if (!gate) { + throw new Error('terminal PTY ACK gate is unavailable') + } + gate.hold(heldPtyIds) + }, ptyIds) +} + +async function releaseSshPtyAckGate(page: Page): Promise { + await page.evaluate(() => { + ;(window as SshPtyAckGateWindow).__terminalPtyAckGate?.release() + }) +} + +async function readSshPtyAckGate(page: Page): Promise { + return page.evaluate( + () => (window as SshPtyAckGateWindow).__terminalPtyAckGate?.snapshot() ?? null + ) +} + async function stopRemoteLoad(page: Page, ptyId: string): Promise { await page.evaluate((targetPtyId) => window.api.pty.write(targetPtyId, '\x03'), ptyId) } @@ -207,6 +262,84 @@ test.describe('Docker SSH relay perf', () => { } }) + test('keeps active remote typing responsive while a background SSH PTY stream is ACK-stalled', async ({ + orcaPage + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + let backgroundPtyId: string | null = null + let activePtyId: string | null = null + try { + target = startDockerSshRelayTarget(testInfo) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await connectDockerRemote(orcaPage, target) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + backgroundPtyId = await waitForActivePanePtyId(orcaPage, 60_000) + + const runId = String(Date.now()) + await execInTerminal( + orcaPage, + backgroundPtyId, + `node -e ${shellQuote(remoteBackgroundFloodScript(runId))}` + ) + await waitForTerminalOutput(orcaPage, `REMOTE_ACK_FLOOD_READY_${runId}`, 30_000, 80_000) + await holdSshPtyAckGate(orcaPage, [backgroundPtyId]) + await orcaPage.evaluate((ptyId) => window.api.pty.write(ptyId, 'g'), backgroundPtyId) + + await splitActiveTerminalPane(orcaPage, 'vertical') + await focusLastTerminalPane(orcaPage) + activePtyId = await waitForActivePanePtyId(orcaPage, 60_000) + expect(activePtyId).not.toBe(backgroundPtyId) + + const activeRunId = `${runId}_active` + await execInTerminal( + orcaPage, + activePtyId, + `node -e ${shellQuote(remoteTypingLoadScript(activeRunId))}` + ) + await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${activeRunId}`, 30_000, 80_000) + await expect + .poll(async () => (await readSshPtyAckGate(orcaPage))?.heldAckChars ?? 0, { + timeout: 30_000, + message: 'remote background SSH PTY stream did not build held ACK pressure' + }) + .toBeGreaterThan(MIN_HELD_SSH_ACK_CHARS) + + const measurement = await measureRemoteTyping(orcaPage, activePtyId, activeRunId) + const ackGate = await readSshPtyAckGate(orcaPage) + const summary = `median=${measurement.medianLatencyMs.toFixed( + 1 + )}ms worst=${measurement.worstLatencyMs.toFixed(1)}ms heldAckChars=${ + ackGate?.heldAckChars ?? 0 + } heldPtys=${ackGate?.heldAckCount ?? 0} samples=${measurement.latencies + .map((value) => value.toFixed(1)) + .join(',')}` + console.log(`[docker-ssh-relay-pty-ack-pressure] ${summary}`) + testInfo.annotations.push({ + type: 'docker-ssh-relay-pty-ack-pressure', + description: summary + }) + expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(MIN_HELD_SSH_ACK_CHARS) + expect(measurement.medianLatencyMs).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS) + expect(measurement.worstLatencyMs).toBeLessThan(MAX_WORST_KEY_LATENCY_MS) + + await releaseSshPtyAckGate(orcaPage) + const releasedAckGate = await readSshPtyAckGate(orcaPage) + expect(releasedAckGate?.heldAckChars ?? 0).toBe(0) + } finally { + await releaseSshPtyAckGate(orcaPage).catch(() => undefined) + if (activePtyId) { + await stopRemoteLoad(orcaPage, activePtyId).catch(() => undefined) + } + if (backgroundPtyId) { + await stopRemoteLoad(orcaPage, backgroundPtyId).catch(() => undefined) + } + cleanupDockerSshRelayTarget(target) + } + }) + test('keeps remote typing responsive while relay file streams and git churn are active', async ({ orcaPage }, testInfo) => { diff --git a/tests/e2e/sustained-agent-typing-load-scripts.ts b/tests/e2e/sustained-agent-typing-load-scripts.ts new file mode 100644 index 00000000000..430ef47da07 --- /dev/null +++ b/tests/e2e/sustained-agent-typing-load-scripts.ts @@ -0,0 +1,135 @@ +/** + * Script generators for the multi-workspace sustained typing-latency bench + * (terminal-multi-workspace-typing-latency.spec.ts): + * + * - a paced agent-TUI load generator that replays the deterministic pipeline + * bench fixture through a real PTY at a fixed byte rate, emulating a Claude + * Code-style agent streaming in another workspace, and + * - a typing echo probe that timestamps each keystroke's arrival at the pty + * into a sidecar JSONL, so a key's total latency decomposes into + * input-half (CDP keydown -> pty stdin) and echo-half (pty echo -> screen). + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +// Why absolute file URL: the generated .mjs scripts run with the disposable +// test repo as cwd, so the fixture builder must be imported by absolute +// specifier (file URL keeps Windows drive-letter paths importable). +const PIPELINE_BENCH_URL = pathToFileURL( + path.resolve(__dirname, '..', '..', 'tools', 'benchmarks', 'terminal-pipeline-bench.mjs') +).href + +export function sustainedLoadReadyFilePath( + directory: string, + runId: string, + paneIndex: number +): string { + return path.join(directory, `.orca-mwt-load-ready-${runId}-${paneIndex}`) +} + +export function typingProbeReadyMarker(runId: string): string { + return `MWT_TYPING_READY_${runId}` +} + +export function typingKeyMarkerPrefix(runId: string): string { + return `MWT_KEY_${runId}_` +} + +function sustainedAgentLoadScript(runId: string, readyFileDirectory: string): string { + return ` +import { writeFileSync } from 'node:fs' +import { buildFixture } from ${JSON.stringify(PIPELINE_BENCH_URL)} + +const paneIndex = Number(process.argv[2] ?? 0) +const rateKbps = Number(process.argv[3] ?? 256) +const durationS = Number(process.argv[4] ?? 60) + +const cols = process.stdout.columns ?? 80 +const rows = process.stdout.rows ?? 24 +// 2MB of deterministic Claude-Code-shaped frames, replayed in a loop. +const fixture = buildFixture('agent-tui', 2 * 1024 * 1024, cols, rows) + +const TICK_MS = 50 +const chunkChars = Math.max(1, Math.floor((rateKbps * 1024 * TICK_MS) / 1000)) +const writeChunk = (data) => + new Promise((resolve) => { + if (process.stdout.write(data)) { + resolve() + } else { + process.stdout.once('drain', resolve) + } + }) +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +// Readiness signals via the filesystem, not the terminal buffer: with many +// panes the stream scrolls a READY marker out of the buffer's serialize +// window before the spec's sequential checks reach it. +writeFileSync( + ${JSON.stringify(readyFileDirectory)} + '/.orca-mwt-load-ready-${runId}-' + paneIndex, + String(Date.now()) +) +process.stdout.write('${'MWT_LOAD_READY_'}${runId}_' + paneIndex + '\\r\\n') +const deadline = Date.now() + durationS * 1000 +let offset = 0 +while (Date.now() < deadline) { + await writeChunk(fixture.slice(offset, offset + chunkChars)) + offset = (offset + chunkChars) % fixture.length + await sleep(TICK_MS) +} +process.stdout.write('\\x1b[0m\\x1b[?2026l\\r\\nMWT_LOAD_DONE_${runId}_' + paneIndex + '\\r\\n') +` +} + +function typingEchoProbeScript(runId: string, arrivalSidecarPath: string): string { + return ` +import { appendFileSync } from 'node:fs' + +process.stdin.setEncoding('utf8') +if (process.stdin.isTTY) process.stdin.setRawMode(true) +process.stdin.resume() +let seq = 0 +const interrupt = String.fromCharCode(3) +process.stdout.write('${'MWT_TYPING_READY_'}${runId}\\r\\n') +process.stdin.on('data', (chunk) => { + // One arrival timestamp per chunk: coalesced keystrokes genuinely arrive + // together at the pty, and that coalescing is part of what we measure. + const atMs = Date.now() + if (chunk.includes(interrupt)) { + process.exit(0) + } + for (const char of chunk) { + if (char === '\\r' || char === '\\n') continue + seq += 1 + appendFileSync( + ${JSON.stringify(arrivalSidecarPath)}, + JSON.stringify({ seq, atMs }) + '\\n' + ) + process.stdout.write('\\r\\x1b[2Kmwt prompt ' + seq + ': ' + char + ' ${'MWT_KEY_'}${runId}_' + seq + '\\r\\n') + } +}) +` +} + +export function writeSustainedAgentLoadScript( + scriptPath: string, + runId: string, + readyFileDirectory: string +): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + // Generated scripts concatenate with '/', which Node's fs accepts on all + // platforms; normalize Windows backslashes out of the baked-in directory. + writeFileSync( + scriptPath, + sustainedAgentLoadScript(runId, readyFileDirectory.replaceAll('\\', '/')) + ) +} + +export function writeTypingEchoProbeScript( + scriptPath: string, + runId: string, + arrivalSidecarPath: string +): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync(scriptPath, typingEchoProbeScript(runId, arrivalSidecarPath)) +} diff --git a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts index 74453aa4076..2dccaf2e87c 100644 --- a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts +++ b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts @@ -1,6 +1,6 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' import { randomUUID } from 'node:crypto' -import { rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import { test, expect } from './helpers/orca-app' import { @@ -22,29 +22,23 @@ type HiddenTuiWindow = Window & { __terminalPtyDataInjection?: { inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean } + // Why: only the mode-2031 fact-reply counter survives Phase 6 — the + // hidden-skip counters were deleted with the renderer skip grammar. __terminalPtyOutputDebug?: { reset: () => void snapshot: () => { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number hiddenRendererMode2031ReplyCount: number } } } -type HiddenTuiDebugSnapshot = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number - hiddenRendererMode2031ReplyCount: number -} - type TuiCursorState = { hidden: boolean | null initialized: boolean | null - cursorElementVisible: boolean - cursorCanvasPresent: boolean } +const HIDDEN_FRAME_SCRIPT_DELAY_MS = 750 + function tuiFrame(runId: string, frame: number): string { const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` const rows = [ @@ -79,23 +73,43 @@ function lowRiskRestoreFrame(runId: string, frame: number): string { } async function resetHiddenDebug(page: Page): Promise { - await page.evaluate(() => { + await page.evaluate(async () => { ;(window as HiddenTuiWindow).__terminalPtyOutputDebug?.reset() + // Why: under the Phase-4 hidden-delivery gate the withheld-output signal + // lives in main's delivery debug counters, not the renderer skip path. + await window.api.pty.resetRendererDeliveryDebug() }) } function writeHiddenFrameScript(scriptPath: string, runId: string): void { const frames = Array.from({ length: 25 }, (_, frame) => tuiFrame(runId, frame)) - writeFileSync(scriptPath, `process.stdout.write(${JSON.stringify(frames.join(''))})\n`) + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${HIDDEN_FRAME_SCRIPT_DELAY_MS})\n` + ) +} + +function writeLowRiskFrameScript(scriptPath: string, frame: string): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frame)}), ${HIDDEN_FRAME_SCRIPT_DELAY_MS})\n` + ) } async function writeHiddenFrames(page: Page, ptyId: string, scriptPath: string): Promise { await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`) } -async function readHiddenDebug(page: Page): Promise { - return page.evaluate(() => { - return (window as HiddenTuiWindow).__terminalPtyOutputDebug?.snapshot() ?? null +// Why: Phase-4 hidden-delivery gate contract — hidden PTY bytes are dropped +// in main after model ingestion and never reach the renderer, so "hidden +// output was withheld" is observed via main's dropped-chars counter instead +// of the old renderer hidden-skip counters. +async function readMainHiddenDeliveryDroppedChars(page: Page): Promise { + return page.evaluate(async () => { + const snapshot = await window.api.pty.getRendererDeliveryDebugSnapshot() + return snapshot.hiddenDeliveryDroppedChars }) } @@ -120,23 +134,9 @@ async function readTuiCursorState(page: Page): Promise { _core?: { coreService?: { isCursorHidden?: boolean; isCursorInitialized?: boolean } } } )._core - const cursorElement = pane.container.querySelector('.xterm-cursor') - const cursorRect = cursorElement?.getBoundingClientRect() - const cursorStyle = cursorElement ? window.getComputedStyle(cursorElement) : null return { hidden: terminalCore?.coreService?.isCursorHidden ?? null, - initialized: terminalCore?.coreService?.isCursorInitialized ?? null, - // Why: a blinking DOM cursor may be transparent during the sampled frame; - // disappearance regressions remove the laid-out cursor element/layer. - cursorElementVisible: - !!cursorElement && - !!cursorRect && - cursorRect.width > 0 && - cursorRect.height > 0 && - cursorStyle?.display !== 'none' && - cursorStyle?.visibility !== 'hidden', - cursorCanvasPresent: - pane.container.querySelector('.xterm-cursor-layer canvas') !== null + initialized: terminalCore?.coreService?.isCursorInitialized ?? null } }) } @@ -245,13 +245,23 @@ test.describe('Hidden terminal TUI visual restore', () => { writeHiddenFrameScript(scriptPath, runId) await resetHiddenDebug(orcaPage) await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath) + await resetHiddenDebug(orcaPage) + // Why: hidden-delivery gate contract — the bulk TUI frames must be + // withheld in main (dropped after model ingestion), not delivered and + // skipped renderer-side. await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { timeout: 10_000, - message: 'visually rich hidden TUI output should stay on the live xterm path' + message: 'visually rich hidden TUI output was not withheld from the renderer' }) - .toBe(0) + .toBeGreaterThan(1024) + await expect + .poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), { + timeout: 10_000, + message: 'visually rich hidden TUI source did not come from headless model' + }) + .toBe('headless') await switchToWorktree(orcaPage, secondWorktreeId) await ensureTerminalVisible(orcaPage) @@ -279,6 +289,7 @@ test.describe('Hidden terminal TUI visual restore', () => { hidden: false, initialized: true }) + const screenshotPath = testInfo.outputPath('hidden-tui-restore-final.png') await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) await testInfo.attach('hidden-tui-restore-final.png', { @@ -288,8 +299,9 @@ test.describe('Hidden terminal TUI visual restore', () => { rmSync(scriptPath, { force: true }) }) - test('keeps newer live output correct after hidden output stayed live', async ({ - orcaPage + test('keeps newer live output correct after plain hidden output restores', async ({ + orcaPage, + testRepoPath }, testInfo: TestInfo) => { await waitForSessionReady(orcaPage) const firstWorktreeId = await waitForActiveWorktree(orcaPage) @@ -323,18 +335,20 @@ test.describe('Hidden terminal TUI visual restore', () => { const hiddenFrame = lowRiskRestoreFrame(runId, 40) const liveFrame = lowRiskRestoreFrame(runId, 41) const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_41` + const scriptPath = path.join(testRepoPath, `.orca-low-risk-hidden-${runId}.mjs`) + writeLowRiskFrameScript(scriptPath, hiddenFrame) + await resetHiddenDebug(orcaPage) + await sendToTerminal(orcaPage, hiddenPane.ptyId, `node ${JSON.stringify(scriptPath)}\r`) await resetHiddenDebug(orcaPage) - await injectPaneData(orcaPage, paneKey, hiddenFrame, { - seq: hiddenFrame.length, - rawLength: hiddenFrame.length - }) + // Why: hidden-delivery gate contract — even plain hidden output is + // dropped in main, so the withheld signal is main's dropped counter. await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { timeout: 10_000, - message: 'hidden injected output should stay on the live xterm path for release' + message: 'plain hidden injected output was not withheld from the renderer' }) - .toBe(0) + .toBeGreaterThan(0) await switchToWorktree(orcaPage, secondWorktreeId) await ensureTerminalVisible(orcaPage) @@ -347,7 +361,7 @@ test.describe('Hidden terminal TUI visual restore', () => { await expect .poll(() => getTerminalContent(orcaPage, 12_000), { timeout: 10_000, - message: 'newer live TUI frame did not render after hidden output stayed live' + message: 'newer live TUI frame did not render after hidden output restored' }) .toContain(finalMarker) @@ -361,7 +375,7 @@ test.describe('Hidden terminal TUI visual restore', () => { await expect .poll(() => readTuiCursorState(orcaPage), { timeout: 5_000, - message: 'live TUI cursor stayed hidden after hidden output stayed live' + message: 'live TUI cursor stayed hidden after hidden output restored' }) .toMatchObject({ hidden: false, @@ -373,9 +387,102 @@ test.describe('Hidden terminal TUI visual restore', () => { path: screenshotPath, contentType: 'image/png' }) + rmSync(scriptPath, { force: true }) }) - test('keeps hidden terminal side effects live while hidden output stays live', async ({ + test('restores rich synchronized TUI output from the headless model', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'hidden TUI restore needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const hiddenSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + const hiddenPane = hiddenSnapshot.panes[0] + if (!hiddenPane?.ptyId) { + throw new Error('hidden rich model pane did not bind a PTY') + } + await switchToWorktree(orcaPage, firstWorktreeId) + await expect + .poll(() => getActiveWorktreeId(orcaPage), { + timeout: 10_000, + message: 'first worktree did not become active before hidden rich model restore' + }) + .toBe(firstWorktreeId) + + const runId = randomUUID() + const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_24` + const scriptPath = path.join(testRepoPath, `.orca-hidden-rich-model-${runId}.mjs`) + writeHiddenFrameScript(scriptPath, runId) + await resetHiddenDebug(orcaPage) + try { + await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath) + await resetHiddenDebug(orcaPage) + + // Why: hidden-delivery gate contract — synchronized rich frames are + // withheld in main; the headless model snapshot is the restore source. + await expect + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { + timeout: 10_000, + message: 'rich hidden TUI output was not withheld from the renderer' + }) + .toBeGreaterThan(0) + await expect + .poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), { + timeout: 10_000, + message: 'rich hidden TUI source did not come from headless model' + }) + .toBe('headless') + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 10_000, + message: 'rich headless TUI frame did not restore when visible' + }) + .toContain(finalMarker) + + const content = await getTerminalContent(orcaPage, 12_000) + expect(content).toContain(`Frame 024`) + expect(content).toContain('╭') + expect(content).toContain('├') + expect(content).toContain('█') + expect(content).not.toContain('Orca skipped hidden terminal output') + await expect + .poll(() => readTuiCursorState(orcaPage), { + timeout: 5_000, + message: 'rich headless TUI cursor stayed hidden after restore' + }) + .toMatchObject({ + hidden: false, + initialized: true + }) + + const screenshotPath = testInfo.outputPath('hidden-rich-model-restore-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('hidden-rich-model-restore-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + rmSync(scriptPath, { force: true }) + } + }) + + test('keeps hidden terminal side effects live while hidden output may restore', async ({ orcaPage }) => { await waitForSessionReady(orcaPage) @@ -411,12 +518,6 @@ test.describe('Hidden terminal TUI visual restore', () => { await resetHiddenDebug(orcaPage) await writeHiddenSideEffectBurst(orcaPage, hiddenPane.ptyId, hiddenTitle, marker) - await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { - timeout: 10_000, - message: 'hidden side-effect output should stay on the live xterm path for release' - }) - .toBe(0) await expect .poll(() => getRuntimePaneTitle(orcaPage, hiddenSnapshot.tabId, hiddenPane.numericPaneId), { timeout: 10_000, diff --git a/tests/e2e/terminal-hidden-view-parking.spec.ts b/tests/e2e/terminal-hidden-view-parking.spec.ts new file mode 100644 index 00000000000..4bd2e143e13 --- /dev/null +++ b/tests/e2e/terminal-hidden-view-parking.spec.ts @@ -0,0 +1,552 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + getWorktreeTabs, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +// Why: the parking wiring registers this handle (dev/exposeStore builds only) +// so tests can detect that hidden-view parking is compiled in and which delay +// override the app actually applied. +type ParkingDebugWindow = Window & { + __terminalParkingDebug?: { + parkDelayMs?: number + } +} + +// Why: production cold-park hysteresis is 30s with a multi-minute hot-retain +// window. The fast-park override must be scoped to THIS spec's app launches — +// mutating process.env at module scope leaked into later specs when a worker +// reloaded files without replaying this file's afterAll. +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) } +}) + +const PARKED_FRAME_SCRIPT_DELAY_MS = 750 +const PARKED_FRAME_COUNT = 25 + +function parkedTuiFrame(runId: string, frame: number): string { + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + const rows = [ + '╭────────────────────────────────────────────────────────────────────╮', + `│ Parked view restore Frame ${String(frame).padStart(3, '0')} ${frame % 2 === 0 ? '🟢' : '🟡'} ${progress} │`, + '├──────────────┬──────────────────────┬──────────────────────────────┤', + `│ model │ codex/opencode │ ${runId.slice(0, 28).padEnd(28)} │`, + `│ status │ ${frame % 2 === 0 ? 'thinking' : 'streaming'} │ input ${'#'.repeat((frame % 18) + 1).padEnd(22)} │`, + `│ diff │ +${String(frame * 3).padEnd(19)} │ -${String(frame).padEnd(27)} │`, + '╰──────────────┴──────────────────────┴──────────────────────────────╯', + `PARKED_RESTORE_FINAL_${runId}_${frame}` + ] + return [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'), + '\x1b[10;18H\x1b[?25h', + '\x1b[?2026l' + ].join('') +} + +function writeParkedFrameScript(scriptPath: string, runId: string): void { + const frames = Array.from({ length: PARKED_FRAME_COUNT }, (_, frame) => + parkedTuiFrame(runId, frame) + ) + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${PARKED_FRAME_SCRIPT_DELAY_MS})\n` + ) +} + +// Deterministic static alt-screen frame for the park/reveal cycle test: rich +// styling (box drawing, SGR colors, wide glyphs) that exercises the snapshot +// restore, painted once and held so the on-screen content is stable across +// cycles. No spinner/progress churn — the frame must be byte-identical every +// reveal so drift is detectable. +function cycleReferenceFrame(runId: string): string { + const rows = [ + '╭──────────────────────────────────────────────────────────╮', + `│ Park/reveal cycle reference ${runId.slice(0, 8)} 🟢 你好世界 터미널 │`, + '├───────────────┬──────────────────────────────────────────┤', + `│ model │ \x1b[1mcodex/opencode\x1b[22m stream +142 -37 │`, + `│ status │ \x1b[38;5;204mrunning\x1b[0m\x1b[2;36m diff --git a/pty.ts esc↩ │`, + '╰───────────────┴──────────────────────────────────────────╯', + `CYCLE_REFERENCE_${runId}` + ] + return [ + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'), + '\x1b[?25h' + ].join('') +} + +function writeCycleReferenceScript(scriptPath: string, runId: string): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + // Paint the frame once, then hold the process open so the alt-screen TUI + // stays on screen (and the parkable PTY session stays alive) across cycles. + writeFileSync( + scriptPath, + `process.stdout.write(${JSON.stringify(cycleReferenceFrame(runId))}); setInterval(() => {}, 1000)\n` + ) +} + +// Why: serialize() re-emits the buffer with cursor-restore trailer sequences +// (ESC[…H, ESC[?25h) and the exact CSI form can differ run-to-run without any +// visible change. Compare the CONTENT rows, not the trailer — strip trailing +// control sequences and normalize whitespace-only tail lines. +function terminalContentRows(serialized: string): string[] { + // eslint-disable-next-line no-control-regex + const stripped = serialized.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') + return stripped + .split(/\r?\n/) + .map((line) => line.replace(/\s+$/, '')) + .filter((line) => line.length > 0) +} + +async function readParkingWiring( + page: Page +): Promise<{ present: boolean; parkDelayMs: number | null }> { + return page.evaluate(() => { + const debug = (window as ParkingDebugWindow).__terminalParkingDebug + return { present: debug !== undefined, parkDelayMs: debug?.parkDelayMs ?? null } + }) +} + +// Why: the spec lands ahead of the feature wiring. Skip (rather than fail) +// when the app under test does not expose the parking debug handle so this +// file is safe to merge in any order with the wiring branch. +async function skipUnlessParkingWired(page: Page): Promise { + const deadline = Date.now() + 2_000 + let wiring = await readParkingWiring(page) + while (!wiring.present && Date.now() < deadline) { + await page.waitForTimeout(250) + wiring = await readParkingWiring(page) + } + test.skip( + !wiring.present, + 'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)' + ) +} + +type TerminalTabViewState = { + hasManager: boolean + paneCount: number +} + +async function readTerminalTabViewState(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return { + hasManager: manager !== undefined, + paneCount: manager?.getPanes?.().length ?? 0 + } + }, tabId) +} + +// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so a +// missing manager is the observable signal that the tab's xterm was parked. +async function waitForTabParked(page: Page, tabId: string): Promise { + const parkWaitStartedAt = Date.now() + await expect + .poll(async () => (await readTerminalTabViewState(page, tabId)).hasManager, { + timeout: Math.max(20_000, PARKING_DELAY_MS * 10), + message: `terminal tab ${tabId} did not park (pane manager still mounted)` + }) + .toBe(false) + return Date.now() - parkWaitStartedAt +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((targetTabId) => { + const store = window.__store + if (!store) { + throw new Error('activateTerminalTab: window.__store is unavailable') + } + const state = store.getState() + state.setActiveTabType('terminal') + state.setActiveTab(targetTabId) + }, tabId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: `terminal tab ${tabId} did not become active` + }) + .toBe(tabId) +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + return tabId +} + +async function getUnreadTerminalTabIds(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return [] + } + return Object.keys(store.getState().unreadTerminalTabs) + }) +} + +async function isWorktreeUnread(page: Page, worktreeId: string): Promise { + return page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + return false + } + const worktree = Object.values(store.getState().worktreesByRepo) + .flat() + .find((candidate) => candidate.id === worktreeId) + return worktree?.isUnread === true + }, worktreeId) +} + +async function getTerminalTabTitle( + page: Page, + worktreeId: string, + tabId: string +): Promise { + const tabs = await getWorktreeTabs(page, worktreeId) + return tabs.find((tab) => tab.id === tabId)?.title ?? null +} + +async function hasPendingStartupCommand(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const store = window.__store + if (!store) { + return false + } + return store.getState().pendingStartupByTabId[tabId] !== undefined + }, tabId) +} + +type ParkableTabSetup = { + worktreeId: string + tabAId: string + tabAPtyId: string +} + +// Why: every scenario starts from the same shape — tab A live in the active +// worktree; callers then create more tabs on top so tab A goes hidden. +async function setUpParkableTabA(page: Page): Promise { + const worktreeId = await waitForActiveWorktree(page) + await skipUnlessParkingWired(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const tabASnapshot = await waitForPaneIdentitySnapshot(page, 1) + const tabAPtyId = tabASnapshot.panes[0]?.ptyId + if (!tabAPtyId) { + throw new Error('parking spec tab A did not bind a PTY') + } + return { + worktreeId, + tabAId: tabASnapshot.tabId, + tabAPtyId + } +} + +test.describe('Terminal hidden view parking', () => { + test('parks a hidden terminal tab and restores rich TUI output on reveal', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId, tabAPtyId } = setup + + const runId = randomUUID() + const finalMarker = `PARKED_RESTORE_FINAL_${runId}_${PARKED_FRAME_COUNT - 1}` + const scriptPath = path.join(testRepoPath, `.orca-parked-rich-tui-${runId}.mjs`) + writeParkedFrameScript(scriptPath, runId) + try { + await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'rich TUI final frame did not render while tab A was visible' + }) + .toContain(finalMarker) + + const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) + const parkDetectedAfterMs = await waitForTabParked(orcaPage, tabAId) + const wiring = await readParkingWiring(orcaPage) + testInfo.annotations.push({ + type: 'terminal-parking', + description: `parkDelayMs=${wiring.parkDelayMs ?? PARKING_DELAY_MS} parkDetectedAfterMs=${parkDetectedAfterMs}` + }) + + // Why: parking must be scoped to the hidden tab — the visible tab keeps + // a live pane manager and xterm. + const tabBState = await readTerminalTabViewState(orcaPage, tabBId) + expect(tabBState.hasManager).toBe(true) + expect(tabBState.paneCount).toBeGreaterThan(0) + + await activateTerminalTab(orcaPage, tabAId) + await waitForActiveTerminalManager(orcaPage, 30_000) + const revealedSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + expect(revealedSnapshot.tabId).toBe(tabAId) + // Why: parking only tears down the renderer view; the PTY session must + // survive so reveal reattaches to the same shell. + expect(revealedSnapshot.panes[0]?.ptyId).toBe(tabAPtyId) + + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'parked rich TUI frame did not restore when the tab was revealed' + }) + .toContain(finalMarker) + + const content = await getTerminalContent(orcaPage, 12_000) + expect(content).toContain(`Frame ${String(PARKED_FRAME_COUNT - 1).padStart(3, '0')}`) + expect(content).toContain('╭') + expect(content).toContain('├') + expect(content).toContain('█') + expect(content).not.toContain('Orca skipped hidden terminal output') + + // Why: the typed marker only appears joined in command *output*, so this + // proves the revealed terminal accepts input end-to-end, not just echo. + const typedMarker = `PARKED_TYPED_OK_${runId}` + const typedProbeScript = `console.log('PARKED_TYPED_OK_' + '${runId}')` + await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(typedProbeScript)}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 10_000, + message: 'revealed terminal did not execute and display typed input' + }) + .toContain(typedMarker) + + const screenshotPath = testInfo.outputPath('parked-tab-restore-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('parked-tab-restore-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + rmSync(scriptPath, { force: true }) + } + }) + + test('keeps bell and title side effects live while parked', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId, tabAPtyId } = setup + + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabAId) + + const runId = randomUUID() + const parkedTitle = `Parked side effects ${runId}` + const marker = `PARKED_SIDE_EFFECT_MARKER_${runId}` + // Why: OSC 0 title first, then a standalone BEL (the OSC terminator BEL + // must not count as a bell), then a content marker for the reveal check. + // The 30s keep-alive stops the shell prompt from overwriting the title + // before the store assertion lands. + const payload = `\x1b]0;${parkedTitle}\x07\x07${marker}\n` + const sideEffectScript = `process.stdout.write(${JSON.stringify(payload)}); setTimeout(() => process.exit(0), 30000)` + await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(sideEffectScript)}\r`) + + await expect + .poll(() => getTerminalTabTitle(orcaPage, worktreeId, tabAId), { + timeout: 10_000, + message: 'parked OSC 0 title did not update the tab title in the store' + }) + .toBe(parkedTitle) + await expect + .poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(tabAId), { + timeout: 10_000, + message: 'parked BEL did not mark the terminal tab unread' + }) + .toBe(true) + await expect + .poll(() => isWorktreeUnread(orcaPage, worktreeId), { + timeout: 10_000, + message: 'parked BEL did not mark the worktree unread' + }) + .toBe(true) + + // Why: side effects must come from the pane-less watcher — the burst must + // not have woken the parked view back up. + expect((await readTerminalTabViewState(orcaPage, tabAId)).hasManager).toBe(false) + + await activateTerminalTab(orcaPage, tabAId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'parked side-effect marker did not restore when the tab was revealed' + }) + .toContain(marker) + }) + + test('does not park excluded tabs', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId } = setup + + // Tab C: parking-excluded because it has a pending startup command. Queue + // it after the pane mounted so the mount-time consume cannot drain it. + const tabCId = await createActiveTerminalTab(orcaPage, worktreeId) + await orcaPage.evaluate((tabId) => { + const store = window.__store + if (!store) { + throw new Error('parking exclusion spec: window.__store is unavailable') + } + store.getState().queueTabStartupCommand(tabId, { command: 'echo parked-exclusion-probe' }) + }, tabCId) + expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true) + + // Tab B on top hides both A and C. + const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) + await expect + .poll(() => getActiveTabId(orcaPage), { + timeout: 5_000, + message: 'tab B did not stay active while waiting on the parking window' + }) + .toBe(tabBId) + + // Why: tab A parking proves the machinery ran past the delay in this app + // instance, so the tab C assertion below is not vacuously green. + await waitForTabParked(orcaPage, tabAId) + await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) + + // Premise guard: nothing consumed the pending startup while hidden. + expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true) + const tabCState = await readTerminalTabViewState(orcaPage, tabCId) + expect(tabCState.hasManager).toBe(true) + expect(tabCState.paneCount).toBeGreaterThan(0) + }) + + // Drives 25 deterministic park→reveal cycles on a static rich TUI frame and + // asserts every reveal reproduces the SAME content the tab showed while it was + // continuously visible (the never-parked reference). This is the field-garble + // guard end-to-end: it exercises the real renderer teardown + HeadlessEmulator + // snapshot restore + PTY reattach path the fuzz suites model in isolation, and + // fails if any single cycle — or accumulated drift across 25 — garbles a cell. + test('reproduces a static frame byte-for-byte across 25 park/reveal cycles', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(180_000) + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId, tabAPtyId } = setup + + const runId = randomUUID() + const marker = `CYCLE_REFERENCE_${runId}` + const scriptPath = path.join(testRepoPath, `.orca-cycle-reference-${runId}.mjs`) + writeCycleReferenceScript(scriptPath, runId) + try { + await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'cycle reference frame did not render while tab A was visible' + }) + .toContain(marker) + + // Tab B stays visible whenever tab A is parked; toggling the active tab + // between them is the deterministic hide/reveal driver. + const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) + + // One park/reveal cycle to run the frame through the snapshot restore for a + // baseline. Why not compare against the visible-before-park content: an + // alt-screen restore deliberately drops the normal-buffer scrollback + // (serializeHeadlessTerminalBuffer forces scrollback 0 under alt), so the + // pre-park serialize carries the shell command echo the restore correctly + // omits — that is contract, not garble. Baselining after one reveal makes + // both sides pass through identical machinery, so any later diff is drift. + const runOneParkRevealCycle = async (cycle: number): Promise => { + await activateTerminalTab(orcaPage, tabBId) + await waitForTabParked(orcaPage, tabAId) + await activateTerminalTab(orcaPage, tabAId) + await waitForActiveTerminalManager(orcaPage, 30_000) + const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1) + expect(revealed.panes[0]?.ptyId).toBe(tabAPtyId) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: `cycle ${cycle}: reference frame did not restore on reveal` + }) + .toContain(marker) + const rows = terminalContentRows(await getTerminalContent(orcaPage, 12_000)) + // Garble sentinel: the hidden-skip banner must never appear. + expect(rows.join('\n')).not.toContain('Orca skipped hidden terminal output') + return rows + } + + const referenceRows = await runOneParkRevealCycle(0) + expect(referenceRows.join('\n')).toContain(marker) + expect(referenceRows.join('\n')).toContain('╭') + + // waitForTabParked (inside runOneParkRevealCycle) throws if the tab never + // parked, so reaching here means the machinery ran every cycle — no + // separate premise guard needed for a vacuous-green check. + const CYCLES = 25 + const mismatches: string[] = [] + for (let cycle = 1; cycle < CYCLES; cycle++) { + const rows = await runOneParkRevealCycle(cycle) + if (JSON.stringify(rows) !== JSON.stringify(referenceRows)) { + mismatches.push( + `cycle ${cycle}:\n expected: ${JSON.stringify(referenceRows)}\n actual: ${JSON.stringify(rows)}` + ) + } + } + + testInfo.annotations.push({ + type: 'terminal-parking-cycles', + description: `cycles=${CYCLES} mismatches=${mismatches.length}` + }) + expect( + mismatches, + `park/reveal drift across ${CYCLES} cycles:\n${mismatches.join('\n')}` + ).toEqual([]) + + const screenshotPath = testInfo.outputPath('park-reveal-25-cycles-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('park-reveal-25-cycles-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +}) diff --git a/tests/e2e/terminal-long-table-scroll-restore.spec.ts b/tests/e2e/terminal-long-table-scroll-restore.spec.ts index 1af4b1ecf41..6452207980f 100644 --- a/tests/e2e/terminal-long-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-long-table-scroll-restore.spec.ts @@ -51,17 +51,6 @@ type TerminalRenderDiagnostics = { }[] } -type LongTableDebugWindow = Window & { - __terminalPtyOutputDebug?: { - reset: () => void - snapshot: () => { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number - hiddenRendererMode2031ReplyCount: number - } - } -} - async function setNarrowTerminalViewport(page: Page): Promise { await page.setViewportSize({ width: 900, height: 820 }) await page.waitForTimeout(250) @@ -359,7 +348,6 @@ test.describe('Terminal long table scroll restore repro', () => { window.__store ?.getState() .markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) - ;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset() }) const firstWorktreeId = await waitForActiveWorktree(orcaPage) const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( @@ -398,10 +386,6 @@ test.describe('Terminal long table scroll restore repro', () => { await scrollActiveTerminalLikeUser(orcaPage) await closeFeatureTips(orcaPage) const diagnostics = await readTerminalRenderDiagnostics(orcaPage) - const hiddenDebug = await orcaPage.evaluate(() => - (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() - ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) const restoredPane = diagnostics.allPaneStates.find((paneState) => paneState.hasMarker) expect(restoredPane).toBeDefined() expect(diagnostics.cursorHidden).toBe(false) @@ -426,7 +410,6 @@ test.describe('Terminal long table scroll restore repro', () => { window.__store ?.getState() .markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) - ;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset() }) const firstWorktreeId = await waitForActiveWorktree(orcaPage) const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( @@ -467,10 +450,6 @@ test.describe('Terminal long table scroll restore repro', () => { await scrollActiveTerminalLikeUser(orcaPage) await closeFeatureTips(orcaPage) const diagnostics = await readTerminalRenderDiagnostics(orcaPage) - const hiddenDebug = await orcaPage.evaluate(() => - (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() - ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) // Why: renderer cell metrics can land one column wider in headless runs; // the content and screenshot assertions below cover the actual regression. expect(diagnostics.cols).toBeLessThanOrEqual(112) @@ -504,7 +483,6 @@ test.describe('Terminal long table scroll restore repro', () => { window.__store ?.getState() .markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) - ;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset() }) const firstWorktreeId = await waitForActiveWorktree(orcaPage) const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( @@ -571,10 +549,6 @@ test.describe('Terminal long table scroll restore repro', () => { const diagnostics = await readTerminalRenderDiagnostics(orcaPage) const overpaint = await readTerminalRightEdgeOverpaint(orcaPage) const wrapDiagnostics = await readTerminalBoxTableWrapDiagnostics(orcaPage) - const hiddenDebug = await orcaPage.evaluate(() => - (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() - ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) expect(diagnostics.cols).toBeLessThanOrEqual(NARROW_TERMINAL_MAX_COLS) expect(wrapDiagnostics.cols).toBeGreaterThanOrEqual(generatedTableWidth) expect(diagnostics.cursorHidden).toBe(false) diff --git a/tests/e2e/terminal-multi-workspace-typing-latency.spec.ts b/tests/e2e/terminal-multi-workspace-typing-latency.spec.ts new file mode 100644 index 00000000000..49bb13fc496 --- /dev/null +++ b/tests/e2e/terminal-multi-workspace-typing-latency.spec.ts @@ -0,0 +1,602 @@ +/** + * Deterministic reproduction + benchmark for "typing lags while multiple + * workspaces run agents" (the multi-workspace typing-latency complaint). + * + * Unlike the artificial-opencode suite (bounded bursts + held ACK gates), + * this harness runs SUSTAINED paced agent-TUI streams through real PTYs in + * background-workspace panes (and optionally visible splits) with no + * artificial wedges, types at a fixed cadence WITHOUT waiting for each echo + * (real users keep typing), and decomposes every key's latency into: + * input-half = CDP keydown -> byte arrives at the pty (probe sidecar) + * echo-half = pty echo -> marker visible in the xterm buffer + * All three clocks are epoch ms on one machine, so the halves add up. + * + * Scenarios are gated behind ORCA_TYPING_BENCH=1 (they are benchmarks that + * may legitimately "fail" while the bug reproduces, not CI regression gates). + * Entry point: pnpm bench:multi-workspace-typing (see + * config/scripts/run-multi-workspace-typing-bench.mjs for knobs). Results are + * written as JSON to tools/benchmarks/results/ for A/B comparison. + */ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { type ChildProcess, spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveWorktreeId, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + ensureActiveWorktreePaneLoad, + focusActiveTerminalInput, + focusPane, + waitForTerminalOutputForPtyId, + type TerminalLoadPane +} from './artificial-opencode-pane-interactions' +import { + sustainedLoadReadyFilePath, + typingKeyMarkerPrefix, + typingProbeReadyMarker, + writeSustainedAgentLoadScript, + writeTypingEchoProbeScript +} from './sustained-agent-typing-load-scripts' + +const BENCH_ENABLED = process.env.ORCA_TYPING_BENCH === '1' + +function readPositiveInt(name: string, fallback: number): number { + const value = Number(process.env[name]) + return Number.isInteger(value) && value > 0 ? value : fallback +} + +const LOAD_PANES = readPositiveInt('ORCA_TYPING_BENCH_LOAD_PANES', 4) +const LOAD_RATE_KBPS = readPositiveInt('ORCA_TYPING_BENCH_RATE_KBPS', 256) +const KEY_COUNT = readPositiveInt('ORCA_TYPING_BENCH_KEYS', 32) +const KEY_CADENCE_MS = readPositiveInt('ORCA_TYPING_BENCH_KEY_CADENCE_MS', 250) +const CPU_WORKERS = readPositiveInt('ORCA_TYPING_BENCH_CPU_WORKERS', 0) +const BENCH_LABEL = process.env.ORCA_TYPING_BENCH_LABEL ?? 'dev' + +const KEY_CHARS = 'abcdefghijklmnopqrstuvwxyz' +const TIMER_SAMPLE_MS = 16 +const MARKER_SCAN_TRAILING_ROWS = 160 +const ECHO_STRAGGLER_TIMEOUT_MS = 30_000 +// Load must outlive setup (pane splits, worktree switches) plus the typing +// window; generously padded because setup time varies with pane count. +const LOAD_DURATION_S = Math.ceil((KEY_COUNT * KEY_CADENCE_MS) / 1000) + 90 + +const RESULTS_DIR = path.resolve(__dirname, '..', '..', 'tools', 'benchmarks', 'results') + +type LatencyStats = { + count: number + p50: number + p90: number + p99: number + max: number +} + +type KeySample = { + seq: number + sentAt: number + ptyArrivedAt: number | null + echoSeenAt: number | null +} + +type PacedTypingMeasurement = { + keyCount: number + missingPtyArrivalCount: number + missingEchoCount: number + totalMs: LatencyStats | null + inputHalfMs: LatencyStats | null + echoHalfMs: LatencyStats | null + maxTimerDriftMs: number + samples: KeySample[] +} + +type SchedulerDebugSnapshot = { + queuedChars: number + peakQueuedChars: number + droppedBacklogCount: number +} + +type MainDeliveryDebugSnapshot = { + pendingChars: number + peakPendingChars: number + peakRendererInFlightChars: number + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryDroppedChars: number + pendingDroppedChars: number +} + +type TypingBenchWindow = Window & { + __terminalOutputSchedulerDebug?: { + reset: () => void + snapshot: () => SchedulerDebugSnapshot + } +} + +function latencyStats(samples: number[]): LatencyStats | null { + if (samples.length === 0) { + return null + } + const sorted = [...samples].sort((a, b) => a - b) + const at = (q: number): number => + sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] + return { + count: sorted.length, + p50: at(0.5), + p90: at(0.9), + p99: at(0.99), + max: sorted.at(-1) ?? 0 + } +} + +async function scanRecentKeyMarkerSeqs( + page: Page, + markerPrefix: string +): Promise<{ seqs: number[]; atMs: number }> { + return page.evaluate( + ({ markerPrefix, trailingRows }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const seqs: number[] = [] + if (!pane) { + return { seqs, atMs: Date.now() } + } + // Why trailing rows, not serialize: full-buffer serialization on every + // poll runs on the renderer main thread and would perturb the very + // latency being measured (same rationale as the history-size spec). + const re = new RegExp(`${markerPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)`, 'g') + const buffer = pane.terminal.buffer.active + const start = Math.max(0, buffer.length - trailingRows) + for (let row = start; row < buffer.length; row += 1) { + const line = buffer.getLine(row)?.translateToString(true) ?? '' + let match: RegExpExecArray | null + while ((match = re.exec(line)) !== null) { + seqs.push(Number(match[1])) + } + } + return { seqs, atMs: Date.now() } + }, + { markerPrefix, trailingRows: MARKER_SCAN_TRAILING_ROWS } + ) +} + +function readKeyArrivalSidecar(sidecarPath: string): Map { + const arrivals = new Map() + let raw = '' + try { + raw = readFileSync(sidecarPath, 'utf8') + } catch { + return arrivals + } + for (const line of raw.split('\n')) { + if (!line.trim()) { + continue + } + try { + const entry = JSON.parse(line) as { seq: number; atMs: number } + arrivals.set(entry.seq, entry.atMs) + } catch { + /* torn tail write; final retry pass re-reads */ + } + } + return arrivals +} + +async function measurePacedTyping( + page: Page, + runId: string, + sidecarPath: string +): Promise { + const markerPrefix = typingKeyMarkerPrefix(runId) + await focusActiveTerminalInput(page) + + const timerDrift = await page.evaluateHandle((sampleMs) => { + let maxTimerDriftMs = 0 + let lastTick = performance.now() + const timer = window.setInterval(() => { + const now = performance.now() + maxTimerDriftMs = Math.max(maxTimerDriftMs, now - lastTick - sampleMs) + lastTick = now + }, sampleMs) + return { + stop: () => { + window.clearInterval(timer) + return maxTimerDriftMs + } + } + }, TIMER_SAMPLE_MS) + + // Concurrent echo watcher: records the first time each key's marker is + // visible in the buffer, while typing continues at its own cadence. + const echoSeenAt = new Map() + let watching = true + const echoWatcher = (async () => { + while (watching) { + const { seqs, atMs } = await scanRecentKeyMarkerSeqs(page, markerPrefix) + for (const seq of seqs) { + if (!echoSeenAt.has(seq)) { + echoSeenAt.set(seq, atMs) + } + } + await page.waitForTimeout(10) + } + })() + + const sentAtBySeq = new Map() + try { + for (let index = 0; index < KEY_COUNT; index++) { + const seq = index + 1 + const tickStart = Date.now() + sentAtBySeq.set(seq, tickStart) + await page.keyboard.type(KEY_CHARS[index % KEY_CHARS.length]) + const elapsed = Date.now() - tickStart + if (elapsed < KEY_CADENCE_MS) { + await page.waitForTimeout(KEY_CADENCE_MS - elapsed) + } + } + // Wait out stragglers so a slow echo is measured, not dropped. + const stragglerDeadline = Date.now() + ECHO_STRAGGLER_TIMEOUT_MS + while (echoSeenAt.size < KEY_COUNT && Date.now() < stragglerDeadline) { + await page.waitForTimeout(25) + } + } finally { + watching = false + await echoWatcher + } + const maxTimerDriftMs = await timerDrift.evaluate((watcher) => watcher.stop()) + await timerDrift.dispose() + + // The probe appends arrivals asynchronously; re-read until complete or 5s. + let arrivals = readKeyArrivalSidecar(sidecarPath) + const sidecarDeadline = Date.now() + 5_000 + while (arrivals.size < KEY_COUNT && Date.now() < sidecarDeadline) { + await new Promise((resolve) => setTimeout(resolve, 100)) + arrivals = readKeyArrivalSidecar(sidecarPath) + } + + const samples: KeySample[] = [] + const totalMs: number[] = [] + const inputHalfMs: number[] = [] + const echoHalfMs: number[] = [] + for (let seq = 1; seq <= KEY_COUNT; seq++) { + const sentAt = sentAtBySeq.get(seq) ?? 0 + const ptyArrivedAt = arrivals.get(seq) ?? null + const seenAt = echoSeenAt.get(seq) ?? null + samples.push({ seq, sentAt, ptyArrivedAt, echoSeenAt: seenAt }) + if (ptyArrivedAt !== null) { + inputHalfMs.push(ptyArrivedAt - sentAt) + } + if (seenAt !== null) { + totalMs.push(seenAt - sentAt) + if (ptyArrivedAt !== null) { + echoHalfMs.push(seenAt - ptyArrivedAt) + } + } + } + + return { + keyCount: KEY_COUNT, + missingPtyArrivalCount: KEY_COUNT - arrivals.size, + missingEchoCount: KEY_COUNT - echoSeenAt.size, + totalMs: latencyStats(totalMs), + inputHalfMs: latencyStats(inputHalfMs), + echoHalfMs: latencyStats(echoHalfMs), + maxTimerDriftMs, + samples + } +} + +async function readSchedulerDebug(page: Page): Promise { + return page.evaluate( + () => (window as TypingBenchWindow).__terminalOutputSchedulerDebug?.snapshot() ?? null + ) +} + +async function readMainDeliveryDebug(page: Page): Promise { + return page.evaluate(async () => window.api.pty.getRendererDeliveryDebugSnapshot()) +} + +async function resetDeliveryDebug(page: Page): Promise { + await page.evaluate(async () => { + ;(window as TypingBenchWindow).__terminalOutputSchedulerDebug?.reset() + await window.api.pty.resetRendererDeliveryDebug() + }) +} + +function spawnCpuPressureWorkers(): ChildProcess[] { + const workerPath = path.resolve( + __dirname, + '..', + '..', + 'tools', + 'benchmarks', + 'cpu-pressure-worker.mjs' + ) + return Array.from({ length: CPU_WORKERS }, () => + spawn(process.execPath, [workerPath, String((LOAD_DURATION_S + 120) * 1000)], { + stdio: 'ignore' + }) + ) +} + +function writeBenchReport( + testInfo: TestInfo, + scenario: string, + measurement: PacedTypingMeasurement, + scheduler: SchedulerDebugSnapshot | null, + mainDelivery: MainDeliveryDebugSnapshot | null +): void { + const report = { + benchmark: 'multi-workspace-typing-latency', + label: BENCH_LABEL, + scenario, + timestamp: new Date().toISOString(), + config: { + loadPanes: LOAD_PANES, + loadRateKbps: LOAD_RATE_KBPS, + keyCount: KEY_COUNT, + keyCadenceMs: KEY_CADENCE_MS, + cpuWorkers: CPU_WORKERS + }, + measurement, + scheduler, + mainDelivery + } + mkdirSync(RESULTS_DIR, { recursive: true }) + const stamp = report.timestamp.replace(/[:.]/g, '-') + const outPath = path.join( + RESULTS_DIR, + `multi-workspace-typing-${BENCH_LABEL}-${scenario}-${stamp}.json` + ) + writeFileSync(outPath, JSON.stringify(report, null, 2)) + const fmt = (stats: LatencyStats | null): string => + stats + ? `p50 ${stats.p50.toFixed(1)}ms p90 ${stats.p90.toFixed(1)}ms max ${stats.max.toFixed(1)}ms` + : 'n/a' + testInfo.annotations.push({ + type: `multi-workspace-typing-${scenario}`, + description: + `total ${fmt(measurement.totalMs)} | input-half ${fmt(measurement.inputHalfMs)} | ` + + `echo-half ${fmt(measurement.echoHalfMs)} | drift ${measurement.maxTimerDriftMs.toFixed(1)}ms | ` + + `missingEcho ${measurement.missingEchoCount} | report ${outPath}` + }) + console.log(`[multi-workspace-typing] ${scenario}: ${testInfo.annotations.at(-1)?.description}`) +} + +async function startSustainedLoadInPanes( + page: Page, + panes: TerminalLoadPane[], + scriptPath: string, + runId: string, + readyFileDirectory: string +): Promise { + for (const [index, pane] of panes.entries()) { + await sendToTerminal( + page, + pane.ptyId, + `node ${JSON.stringify(scriptPath)} ${index} ${LOAD_RATE_KBPS} ${LOAD_DURATION_S}\r` + ) + } + // Readiness is signalled via files, not terminal markers: a streaming pane + // scrolls its READY line out of the buffer before sequential checks get to + // it once several panes start together. + const missingReadyPanes = (): number[] => + panes + .map((_, index) => index) + .filter((index) => !existsSync(sustainedLoadReadyFilePath(readyFileDirectory, runId, index))) + await expect + .poll(() => missingReadyPanes().length, { + timeout: 30_000, + message: `load panes never signalled ready: ${missingReadyPanes().join(', ')}` + }) + .toBe(0) +} + +async function startTypingProbe( + page: Page, + typingPtyId: string, + scriptPath: string, + runId: string +): Promise { + await sendToTerminal(page, typingPtyId, `node ${JSON.stringify(scriptPath)}\r`) + await waitForTerminalOutputForPtyId(page, typingPtyId, typingProbeReadyMarker(runId), 15_000) +} + +function removeLoadReadyFiles(directory: string, runId: string, paneCount: number): void { + for (let index = 0; index < paneCount; index++) { + rmSync(sustainedLoadReadyFilePath(directory, runId, index), { force: true }) + } +} + +async function stopPtysQuietly(page: Page, ptyIds: string[]): Promise { + await Promise.all( + ptyIds.map((ptyId) => sendToTerminal(page, ptyId, '\x03').catch(() => undefined)) + ) +} + +test.describe('Multi-workspace sustained typing latency bench', () => { + test.describe.configure({ mode: 'serial' }) + test.setTimeout(10 * 60 * 1000) + + test('baseline: paced typing with no agent load', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.skip(!BENCH_ENABLED, 'Bench-only: run via pnpm bench:multi-workspace-typing') + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const typingPtyId = await waitForActivePanePtyId(orcaPage) + + const runId = randomUUID() + const probePath = path.join(testRepoPath, `.orca-mwt-probe-${runId}.mjs`) + const sidecarPath = path.join(testRepoPath, `.orca-mwt-arrivals-${runId}.jsonl`) + writeTypingEchoProbeScript(probePath, runId, sidecarPath) + try { + await resetDeliveryDebug(orcaPage) + await startTypingProbe(orcaPage, typingPtyId, probePath, runId) + const measurement = await measurePacedTyping(orcaPage, runId, sidecarPath) + writeBenchReport( + testInfo, + 'baseline', + measurement, + await readSchedulerDebug(orcaPage), + await readMainDeliveryDebug(orcaPage) + ) + expect(measurement.missingEchoCount).toBe(0) + expect(measurement.totalMs?.p50 ?? Number.POSITIVE_INFINITY).toBeLessThan(250) + } finally { + await stopPtysQuietly(orcaPage, [typingPtyId]) + rmSync(probePath, { force: true }) + rmSync(sidecarPath, { force: true }) + } + }) + + test('typing under sustained hidden multi-workspace agent load', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.skip(!BENCH_ENABLED, 'Bench-only: run via pnpm bench:multi-workspace-typing') + await waitForSessionReady(orcaPage) + const typingWorktreeId = await waitForActiveWorktree(orcaPage) + const loadWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== typingWorktreeId) + expect(Boolean(loadWorktreeId), 'bench needs the seeded secondary worktree').toBe(true) + if (!loadWorktreeId) { + return + } + + const runId = randomUUID() + const loadPath = path.join(testRepoPath, `.orca-mwt-load-${runId}.mjs`) + const probePath = path.join(testRepoPath, `.orca-mwt-probe-${runId}.mjs`) + const sidecarPath = path.join(testRepoPath, `.orca-mwt-arrivals-${runId}.jsonl`) + writeSustainedAgentLoadScript(loadPath, runId, testRepoPath) + writeTypingEchoProbeScript(probePath, runId, sidecarPath) + + const cpuWorkers = spawnCpuPressureWorkers() + let loadPanes: TerminalLoadPane[] = [] + try { + await switchToWorktree(orcaPage, loadWorktreeId) + loadPanes = await ensureActiveWorktreePaneLoad(orcaPage, LOAD_PANES) + await startSustainedLoadInPanes(orcaPage, loadPanes, loadPath, runId, testRepoPath) + + await switchToWorktree(orcaPage, typingWorktreeId) + await expect + .poll(() => getActiveWorktreeId(orcaPage), { timeout: 10_000 }) + .toBe(typingWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const typingPtyId = await waitForActivePanePtyId(orcaPage) + + await resetDeliveryDebug(orcaPage) + // Load is flowing when the hidden-delivery gate starts dropping the + // background worktree's bytes — the topology the complaint describes. + await expect + .poll( + async () => (await readMainDeliveryDebug(orcaPage))?.hiddenDeliveryDroppedChars ?? 0, + { timeout: 30_000, message: 'hidden load never started flowing' } + ) + .toBeGreaterThan(0) + + await startTypingProbe(orcaPage, typingPtyId, probePath, runId) + const measurement = await measurePacedTyping(orcaPage, runId, sidecarPath) + writeBenchReport( + testInfo, + `hidden-load-${LOAD_PANES}x${LOAD_RATE_KBPS}kbps-cpu${CPU_WORKERS}`, + measurement, + await readSchedulerDebug(orcaPage), + await readMainDeliveryDebug(orcaPage) + ) + // Hang detector only — the JSON report is the benchmark output. A + // reproduced regression shows up as large percentiles, not a hard fail. + expect(measurement.missingEchoCount).toBe(0) + + await stopPtysQuietly(orcaPage, [typingPtyId]) + } finally { + for (const worker of cpuWorkers) { + worker.kill('SIGKILL') + } + await switchToWorktree(orcaPage, loadWorktreeId).catch(() => undefined) + await stopPtysQuietly( + orcaPage, + loadPanes.map((pane) => pane.ptyId) + ) + await switchToWorktree(orcaPage, typingWorktreeId).catch(() => undefined) + rmSync(loadPath, { force: true }) + rmSync(probePath, { force: true }) + rmSync(sidecarPath, { force: true }) + removeLoadReadyFiles(testRepoPath, runId, LOAD_PANES) + } + }) + + test('typing under sustained visible split agent load', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.skip(!BENCH_ENABLED, 'Bench-only: run via pnpm bench:multi-workspace-typing') + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const runId = randomUUID() + const loadPath = path.join(testRepoPath, `.orca-mwt-load-${runId}.mjs`) + const probePath = path.join(testRepoPath, `.orca-mwt-probe-${runId}.mjs`) + const sidecarPath = path.join(testRepoPath, `.orca-mwt-arrivals-${runId}.jsonl`) + writeSustainedAgentLoadScript(loadPath, runId, testRepoPath) + writeTypingEchoProbeScript(probePath, runId, sidecarPath) + + const cpuWorkers = spawnCpuPressureWorkers() + let panes: TerminalLoadPane[] = [] + try { + // Pane 0 types; the rest replay the agent stream side by side — the + // "Claude Code running in a visible split" shape. + panes = await ensureActiveWorktreePaneLoad(orcaPage, 2) + const [typingPane, ...loadPanes] = panes + await startSustainedLoadInPanes(orcaPage, loadPanes, loadPath, runId, testRepoPath) + await focusPane(orcaPage, typingPane.paneKey) + + await resetDeliveryDebug(orcaPage) + await startTypingProbe(orcaPage, typingPane.ptyId, probePath, runId) + const measurement = await measurePacedTyping(orcaPage, runId, sidecarPath) + writeBenchReport( + testInfo, + `visible-split-${LOAD_RATE_KBPS}kbps-cpu${CPU_WORKERS}`, + measurement, + await readSchedulerDebug(orcaPage), + await readMainDeliveryDebug(orcaPage) + ) + expect(measurement.missingEchoCount).toBe(0) + } finally { + for (const worker of cpuWorkers) { + worker.kill('SIGKILL') + } + await stopPtysQuietly( + orcaPage, + panes.map((pane) => pane.ptyId) + ) + rmSync(loadPath, { force: true }) + rmSync(probePath, { force: true }) + rmSync(sidecarPath, { force: true }) + removeLoadReadyFiles(testRepoPath, runId, panes.length) + } + }) +}) diff --git a/tests/e2e/terminal-pane-close-layout-consistency.spec.ts b/tests/e2e/terminal-pane-close-layout-consistency.spec.ts new file mode 100644 index 00000000000..db837c317cb --- /dev/null +++ b/tests/e2e/terminal-pane-close-layout-consistency.spec.ts @@ -0,0 +1,360 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + sendToTerminal, + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +/** + * Repro hunt for the "ghost blank pane" field incident: a split pane whose PTY + * and leaf→PTY binding were torn down while the persisted layout `root` kept + * the leaf, so revisiting the tab materialized a permanently blank pane with + * no terminal behind it. + * + * Field state (workspace remote-issue-2, 2026-07-09): root held 3 leaves, + * ptyIdsByLeafId held 2, no daemon session for the third — the close/exit ran + * near a hidden/park boundary. Each test here closes (or exits) a split pane + * at a different phase of the hidden-view parking lifecycle and asserts the + * invariant that broke in the field: + * + * leaves(persisted root) === keys(persisted ptyIdsByLeafId) === live panes + * + * A failing scenario IS the finding — it pins which boundary loses the layout + * collapse. + */ + +// Why 2000ms: the override shrinks BOTH cold-park delay and hot-retain, and +// the hidden-but-mounted scenario needs the shell exit to land well inside the +// hot-retain window — 500ms let slow shell teardown race past parking and turn +// that scenario into the exits-while-parked one. +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 2_000 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) } +}) + +type ParkingDebugWindow = Window & { + __terminalParkingDebug?: { parkDelayMs?: number } +} + +async function skipUnlessParkingWired(page: Page): Promise { + const deadline = Date.now() + 2_000 + let present = await page.evaluate( + () => (window as ParkingDebugWindow).__terminalParkingDebug !== undefined + ) + while (!present && Date.now() < deadline) { + await page.waitForTimeout(250) + present = await page.evaluate( + () => (window as ParkingDebugWindow).__terminalParkingDebug !== undefined + ) + } + test.skip(!present, 'terminal hidden view parking wiring is not compiled in') +} + +type LayoutConsistency = { + rootLeafIds: string[] + boundLeafIds: string[] + boundPtyIds: string[] + livePaneCount: number | null + hasManager: boolean + domPaneCount: number +} + +async function readLayoutConsistency(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const store = window.__store + if (!store) { + throw new Error('window.__store unavailable') + } + const layout = store.getState().terminalLayoutsByTabId[tabId] + const rootLeafIds: string[] = [] + type LayoutNode = + | { type: 'leaf'; leafId: string } + | { type: 'split'; first: LayoutNode; second: LayoutNode } + const walk = (node: LayoutNode | null | undefined): void => { + if (!node) { + return + } + if (node.type === 'leaf') { + rootLeafIds.push(node.leafId) + return + } + walk(node.first) + walk(node.second) + } + walk((layout?.root ?? null) as LayoutNode | null) + const manager = window.__paneManagers?.get(tabId) + const managerPanes = manager?.getPanes?.() ?? null + const paneElements = managerPanes ? new Set(managerPanes.map((pane) => pane.container)) : null + return { + rootLeafIds, + boundLeafIds: Object.keys(layout?.ptyIdsByLeafId ?? {}), + boundPtyIds: Object.values(layout?.ptyIdsByLeafId ?? {}), + livePaneCount: managerPanes ? managerPanes.length : null, + hasManager: manager !== undefined, + domPaneCount: paneElements + ? Array.from(document.querySelectorAll('.pane[data-leaf-id]')).filter( + (element) => paneElements.has(element) + ).length + : 0 + } + }, tabId) +} + +/** + * The invariant under hunt. Polls so post-close persists can land, then does a + * final full read whose diff names the divergence (stale root leaf vs dropped + * binding vs ghost live pane). + */ +async function expectLayoutConsistent( + page: Page, + tabId: string, + expectedPaneCount: number, + phase: string, + deadPtyId?: string +): Promise { + // Why: polling a shape (not a boolean) makes a timeout print the diverged + // state — which of root/bindings/live panes went stale is the finding. + await expect + .poll( + async () => { + const state = await readLayoutConsistency(page, tabId) + return { + hasManager: state.hasManager, + livePaneCount: state.livePaneCount, + domPaneCount: state.domPaneCount, + rootLeafCount: state.rootLeafIds.length, + boundLeafCount: state.boundLeafIds.length, + unboundRootLeafIds: state.rootLeafIds.filter( + (leafId) => !state.boundLeafIds.includes(leafId) + ), + deadPtyStillBound: deadPtyId ? state.boundPtyIds.includes(deadPtyId) : false + } + }, + { + timeout: 15_000, + message: `[${phase}] layout did not settle to ${expectedPaneCount} consistent pane(s)` + } + ) + .toEqual({ + hasManager: true, + livePaneCount: expectedPaneCount, + domPaneCount: expectedPaneCount, + rootLeafCount: expectedPaneCount, + boundLeafCount: expectedPaneCount, + unboundRootLeafIds: [], + deadPtyStillBound: false + }) +} + +async function closeLastPaneOnTab(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + throw new Error(`closeLastPaneOnTab: no mounted pane manager for tab ${tabId}`) + } + const target = manager.getPanes().at(-1) + if (!target) { + throw new Error('closeLastPaneOnTab: tab has no panes') + } + manager.closePane(target.id) + }, tabId) +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + return tabId +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((targetTabId) => { + const store = window.__store + if (!store) { + throw new Error('activateTerminalTab: window.__store is unavailable') + } + const state = store.getState() + state.setActiveTabType('terminal') + state.setActiveTab(targetTabId) + }, tabId) + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: `terminal tab ${tabId} did not become active` + }) + .toBe(tabId) +} + +// Why: TerminalPane unmount deletes its __paneManagers entry — that absence is +// the observable "parked" signal (same detection as the parking spec). +async function waitForTabParked(page: Page, tabId: string): Promise { + await expect + .poll(async () => page.evaluate((id) => window.__paneManagers?.get(id) !== undefined, tabId), { + timeout: Math.max(20_000, PARKING_DELAY_MS * 10), + message: `terminal tab ${tabId} did not park (pane manager still mounted)` + }) + .toBe(false) +} + +async function waitForTabRemounted(page: Page, tabId: string): Promise { + await expect + .poll(async () => page.evaluate((id) => window.__paneManagers?.get(id) !== undefined, tabId), { + timeout: 15_000, + message: `terminal tab ${tabId} did not remount on reveal` + }) + .toBe(true) +} + +type SplitTabSetup = { + worktreeId: string + tabId: string + splitLeafId: string + splitPtyId: string +} + +// Why: every scenario starts from the field shape — a tab whose main pane got +// a split (the "setup pane" analog) that is fully bound and settled. +async function setUpSplitTab(page: Page): Promise { + await waitForSessionReady(page) + const worktreeId = await waitForActiveWorktree(page) + await skipUnlessParkingWired(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + const tabId = await getActiveTabId(page) + if (!tabId) { + throw new Error('setUpSplitTab: no active terminal tab') + } + await splitActiveTerminalPane(page, 'vertical') + const snapshot = await waitForPaneIdentitySnapshot(page, 2) + const splitPane = snapshot.panes.at(-1) + if (!splitPane?.ptyId) { + throw new Error('setUpSplitTab: split pane did not bind a PTY') + } + return { worktreeId, tabId, splitLeafId: splitPane.leafId, splitPtyId: splitPane.ptyId } +} + +test.describe('terminal pane close vs hidden/park lifecycle keeps layout consistent', () => { + test('control: close while visible', async ({ orcaPage }) => { + const { tabId } = await setUpSplitTab(orcaPage) + await closeLastPaneOnTab(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-visible') + }) + + test('close and hide the tab in the same tick', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await orcaPage.evaluate( + ({ tabId, worktreeId }) => { + const store = window.__store + const manager = window.__paneManagers?.get(tabId) + if (!store || !manager) { + throw new Error('close+hide: store/manager unavailable') + } + const target = manager.getPanes().at(-1) + if (!target) { + throw new Error('close+hide: no split pane') + } + manager.closePane(target.id) + // Hide tab A before any deferred post-close work can run. + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + }, + { tabId, worktreeId } + ) + await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-then-hide-same-tick') + }) + + test('close while hidden but still mounted (hot-retain window)', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + await closeLastPaneOnTab(orcaPage, tabId) + await waitForTabParked(orcaPage, tabId) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-while-hidden-mounted') + }) + + test('close immediately after reveal remount, before panes settle', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabId) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + // Close as soon as the manager exists — panes may still be attaching. + await orcaPage.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const target = manager?.getPanes().at(-1) + if (manager && target) { + manager.closePane(target.id) + } + }, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-mid-reveal') + }) + + test('clean visible close survives a later park/reveal cycle', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await closeLastPaneOnTab(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'pre-park close') + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabId) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'post-park-reveal') + }) + + test('split pane shell exits while hidden but still mounted', async ({ orcaPage }) => { + const { worktreeId, tabId, splitPtyId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + // The setup-script analog: the split's shell ends on its own while the + // tab is hidden-but-mounted. + await sendToTerminal(orcaPage, splitPtyId, 'exit\r') + await orcaPage.waitForTimeout(PARKING_DELAY_MS / 2) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'shell-exit-while-hidden-mounted', splitPtyId) + }) + + test('split pane shell exits while the tab is parked', async ({ orcaPage }) => { + const { worktreeId, tabId, splitPtyId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabId) + await sendToTerminal(orcaPage, splitPtyId, 'exit\r') + await orcaPage.waitForTimeout(PARKING_DELAY_MS) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + // Why: the parked exit is deliberately deferred (no PaneManager to promote + // siblings) — the reveal remount owns the per-leaf teardown. This asserts + // that ownership actually resolves instead of leaving a ghost pane. + await expectLayoutConsistent(orcaPage, tabId, 1, 'shell-exit-while-parked', splitPtyId) + }) +}) diff --git a/tests/e2e/terminal-parked-memory.spec.ts b/tests/e2e/terminal-parked-memory.spec.ts new file mode 100644 index 00000000000..2fb1165abbe --- /dev/null +++ b/tests/e2e/terminal-parked-memory.spec.ts @@ -0,0 +1,358 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +// Why: production cold-park hysteresis is 30s. The fast-park env override is +// scoped to this spec's app launches via orcaAppExtraEnv (same pattern as +// terminal-hidden-view-parking.spec.ts) so it cannot leak into other specs. +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }, + // Why: without this switch Chromium quantizes performance.memory and only + // refreshes it every ~20 minutes, so both scenarios report the same stale + // launch-time bucket instead of a comparable heap figure. + orcaAppExtraArgs: ['--enable-precise-memory-info'] +}) + +// Why: 8 hidden tabs is below the 12-tab hot-retain limit, but that limit +// never retains anything here — the ORCA_E2E_TERMINAL_PARKING_DELAY_MS +// collapse (terminal-parking-e2e-overrides.ts) shrinks hotRetainMs to the +// same delay as coldParkDelayMs, and the policy cold-parks any tab hidden +// past hotRetainMs before the retain-count limit is even consulted. So all 8 +// park without needing 14 tabs or extra policy knobs. +const SCROLLBACK_TAB_COUNT = 8 +const SCROLLBACK_LINE_COUNT = 3000 +const PARK_SETTLE_MS = 2_000 +const HEAP_SAMPLE_COUNT = 5 +const HEAP_SAMPLE_INTERVAL_MS = 250 +// Why: each test launches a fresh app, fills 8 terminals with ~3000 lines of +// scrollback each, then waits out the parking window — well past the default +// 120s per-test budget. +const PARKED_MEMORY_TEST_TIMEOUT_MS = 300_000 + +// Why: mixed-width content (ASCII, CJK wide cells, emoji, box drawing) makes +// each xterm hold realistic narrow+wide buffer rows, so released parked-tab +// memory reflects real agent output rather than uniform filler. +function writeScrollbackFillScript(scriptPath: string, runId: string): void { + const script = [ + `const tabIndex = process.argv[2] ?? '0'`, + `const wide = '統合端末記憶計測'`, + `const emoji = ['🟢', '🟡', '🔵', '🟣']`, + `const lines = []`, + `for (let i = 0; i < ${SCROLLBACK_LINE_COUNT}; i += 1) {`, + ` const ascii = ('tab ' + tabIndex + ' line ' + String(i).padStart(4, '0') + ' ').padEnd(48, 'abcdefghijklmnopqrstuvwxyz')`, + ` const box = '│' + '─'.repeat(8 + (i % 24)) + '│'`, + ` lines.push(ascii + ' ' + wide.repeat(1 + (i % 3)) + ' ' + emoji[i % 4] + ' ' + box)`, + `}`, + `process.stdout.write(lines.join('\\n') + '\\n')`, + `process.stdout.write('PARKED_MEMORY_FILL_DONE_${runId}_' + tabIndex + '\\n')` + ].join('\n') + writeFileSync(scriptPath, `${script}\n`) +} + +// Why: the spec lands ahead of the feature wiring in some merge orders. Skip +// (rather than fail) when the app under test does not expose the parking +// debug handle, mirroring terminal-hidden-view-parking.spec.ts. +async function skipUnlessParkingWired(page: Page): Promise { + const deadline = Date.now() + 2_000 + let present = await page.evaluate(() => window.__terminalParkingDebug !== undefined) + while (!present && Date.now() < deadline) { + await page.waitForTimeout(250) + present = await page.evaluate(() => window.__terminalParkingDebug !== undefined) + } + test.skip( + !present, + 'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)' + ) +} + +type TerminalTabViewState = { + hasManager: boolean + paneCount: number +} + +// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so +// a missing manager is the observable signal that the tab's xterm was parked. +async function readTerminalTabViewState(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return { + hasManager: manager !== undefined, + paneCount: manager?.getPanes?.().length ?? 0 + } + }, tabId) +} + +async function countMountedPaneManagers(page: Page, tabIds: string[]): Promise { + return page.evaluate( + (tabIds) => tabIds.filter((tabId) => window.__paneManagers?.get(tabId) !== undefined).length, + tabIds + ) +} + +async function waitForTabsParked(page: Page, tabIds: string[]): Promise { + await expect + .poll(() => countMountedPaneManagers(page, tabIds), { + timeout: Math.max(30_000, PARKING_DELAY_MS * 10), + message: 'hidden scrollback tabs did not all park (pane managers still mounted)' + }) + .toBe(0) +} + +type ScrollbackTab = { + tabId: string + ptyId: string +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + const snapshot = await waitForPaneIdentitySnapshot(page, 1) + const ptyId = snapshot.panes[0]?.ptyId + if (snapshot.tabId !== tabId || !ptyId) { + throw new Error('createActiveTerminalTab: new tab did not bind a PTY') + } + return { tabId, ptyId } +} + +async function fillActiveTerminalWithScrollback( + page: Page, + ptyId: string, + scriptPath: string, + tabIndex: number, + runId: string +): Promise { + await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)} ${tabIndex}\r`) + await expect + .poll(() => getTerminalContent(page, 4_000), { + timeout: 30_000, + message: `scrollback fill marker for tab ${tabIndex} did not render` + }) + .toContain(`PARKED_MEMORY_FILL_DONE_${runId}_${tabIndex}`) +} + +type ScrollbackTabSetup = { + worktreeId: string + scrollbackTabs: ScrollbackTab[] +} + +// Why: each tab generates its scrollback while visible, so every xterm holds +// the full buffer before going hidden — the hidden-delivery gate never gets a +// chance to drop the output the memory comparison depends on. +async function setUpScrollbackTabs( + page: Page, + scriptPath: string, + runId: string +): Promise { + const worktreeId = await waitForActiveWorktree(page) + await skipUnlessParkingWired(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const baselineSnapshot = await waitForPaneIdentitySnapshot(page, 1) + const baselinePtyId = baselineSnapshot.panes[0]?.ptyId + if (!baselinePtyId) { + throw new Error('parked memory spec: baseline terminal tab did not bind a PTY') + } + + const scrollbackTabs: ScrollbackTab[] = [{ tabId: baselineSnapshot.tabId, ptyId: baselinePtyId }] + await fillActiveTerminalWithScrollback(page, baselinePtyId, scriptPath, 0, runId) + for (let tabIndex = 1; tabIndex < SCROLLBACK_TAB_COUNT; tabIndex += 1) { + const tab = await createActiveTerminalTab(page, worktreeId) + scrollbackTabs.push(tab) + await fillActiveTerminalWithScrollback(page, tab.ptyId, scriptPath, tabIndex, runId) + } + return { worktreeId, scrollbackTabs } +} + +type ParkedMemoryMetrics = { + heapUsedMB: number + liveTerminals: number + livePaneManagers: number +} + +// Why: usedJSHeapSize only drops after a GC, so force one over CDP (best +// effort) and take the min of several settled samples — the min reflects +// retained heap instead of allocation noise between collections. Note xterm +// buffer rows are typed-array backing stores outside the V8 heap, so the +// liveTerminals/livePaneManagers counts are the strong release signal and the +// heap figure tracks only the on-heap share. +async function sampleParkedMemoryMetrics(page: Page): Promise { + await page.waitForTimeout(PARK_SETTLE_MS) + try { + const session = await page.context().newCDPSession(page) + await session.send('HeapProfiler.collectGarbage') + await session.detach() + } catch { + // GC over CDP is a measurement-fidelity improvement, not a gate. + } + + let minHeapBytes: number | null = null + for (let sample = 0; sample < HEAP_SAMPLE_COUNT; sample += 1) { + const heapBytes = await page.evaluate(() => { + const memory = (performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory + return memory?.usedJSHeapSize ?? null + }) + if (heapBytes !== null) { + minHeapBytes = minHeapBytes === null ? heapBytes : Math.min(minHeapBytes, heapBytes) + } + await page.waitForTimeout(HEAP_SAMPLE_INTERVAL_MS) + } + if (minHeapBytes === null) { + throw new Error('sampleParkedMemoryMetrics: performance.memory.usedJSHeapSize is unavailable') + } + + const liveCounts = await page.evaluate(() => ({ + liveTerminals: document.querySelectorAll('.xterm').length, + livePaneManagers: window.__paneManagers?.size ?? 0 + })) + return { heapUsedMB: minHeapBytes / (1024 * 1024), ...liveCounts } +} + +function formatParkedMemoryAnnotation(metrics: ParkedMemoryMetrics, parkedTabs: number): string { + return [ + `panes=${SCROLLBACK_TAB_COUNT}`, + `parkedTabs=${parkedTabs}`, + `heapUsedMB=${metrics.heapUsedMB.toFixed(1)}`, + `liveTerminals=${metrics.liveTerminals}`, + `livePaneManagers=${metrics.livePaneManagers}` + ].join(' ') +} + +test.describe('Terminal parked memory', () => { + test('releases renderer terminal memory when hidden tabs park', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS) + await waitForSessionReady(orcaPage) + + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`) + writeScrollbackFillScript(scriptPath, runId) + try { + const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId) + + // A fresh 9th tab hides all 8 scrollback tabs. + const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabsParked( + orcaPage, + scrollbackTabs.map((tab) => tab.tabId) + ) + + const metrics = await sampleParkedMemoryMetrics(orcaPage) + testInfo.annotations.push({ + type: 'opencode-parked-memory', + description: formatParkedMemoryAnnotation(metrics, scrollbackTabs.length) + }) + + // Structural assertions: all 8 parked (managers gone), and the only + // live xterm/pane manager belongs to the visible tab. + for (const tab of scrollbackTabs) { + expect((await readTerminalTabViewState(orcaPage, tab.tabId)).hasManager).toBe(false) + } + const visibleState = await readTerminalTabViewState(orcaPage, visibleTab.tabId) + expect(visibleState.hasManager).toBe(true) + expect(visibleState.paneCount).toBeGreaterThan(0) + // Why: design invariant 5 — renderer terminal views scale with visible + // panes, so parked tabs must leave no xterm DOM behind. + expect(metrics.liveTerminals).toBe(visibleState.paneCount) + expect(metrics.livePaneManagers).toBe(1) + } finally { + rmSync(scriptPath, { force: true }) + } + }) + + test('retains terminal views when parking is disabled', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS) + await waitForSessionReady(orcaPage) + + // Why: settings.terminalHiddenViewParking === false is the design-doc + // kill switch. updateSettings persists it through window.api.settings.set + // and updates the store slice the cold-park hook subscribes to — the same + // mutation path dead-terminal-repro.spec.ts uses, so no extra launch-env + // wiring is needed. + await orcaPage.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('parked memory spec: window.__store is unavailable') + } + await store.getState().updateSettings({ terminalHiddenViewParking: false }) + }) + await expect + .poll( + () => + orcaPage.evaluate(() => window.__store?.getState().settings?.terminalHiddenViewParking), + { timeout: 5_000, message: 'terminalHiddenViewParking kill switch did not persist' } + ) + .toBe(false) + + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`) + writeScrollbackFillScript(scriptPath, runId) + try { + const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId) + const scrollbackTabIds = scrollbackTabs.map((tab) => tab.tabId) + + const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId) + // Why: with parking enabled these tabs park within ~1x the collapsed + // delay (the first test proves the machinery in this app build), so + // surviving 3x the delay shows the kill switch held. + await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) + expect(await countMountedPaneManagers(orcaPage, scrollbackTabIds)).toBe(SCROLLBACK_TAB_COUNT) + + const metrics = await sampleParkedMemoryMetrics(orcaPage) + testInfo.annotations.push({ + type: 'opencode-parked-memory-disabled', + description: formatParkedMemoryAnnotation(metrics, 0) + }) + + // Structural assertions: every hidden tab keeps its pane manager and + // xterm; nothing parked even after the settle + sampling window. + for (const tab of scrollbackTabs) { + const state = await readTerminalTabViewState(orcaPage, tab.tabId) + expect(state.hasManager).toBe(true) + expect(state.paneCount).toBeGreaterThan(0) + } + expect((await readTerminalTabViewState(orcaPage, visibleTab.tabId)).hasManager).toBe(true) + expect(metrics.livePaneManagers).toBe(SCROLLBACK_TAB_COUNT + 1) + expect(metrics.liveTerminals).toBe(SCROLLBACK_TAB_COUNT + 1) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +}) diff --git a/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts b/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts new file mode 100644 index 00000000000..6a57a4a3506 --- /dev/null +++ b/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts @@ -0,0 +1,106 @@ +/** + * Repro + recovery for the dead-push-delivery wedge (field snapshot, + * v1.4.121-rc.0, 2026-07-06): every `pty:data` push event vanishes before the + * renderer processes it while invoke IPC stays healthy — terminals go + * literally blank (bytes sent, never consumed, never ACKed) and previously + * only a renderer reload recovered. + * + * The `__terminalDeliveryWatchdog.blackhole` hook drops incoming pty:data at + * the dispatcher exactly as the field failure does (no receive count, no ACK, + * no handler). The watchdog must then confirm the wedge over invoke, write off + * the lost bytes in main, and repaint the pane from the main-owned buffer + * snapshot — all WITHOUT the push channel and WITHOUT a reload. The wedged + * output becomes visible while the blackhole is still engaged: that is the + * pull-recovery proof. + * + * Timing: the watchdog runs at 500ms ticks here, but main refuses a write-off + * until it has seen 10s of ACK silence (PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS, + * a deliberate prod constant) — so recovery lands at ~11-13s and the polls + * below allow 30s. + */ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' +import { + waitForActiveTerminalManager, + waitForActivePanePtyId, + execInTerminal, + getTerminalContent +} from './helpers/terminal' + +type DeliveryWatchdogWindow = Window & { + __terminalDeliveryWatchdog?: { + blackhole: (on: boolean) => void + configure: (config: { + intervalMs?: number + stallTicksToHeal?: number + healCooldownMs?: number + }) => void + snapshot: () => { + receivedPtyDataEventCount: number + stallStreakTicks: number + healCount: number + blackholed: boolean + } + } +} + +test.describe('terminal push-delivery loss recovery', () => { + test.afterEach(async ({ orcaPage }) => { + await orcaPage.evaluate(() => { + ;(window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.blackhole(false) + }) + }) + + test('watchdog repaints wedged terminals from the main buffer without push delivery or reload', async ({ + orcaPage + }) => { + test.setTimeout(120_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + + // Live baseline: push delivery works. The $((…)) arithmetic keeps the + // asserted string out of the typed command's local echo. + await execInTerminal(orcaPage, ptyId, 'echo live-before-$((41+1))') + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 15_000 }) + .toContain('live-before-42') + + // Engage the field wedge and speed the watchdog up for CI. + await orcaPage.evaluate(() => { + const watchdog = (window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog + if (!watchdog) { + throw new Error('delivery watchdog e2e hook missing — exposeStore build?') + } + watchdog.configure({ intervalMs: 500, healCooldownMs: 3_000 }) + watchdog.blackhole(true) + }) + + await execInTerminal(orcaPage, ptyId, 'echo wedged-$((100+23))') + + // The wedge repro itself: output is swallowed, pane stays stale. + await orcaPage.waitForTimeout(1_500) + expect(await getTerminalContent(orcaPage)).not.toContain('wedged-123') + + // Pull-recovery proof: while the push channel is still dead, the healed + // pane repaints from the main-owned snapshot and shows the wedged output. + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 30_000 }) + .toContain('wedged-123') + const healSnapshot = await orcaPage.evaluate( + () => (window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.snapshot() ?? null + ) + expect(healSnapshot?.healCount ?? 0).toBeGreaterThan(0) + + // Channel restored: live output flows again with no reload in between. + await orcaPage.evaluate(() => { + ;(window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.blackhole(false) + }) + await execInTerminal(orcaPage, ptyId, 'echo live-after-$((200+56))') + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 15_000 }) + .toContain('live-after-256') + }) +}) diff --git a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts index 238bd4f9d26..3f4d4f17dae 100644 --- a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts @@ -180,7 +180,10 @@ function rawEmojiFixtureFrameTailMarker(runId: string): string { } async function setWideRenderedTableViewport(page: Page): Promise { - await page.setViewportSize({ width: 1480, height: 820 }) + const isWindows = await page.evaluate(() => navigator.userAgent.includes('Windows')) + // Why: macOS hosted runners need extra room for font/column variance, while + // Windows Electron golden rendering is stable at the native-sized viewport. + await page.setViewportSize({ width: isWindows ? 1480 : 1760, height: 820 }) await page.waitForTimeout(250) await page.evaluate(() => { const store = window.__store @@ -556,6 +559,9 @@ test.describe('Terminal raw emoji table scroll restore repro', () => { await switchToWorktree(orcaPage, secondWorktreeId) await waitForActiveTerminalManager(orcaPage, 30_000) await orcaPage.waitForTimeout(1_000) + // Why: switching back can replay hidden terminal contents immediately; + // make the viewport wide before restore so the table cannot wrap first. + await setWideRenderedTableViewport(orcaPage) await switchToWorktree(orcaPage, firstWorktreeId) // Why: activating another worktree can restore the right sidebar. This // golden is about terminal renderer restore at a deliberately wide width. diff --git a/tests/e2e/terminal-sleep-wake-restore.spec.ts b/tests/e2e/terminal-sleep-wake-restore.spec.ts new file mode 100644 index 00000000000..83a924a1099 --- /dev/null +++ b/tests/e2e/terminal-sleep-wake-restore.spec.ts @@ -0,0 +1,220 @@ +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +type SleepWakeTerminalDebug = { + activeTabId: string | null + activeWorktreeId: string | null + tabs: { + id: string + ptyId?: string + generation?: number + pendingActivationSpawn?: boolean | number + }[] + ptyIdsByTabId: Record + ptyIdsByLeafIdByTabId: Record> +} + +async function sleepWorktreeTerminals(page: Page, worktreeId: string): Promise { + await page.evaluate(async (id) => { + const store = window.__store + if (!store) { + throw new Error('store unavailable') + } + const state = store.getState() + await state.shutdownWorktreeBrowsers(id) + await state.shutdownWorktreeTerminals(id, { keepIdentifiers: true }) + }, worktreeId) +} + +async function readLivePtyCountForWorktree(page: Page, worktreeId: string): Promise { + return page.evaluate((id) => { + const store = window.__store + if (!store) { + return 0 + } + const state = store.getState() + const tabs = state.tabsByWorktree[id] ?? [] + return tabs.reduce((count, tab) => count + (state.ptyIdsByTabId[tab.id]?.length ?? 0), 0) + }, worktreeId) +} + +async function readSleepWakeTerminalDebug( + page: Page, + worktreeId: string +): Promise { + return page.evaluate((id) => { + const store = window.__store + if (!store) { + return { + activeTabId: null, + activeWorktreeId: null, + tabs: [], + ptyIdsByTabId: {}, + ptyIdsByLeafIdByTabId: {} + } + } + const state = store.getState() + const tabs = state.tabsByWorktree[id] ?? [] + return { + activeTabId: state.activeTabId, + activeWorktreeId: state.activeWorktreeId, + tabs: tabs.map((tab) => ({ + id: tab.id, + ptyId: tab.ptyId, + generation: tab.generation, + pendingActivationSpawn: tab.pendingActivationSpawn + })), + ptyIdsByTabId: Object.fromEntries( + tabs.map((tab) => [tab.id, state.ptyIdsByTabId[tab.id] ?? []]) + ), + ptyIdsByLeafIdByTabId: Object.fromEntries( + tabs.map((tab) => [tab.id, state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}]) + ) + } + }, worktreeId) +} + +async function mainSnapshotContains(page: Page, ptyId: string, text: string): Promise { + return page.evaluate( + async ({ targetPtyId, expectedText }) => { + const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, { + scrollbackRows: 200 + }) + return snapshot?.data.includes(expectedText) ?? false + }, + { targetPtyId: ptyId, expectedText: text } + ) +} + +function richSleepWakePayload(runId: string): string { + const shortId = runId.slice(0, 8) + return [ + '\x1b[?2026h', + '\x1b[2J\x1b[H', + '╭────────────────────────────────────────────╮', + `│ sleep wake restore ${shortId} 😀 │`, + '├────────────┬───────────────┬───────────────┤', + '│ agent │ status │ output │', + '├────────────┼───────────────┼───────────────┤', + `│ codex-${shortId.slice(0, 4)} │ thinking │ box/table ok │`, + '│ opencode │ streaming │ unicode ✓ │', + '│ shell │ idle │ prompt ready │', + '╰────────────┴───────────────┴───────────────╯', + `SLEEP_WAKE_RESTORE_${runId}`, + `SLEEP_WAKE_TABLE_${runId}`, + '\x1b[?2026l' + ].join('\r\n') +} + +function sleepWakeExpectedMarkers(runId: string): string[] { + return [ + `SLEEP_WAKE_RESTORE_${runId}`, + `SLEEP_WAKE_TABLE_${runId}`, + 'box/table ok', + 'unicode ✓', + 'prompt ready' + ] +} + +function writeSleepWakePayloadScript(scriptPath: string, payload: string): void { + const encodedPayload = Buffer.from(payload, 'utf8').toString('base64') + writeFileSync( + scriptPath, + `process.stdout.write(Buffer.from(${JSON.stringify(encodedPayload)}, 'base64').toString('utf8'))\n`, + 'utf8' + ) +} + +test.describe('Terminal sleep wake restore', () => { + test('restores slept terminal output and accepts fresh input after wake', async ({ + orcaPage, + testRepoPath + }) => { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'sleep wake restore needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + const runId = randomUUID() + const restoreMarker = `SLEEP_WAKE_RESTORE_${runId}` + const freshMarker = `SLEEP_WAKE_FRESH_${runId}` + const expectedMarkers = sleepWakeExpectedMarkers(runId) + const scriptPath = path.join(testRepoPath, `.orca-sleep-wake-restore-${runId}.mjs`) + writeSleepWakePayloadScript(scriptPath, richSleepWakePayload(runId)) + try { + await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`) + await waitForTerminalOutput(orcaPage, restoreMarker, 10_000, 20_000) + const beforeSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + for (const marker of expectedMarkers) { + expect(await mainSnapshotContains(orcaPage, ptyId, marker)).toBe(true) + } + + await switchToWorktree(orcaPage, firstWorktreeId) + await sleepWorktreeTerminals(orcaPage, secondWorktreeId) + const afterSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + await expect + .poll(() => readLivePtyCountForWorktree(orcaPage, secondWorktreeId), { + timeout: 10_000, + message: 'sleep did not release live PTYs for the background worktree' + }) + .toBe(0) + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const awakePtyId = await waitForActivePanePtyId(orcaPage) + const afterWakeDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + const awakeTerminalContent = await getTerminalContent(orcaPage, 20_000) + for (const marker of expectedMarkers) { + expect + .soft(awakeTerminalContent.includes(marker), { + message: JSON.stringify( + { + missingMarker: marker, + ptyId, + awakePtyId, + beforeSleepDebug, + afterSleepDebug, + afterWakeDebug, + terminalTail: awakeTerminalContent.slice(-2000) + }, + null, + 2 + ) + }) + .toBe(true) + } + await waitForTerminalOutput(orcaPage, restoreMarker, 15_000, 20_000) + await sendToTerminal(orcaPage, awakePtyId, `printf '\\n${freshMarker}\\n'\r`) + await waitForTerminalOutput(orcaPage, freshMarker, 10_000, 20_000) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +}) diff --git a/tests/e2e/terminal-stuck-occlusion-recovery.spec.ts b/tests/e2e/terminal-stuck-occlusion-recovery.spec.ts new file mode 100644 index 00000000000..6545264fee3 --- /dev/null +++ b/tests/e2e/terminal-stuck-occlusion-recovery.spec.ts @@ -0,0 +1,147 @@ +/** + * Repro + recovery for the stuck-occlusion freeze (field snapshot, + * v1.4.124-rc.2.perf, 2026-07-06): macOS occlusion tracking wedges + * document.visibilityState at 'hidden' after display sleep and never fires + * another visibilitychange. The hidden-delivery gate then marks the pane the + * user is looking at as hidden, and main drops its renderer-bound bytes + * indefinitely (78MB dropped across 2 visible ptys in the field) — a frozen + * terminal with a perfectly healthy transport. + * + * The test emulates the wedge exactly as Chromium produces it: visibilityState + * pinned to 'hidden' with one final visibilitychange, then silence. Recovery + * must come from the staleness proof — a real keystroke while the document + * claims hidden — which unlatches the gate and repaints the missed output from + * the main-owned snapshot, WITHOUT a reload and WITHOUT any visibilitychange. + */ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' +import { + waitForActiveTerminalManager, + waitForActivePanePtyId, + execInTerminal, + getTerminalContent +} from './helpers/terminal' + +type DeliverySnapshot = { + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryDroppedChars: number +} + +async function getDeliverySnapshot(page: Page): Promise { + return page.evaluate(async () => { + const snapshot = await window.api.pty.getRendererDeliveryDebugSnapshot() + return { + hiddenDeliveryGatedPtyCount: snapshot.hiddenDeliveryGatedPtyCount, + hiddenDeliveryGatedVisiblePtyCount: snapshot.hiddenDeliveryGatedVisiblePtyCount, + hiddenDeliveryDroppedChars: snapshot.hiddenDeliveryDroppedChars + } + }) +} + +test.describe('terminal stuck-occlusion recovery', () => { + test.afterEach(async ({ orcaPage }) => { + // Drop the instance shadow so the prototype getter (real state) rules + // again, and fire one genuine visibilitychange to restore tracker trust. + await orcaPage.evaluate(() => { + delete (document as { visibilityState?: string }).visibilityState + document.dispatchEvent(new Event('visibilitychange')) + }) + }) + + test('a keystroke unlatches the hidden-delivery gate wedged by stale visibilityState', async ({ + orcaPage + }) => { + test.setTimeout(120_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + + // Live baseline: foreground delivery works. The $((…)) arithmetic keeps + // the asserted string out of the typed command's local echo. + await execInTerminal(orcaPage, ptyId, 'echo live-before-$((41+1))') + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 15_000 }) + .toContain('live-before-42') + + // Emulate the Chromium occlusion wedge: visibilityState pins at 'hidden', + // one last visibilitychange fires, then the tracker goes silent forever. + await orcaPage.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { + get: () => 'hidden', + configurable: true + }) + document.dispatchEvent(new Event('visibilitychange')) + }) + + // The visible pane's pty gets marked hidden in main — the field state: + // gate holding a pty that main's own visibility set says is visible. + await expect + .poll(async () => (await getDeliverySnapshot(orcaPage)).hiddenDeliveryGatedPtyCount, { + timeout: 15_000 + }) + .toBeGreaterThan(0) + expect( + (await getDeliverySnapshot(orcaPage)).hiddenDeliveryGatedVisiblePtyCount + ).toBeGreaterThan(0) + + // The freeze repro: output produced now is dropped by main, not painted. + const droppedBefore = (await getDeliverySnapshot(orcaPage)).hiddenDeliveryDroppedChars + await execInTerminal(orcaPage, ptyId, 'echo occluded-$((70+8))') + await expect + .poll(async () => (await getDeliverySnapshot(orcaPage)).hiddenDeliveryDroppedChars, { + timeout: 15_000 + }) + .toBeGreaterThan(droppedBefore) + expect(await getTerminalContent(orcaPage)).not.toContain('occluded-78') + + // The staleness proof: one real keystroke while the document claims + // hidden. No visibilitychange fires — recovery must ride the proof alone. + await orcaPage.keyboard.press('Shift') + + // Gate unlatches and the missed output repaints from the main-owned + // snapshot — no reload, visibilityState still reads 'hidden'. + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 30_000 }) + .toContain('occluded-78') + await expect + .poll(async () => (await getDeliverySnapshot(orcaPage)).hiddenDeliveryGatedVisiblePtyCount, { + timeout: 15_000 + }) + .toBe(0) + + // Live delivery continues under the override. + await execInTerminal(orcaPage, ptyId, 'echo live-after-$((200+56))') + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 15_000 }) + .toContain('live-after-256') + + // The one-paste freeze report is prod-reachable and carries the episode's + // history: the stale-visibility latch and gate transitions must be in the + // renderer breadcrumbs, and main's per-pty table must be populated. + const report = await orcaPage.evaluate(() => + ( + window as Window & { + __orcaTerminalFreezeReport?: () => Promise<{ + renderer: { breadcrumbs: { kind: string }[]; documentVisibilityProvenStale: boolean } + main: { diagnostics: { perPty: unknown[]; breadcrumbs: { kind: string }[] } } + }> + } + ).__orcaTerminalFreezeReport?.() + ) + if (!report) { + throw new Error('freeze report global missing from prod-path renderer') + } + expect(report.renderer.documentVisibilityProvenStale).toBe(true) + const rendererKinds = report.renderer.breadcrumbs.map((crumb) => crumb.kind) + expect(rendererKinds).toContain('stale-visibility-latch') + expect(rendererKinds).toContain('renderer-gate-unmark') + expect(report.main.diagnostics.perPty.length).toBeGreaterThan(0) + const mainKinds = report.main.diagnostics.breadcrumbs.map((crumb) => crumb.kind) + expect(mainKinds).toContain('gate-mark') + expect(mainKinds).toContain('gate-unmark') + }) +}) diff --git a/tools/benchmarks/cpu-pressure-worker.mjs b/tools/benchmarks/cpu-pressure-worker.mjs new file mode 100644 index 00000000000..a0579be6dbd --- /dev/null +++ b/tools/benchmarks/cpu-pressure-worker.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +/** + * Busy-spins one CPU core to emulate a loaded machine during latency benches + * (the "every workspace is running an agent and the whole box is hot" case). + * Spawn N of these to occupy N cores; kill to release. + * + * Usage: node cpu-pressure-worker.mjs [maxDurationMs] + * The duration failsafe (default 10 min) prevents orphaned spinners if the + * spawning bench dies without cleanup. + */ +const maxDurationMs = Number(process.argv[2]) > 0 ? Number(process.argv[2]) : 10 * 60 * 1000 +const deadline = Date.now() + maxDurationMs + +let x = 1 +while (Date.now() < deadline) { + // Hot integer loop between deadline checks; no yields, no allocation. + for (let i = 0; i < 5_000_000; i++) { + x = (x * 31 + 7) % 1000003 + } +} +process.exit(0) diff --git a/tools/benchmarks/terminal-headless-parse-bench.mjs b/tools/benchmarks/terminal-headless-parse-bench.mjs new file mode 100644 index 00000000000..694593cfb3c --- /dev/null +++ b/tools/benchmarks/terminal-headless-parse-bench.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/** + * Decomposes cross-terminal pipeline results: feeds the same fixtures from + * terminal-pipeline-bench through a bare @xterm/headless Terminal — no Orca + * layers, no IPC, no rendering — to locate where throughput is lost. + * + * If headless xterm parses a fixture near the plain-text rate, the pipeline + * gap for that fixture lives in Orca's layers (delivery, side-effect + * scanning, renderer paint). If headless collapses too, the cost is intrinsic + * to xterm.js's parser/buffer for that byte pattern. + * + * Usage: + * node tools/benchmarks/terminal-headless-parse-bench.mjs + * [--size-mb 10] [--cols 114] [--rows 85] [--scrollback 5000] + */ +import { performance } from 'node:perf_hooks' +import xterm from '@xterm/headless' +import { buildFixture } from './terminal-pipeline-bench.mjs' + +const { Terminal } = xterm +const CHUNK = 64 * 1024 + +function arg(name, fallback) { + const i = process.argv.indexOf(name) + return i === -1 ? fallback : Number(process.argv[i + 1]) +} + +const sizeMb = arg('--size-mb', 10) +const cols = arg('--cols', 114) +const rows = arg('--rows', 85) +const scrollback = arg('--scrollback', 5000) +const targetBytes = Math.floor(sizeMb * 1024 * 1024) + +function writeAll(term, data) { + return new Promise((resolve) => { + let offset = 0 + const next = () => { + if (offset >= data.length) { + resolve() + return + } + const chunk = data.slice(offset, offset + CHUNK) + offset += CHUNK + // write callback fires after the chunk is parsed — same "fully parsed" + // fence semantics as the DSR fence in the pipeline bench. + term.write(chunk, next) + } + next() + }) +} + +const FIXTURES = ['ascii-log', 'cjk-emoji', 'agent-tui', 'styles-stress'] + +console.log( + `headless xterm ${cols}x${rows} scrollback=${scrollback}, ${sizeMb}MB per fixture (parse-only, no render)` +) +for (const name of FIXTURES) { + const fixture = buildFixture(name, targetBytes, cols, rows) + const bytes = Buffer.byteLength(fixture, 'utf8') + const term = new Terminal({ cols, rows, scrollback, allowProposedApi: true }) + // Warmup primes JIT so the first fixture isn't penalized. + await writeAll(term, fixture.slice(0, 256 * 1024)) + const start = performance.now() + await writeAll(term, fixture) + const ms = performance.now() - start + console.log( + `${name.padEnd(15)} ${(bytes / 1024 / 1024 / (ms / 1000)).toFixed(1).padStart(7)} MB/s (${ms.toFixed(0)}ms)` + ) + term.dispose() +} diff --git a/tools/benchmarks/terminal-pipeline-bench.mjs b/tools/benchmarks/terminal-pipeline-bench.mjs new file mode 100644 index 00000000000..9d013dcc3ca --- /dev/null +++ b/tools/benchmarks/terminal-pipeline-bench.mjs @@ -0,0 +1,569 @@ +#!/usr/bin/env node +/** + * Cross-terminal pipeline benchmark. Run INSIDE the terminal under test + * (Orca pane, iTerm2, Ghostty, Terminal.app, VS Code, ...) — it measures the + * full byte path of whatever terminal hosts it: PTY -> (daemon/ptyHost -> + * IPC ->) parser -> response. + * + * Metrics per run: + * 1. dsr-idle — DSR (ESC[6n) round-trip latency at rest. The reply is + * produced only after the terminal's parser reaches the + * query, so this tracks the input/echo pipeline without + * needing OS-level keystroke injection. + * 2. throughput — wall time to stream each fixture, ended by a DSR + * fence. The fence matters: xterm.js-class terminals + * ingest at wire speed into an internal queue and parse + * later, so socket drain alone would flatter them. + * 3. dsr-under-load — DSR latency sampled while a paced (default 1 MB/s) + * agent-TUI stream plays. This is the "typing while an + * agent floods output" complaint, quantified. + * + * Usage (run in EACH terminal being compared, same machine, on AC power): + * node tools/benchmarks/terminal-pipeline-bench.mjs --label m2max-2026-07-02 + * [--size-mb 10] [--iterations 5] [--dsr-count 200] [--skip-load] + * [--fixtures ascii-log,cjk-emoji,agent-tui,styles-stress] + * + * Aggregate results from all terminals into one comparison table: + * node tools/benchmarks/terminal-pipeline-bench.mjs report [--label ] + * + * Protocol notes: keep hands off the keyboard during a run (stdin is parsed + * for DSR replies), use a comparable window size everywhere, avoid tmux/screen + * (they proxy the queries and would be the thing measured). `styles-stress` is + * deliberately pathological (every cell restyled); read it as a ceiling probe, + * not a realistic workload. Complementary manual metric: Typometer for true + * keypress->pixel latency — this probe stops at the parser reply. + * Results: tools/benchmarks/results/terminal-pipeline-