* fix(startup): stop a duplicate headless serve from crash-looping and leaking AppImage mounts
A second Orca launch that loses the single-instance lock called app.quit()
before `ready`. That quit is deferred, so the doomed process kept booting into
Chromium's Linux display initialization, failed with "Missing X server or
$DISPLAY", and died with SIGSEGV. systemd read that as a crash and restarted it
forever; each restart re-mounted the AppImage and left the squashfuse mount
behind, until the host hit the 1000-mount FUSE ceiling and every later launch
failed.
The lock-losing launch now calls app.exit(3), which terminates synchronously
before any display init. Exit code 3 is a stable "another process already owns
this userData profile" contract, and the documented systemd unit uses
RestartPreventExitStatus=3 plus a real StartLimitIntervalSec/StartLimitBurst
window so a permanently failing launch can no longer retry unbounded.
Second-instance argv is now forwarded to the owner, and a duplicate `orca serve`
no longer asks the live headless server to open a desktop window. Desktop
activation for ordinary launches and macOS dock re-activation is unchanged.
Closes#11935
* docs(headless): clear the start limit before the scripted service starts
StartLimitIntervalSec=300/StartLimitBurst=5 rate-limits operator starts too, so
after a crash-loop trips the burst systemd refuses a plain `systemctl start` for
the rest of the window. The Upgrade and Roll back scripts run under
`set -euo pipefail`, so that refusal aborted the rollback mid-flight and left the
server down on the exact recovery path the doc prescribes.
Both scripts (and their EXIT-trap recoveries) now run `systemctl reset-failed`
first, the unit reference explains the interaction, and the crash-loop bullet
points at it for manual starts.
Co-authored-by: Orca <help@stably.ai>
* test(startup): reproduce the #11935 duplicate-serve crash loop under real Electron
The committed coverage for #11935 was source-text greps, so nothing gated the
mechanism the fix rests on: pre-`ready` `app.quit()` is deferred, which is why
the lock-losing headless `orca serve` kept booting into Linux display init.
This runs two real Electron processes against one disposable profile. The
duplicate executes the lock-loss gate's own `app.*` statement, lifted out of
`src/main/index.ts`, so reverting to `app.quit()` fails the test. It also feeds
the owner's real forwarded argv through `shouldActivateDesktopForSecondInstance`.
Also record why the activation predicate matches `--serve` and not the `serve`
subcommand: an AppImage launched as `orca serve` exits at the CLI redirect
before requesting the lock.
* test(startup): wait for the owner process to exit before removing its profile
Windows holds the profile's handles for a beat after SIGKILL, so an immediate
rmSync can fail with EBUSY/EPERM.
Co-authored-by: Orca <help@stably.ai>
* test(startup): pass the fixture marker path by env, not argv
Chromium reorders argv and the duplicate's argv is itself under test, so a
trailing positional was the wrong channel for it.
Co-authored-by: Orca <help@stably.ai>
* test(startup): only the activation case waits on the owner notification
The exit-contract cases assert on the duplicate's own already-terminated
process, so they should not block on cross-process delivery.
Co-authored-by: Orca <help@stably.ai>
* test(startup): drop the staged lock race, keep the real-Electron gate contract
CI proved the two-process form cannot work on a display-less Linux runner:
Chromium's ProcessSingleton needs the browser IO thread, which needs `ready`,
which needs a display. The pre-`ready` owner looked stale and the duplicate took
the lock (`expected [ 'DUPLICATE_WON_LOCK' ] to include 'DUPLICATE_LOST_LOCK'`).
Lock acquisition and argv forwarding are already covered in
single-instance-lock.test.ts. What only a real process can settle is what the
loser does next, so that is all this file now runs -- display-independent.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(worktrees): stop silently switching existing Windows setup scripts to Git Bash
#6967 derived the Windows setup-runner shell from `terminalWindowsShell`. On
upgrade, any Windows user whose terminal preference resolved to Git Bash had
their existing `orca.yaml` setup script (and issue command) handed to bash
instead of cmd.exe. Scripts authored against the cmd runner — `copy`, `xcopy`,
`set VAR=value`, `if errorlevel 1`, `%VAR%`, backslash paths — broke with no
migration and no warning, and the failure looked like Orca broke the project.
The conflation is also wrong in the steady state: a terminal preference is
per-user, so two people on the same repo got different interpreters for the
same orca.yaml and no project could write a setup script that worked for all
of its Windows contributors.
The interpreter is now a property of the script, declared the standard way:
a leading `#!` line. Native Windows keeps the historical `.cmd` runner unless
the script declares a POSIX shell, so no existing script changes behavior.
`resolveSetupRunnerShell` keeps its role as the feasibility gate — a bash
runner still requires the terminal to resolve to Git Bash, since the launch
command is typed into that shell and uses MSYS `/c/...` paths.
`buildWindowsRunnerScript` now drops a leading `#!` line rather than `call`ing
it, so a declared-bash script that falls back to cmd (Git Bash missing) fails
on a real setup line instead of aborting on errorlevel at line one.
WSL worktrees, POSIX platforms, and SSH hosts are untouched.
* fix(worktrees): keep the cmd setup runner launchable from a Git Bash pane
Adversarial review of this PR found that pinning the runner format per script
reopened issue #6896 one layer down.
- `WorktreeSetupLaunch.shell` had been redefined to mean "the format the runner
file was written in". `resolveSetupRunnerCommand` consumes it as "the shell
that types the launch command", so a Git Bash terminal with a batch setup
script produced `cmd.exe /c "C:\...\setup-runner.cmd"` typed into a bash pane,
where MSYS rewrites the `/c` switch into a drive path: cmd opens interactively
and setup never runs. `shell` is the terminal's family again; the runner file's
.cmd/.sh extension carries the format, and a batch runner launched from a POSIX
pane reuses the existing PowerShell ProcessStartInfo launcher.
- The cmd runner dropped a leading `#!` line and ran the rest as batch, so a bash
script reaching cmd (PowerShell/cmd terminal, or any SSH-to-Windows host) got
its interpreter-agnostic prefix executed before failing mid-way. It now prints
why and exits 1 without running anything.
- A `#!` line's option flags were discarded: `#!/usr/bin/env -S bash -euo
pipefail` lost pipefail because the runner is launched as `bash <path>`. The
generated posix runner now replays declared flags via `set` and drops the
duplicate interpreter line.
- Docs cover the per-user setup command in repository hook settings, which goes
through the same `#!` rule, and describe what the `#!` line does and does not
select.
Tests: composed launch command for a POSIX pane + cmd runner (hooks, shared
runner command, setup sequencing gate, observed-setup signal), the cmd runner's
shebang refusal, and shebang flag replay. Each fails with the source reverted.
* fix(worktrees): replay only real `set` flags and keep the gate in the pane's shell
Two round-2 review findings:
- `#!/bin/bash -l` replayed `set -l`, which exits 2 and aborted the runner under
its own `set -e` before a single setup line ran (all platforms). Only the flags
`set` documents are replayed now; a bare `-o` with no option name is dropped
instead of dumping the shell-option table.
- The wait-for-setup gate picked its language from the runner file, so a batch
runner launched from a Git Bash pane got the PowerShell gate while the agent
startup command was already POSIX-quoted — `Invoke-Expression` cannot parse
`'\''`. The gate now follows the pane; the runner still launches through the
ProcessStartInfo launcher, never through bash.
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Keep only the durable docs already allowlisted for tracking
(STYLEGUIDE, assets, localized readme, and reference compatibility
guides). Drop feature design notes, plans, and repro artifacts that
were force-added past the existing docs ignore rules.
* fix(windows): make the GPU fallback actually remove the GPU child, and stop WSL latching absent
Three Windows crash/regression fixes from shipped 1.4.156/1.4.158/1.4.159 crash reports.
GPU fallback (cluster D, 14 reports, exit 0x80000003 STATUS_BREAKPOINT):
the software-rendering fallback called disableHardwareAcceleration() plus
--disable-gpu, neither of which removes the GPU child process — Chromium still
spawns it to host Viz and merely drops the backend to software GL. Measured on
Windows 11 / Electron 43.1.0: gpuProcessCount stays 1. So a GPU process being
killed by a bad driver or an injected DLL kept dying after the fallback engaged,
on every launch, for the life of that build (the marker is sticky per version).
The crash tails show exactly this: gpu_fallback_applied followed by another GPU
crash 1.3s later. --in-process-gpu is the only switch that drops the child count
to 0; --disable-software-rasterizer is deliberately excluded because it also
kills SwiftShader, which would drop every terminal to the DOM renderer.
WSL distro list: a successful-but-empty `wsl --list --quiet` was cached for the
process lifetime. `wsl --install` reports zero distros while one is still
provisioning, so an early probe latched "no WSL" until restart — WSL appeared
during setup and then vanished from the terminal picker. Empty results now
re-probe on an exponential window (15s doubling to a 5min cap) while staying
readable, so a missing distro is still visible to isKnownMissingDistro.
WSL availability: isWslAvailable() latched false on any failure via a bare catch,
so one slow wsl.exe activation disabled WSL for the whole session. Failures are
now classified — a numeric exit status or ENOENT is answer-shaped and holds for
10min, anything else (timeout, spawn failure) retries after 45s — and both back
off per consecutive failure, mirroring isPwshAvailable.
Windows-only: every changed path is behind an existing process.platform check,
so macOS and Linux behaviour is unchanged.
* fix(windows): drop a stale WSL availability failure once a distro list succeeds
The distro-list and availability caches expire independently, and
getWslRepairReason checks availability first. So a definitive availability
failure (numeric exit status or ENOENT) held for 10-30min would keep reporting
`wsl-unavailable` even after `wsl --list --quiet` successfully returned a
distro — i.e. over a WSL that demonstrably just answered. That is the same
latch class this branch fixes, surviving in the gap between the two caches.
A non-empty distro list proves wsl.exe ran, so drop the negative availability
cache and let the next call re-probe. Scoped to non-empty lists only: those are
cached for the process lifetime, so this cannot re-spawn the blocking 5s probe
more than once. An empty list keeps its failure cache, since it re-probes on a
15s-to-5min schedule and would otherwise pay the blocking probe far too often.
* fix(windows): harden GPU safe mode and WSL recovery
* fix(wsl): make capability refresh cleanup explicit
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Adds `orca skills install` and `orca skills update` so skills can be set up without the GUI — SSH hosts, containers, CI. Previously `orca skills` had only `list` and `get`, so there was no headless path.
**Agent targeting is scoped explicitly rather than delegated to detection.** The `skills` CLI decides which agents to install into, and with `-y` and zero detected agents it takes `targetAgents = validAgents` — all ~75. That is not a corner case for a headless CLI: a fresh SSH box or container with no agent installed is the normal starting state. Measured on a bare host, the unscoped command created **52 top-level agent directories and 54 junctions** (one real payload in `~/.agents/skills`, the rest links) on Windows, and 52/53 on macOS.
The CLI now passes `--agent` derived from Orca's own detection, mapped to the `skills` key namespace, plus `universal`. Supplying `--agent` makes `runAdd` use it directly and never call `detectInstalledAgents()`, so the fan-out branch is unreachable. On a bare host it now refuses with `No coding agent detected on this host` and exit 1, creating nothing. Same command with scoping: **1 directory, 0 junctions.**
`universal` alone would under-install — Claude Code is not in that set, and 19 of 28 mapped keys write agent-private homes `universal` never touches. `--agent '*'` is the bug itself. The mapping is hedged three ways: `null` for any agent whose key could not be confirmed, `satisfies Record<TuiAgent, …>` so a new Orca agent is a compile error, and a test pinning every mapped key against the CLI's own valid list.
Fixed during review — two holes that each restored the full fan-out through a different door:
- `--agent ','` trimmed to nothing, which skipped the refusal *and* emitted no `--agent`.
- `--agent -y` passed an emptiness check, and the vendor CLI silently drops `-`-leading values, re-emptying its list.
The real invariant is argument *shape*, not emptiness, and it is now enforced at the choke point in `buildAgentFeatureSkillInstallArgs`, so no caller can emit `-y` without a usable target. `*` remains allowed — asking for every agent explicitly is a choice, not an accident. Verified with 51 hostile inputs through the built binary, each recorded argv replayed through the vendor's own parser.
Also fixed: the `ORCA_CLI_CWD` refusal now runs before target resolution (it was quoting the wrong host's agent list), and `--dry-run` is refused in a forwarded shell rather than printing a command naming the wrong machine.
Validated on a real Windows host across PowerShell 7, PowerShell 5.1, cmd.exe and Git Bash: `.cmd` shims route through `cmd.exe` and `.exe` shims spawn directly (proved with instrumented shims, not inferred), the ENOENT path produces an actionable error rather than a silent failure, and `skills update` genuinely restores a corrupted skill byte-for-byte.
Known, not addressed here — both upstream behaviours this only forwards: a partial install failure exits 0, and "no installed skills found" exits 0. Both are invisible to the headless callers this feature exists for.
Co-authored-by: scastanoh21 <scastanoh21@gmail.com>
* Revert "fix(terminal): avoid flash while restoring parked terminals (#10871)"
This reverts commit 5a6a9e0b28.
Reverted for terminal rendering regressions (flashing, lost content).
Conflict resolution preserves the forwardRef signature from #10433 and
drops the parked-presentation gating #11016 fed with its effective set.
Co-authored-by: Orca <help@stably.ai>
* Revert "fix(terminal): limit pre-paint WebGL resume to macOS (#10794)" and "fix(terminal): stop switch bold flash and Windows lag (#10692)"
This reverts commits 4681edb520 and
8f5a45401f.
#10794 was itself a partial revert of #10692, so both are reverted
together: the Windows retained-WebGL LRU and the macOS pre-paint
(layout-phase) visibility transition that survived it. Terminal
visibility resume returns to passive disposal and recreation on every
platform, and the WebGL context ceiling returns to a flat 128.
Co-authored-by: Orca <help@stably.ai>
* Revert "fix(terminal): release an abandoned synchronized-output frame on reveal (STA-2694) (#10907)"
This reverts commit 97cb32c1cc.
---------
Co-authored-by: Orca <help@stably.ai>
* fix(perf): correct three 07-27 perf regressions
Traversal capacity cap no longer scales with worker concurrency
(#11026). retainWorkspaceSpaceScanEntry charged a traversal-wide entry
counter, so N workers each holding a listing multiplied the live charge.
At concurrency 48 a 48x2,100 tree (100,848 entries) hit the 100,000 cap
while 100x1,500 (150,100 entries, 50% more) passed, and scanLocalWorktree
treats the capacity error as terminal, reporting an intact worktree as
"Unavailable" with sizeBytes 0. The cap is now per directory listing --
the only quantity fixed by directory shape -- restoring the invariant
docs/workspace-space-scan-resource-bounds.md already states. Aggregate
live retention stays bounded by the unchanged 64 MiB byte cap.
Note: releasing each entry's charge at dispatch (the originally suggested
fix) was measured and does not help; the peak is set at admission, before
any entry is dispatched.
Repo image icons are no longer fully base64-decoded on every snapshot
publish (#11012). sanitizeRepoIcon reached decodeBase64Prefix, which
sized its buffer to the whole payload to read a 24-byte header, running
synchronously inside ipcMain.handle at a 250 ms throttle. Validation is
now memoized on source+src in a BoundedMap. Measured for 10 icons x
256 KB: 37.34 ms -> 0.67 ms per publish.
One over-long card label no longer discards the entire snapshot (#11012).
isDashboardSnapshot was all-or-nothing and dashboard-popout returned
early with no log while replaying lastSnapshot, so `orca terminal rename
--title "<1025+ chars>"` froze the pop-out board on its last good paint
with nothing surfaced. Labels are truncated at the producer, the
validator drops only the offending card, and both the rejection and the
drop are logged. The bound now lives in the shared snapshot contract so
producer and validator cannot drift.
Co-authored-by: Orca <help@stably.ai>
* fix(perf): charge a scan listing's parent path once, not per entry
The 4.1 fix made the entry cap per-listing but left the 64 MiB byte cap
charging parentPath.length for every entry in a listing. Because a
listing's entries all share one parent-path string, that multiplied the
path by the directory's width, so the byte cap measured checkout depth
rather than live heap.
The reported symptom therefore still reproduced at the production default
limits: 48 x 2,100 @ concurrency 48 raised a capacity error once the
worktree path passed ~58 characters, while the same layout at concurrency
1 succeeded. The shipped regression test could not see this because it
passes maxRetainedBytes: Number.MAX_SAFE_INTEGER, disabling the only cap
still in play. Measured at a real 65-char worktree root, 3 of the report's
4 documented layouts still failed.
The parent path is now charged once per listing, with its first entry, so
an empty listing strands no charge. Per-entry overhead is unchanged at
512 B + name, which still dominates the estimate, so the OOM protection
the original PR added is preserved.
Adds a production-default-limits case covering the report's layouts under
a deep root, plus an assertion that a short and a deep root reach the same
verdict -- the path independence docs/workspace-space-scan-resource-bounds.md
requires and which no existing test enforced.
Co-authored-by: Orca <help@stably.ai>
* fix(perf): prove the icon cache by decode count, not wall clock
The caching test asserted a per-publish millisecond budget, which failed
on CI at 5.64 ms against a 5 ms ceiling. Any threshold flakes on a loaded
box, so count real sanitizeRepoIcon entries instead: 10 repos x 20
publishes is 200 icon checks against exactly 1 decode. Added cases pin
the cache key (payload and source both re-decode; a cached image verdict
never answers for an emoji) and that a rejection is cached too.
Also drops budget.entries, which the per-listing cap left as a
traversal-wide counter no check reads -- exactly the shape a future
guard could reintroduce the concurrency bug from.
Co-authored-by: Orca <help@stably.ai>
* fix(dashboard): bound the project filter label the whole board rides on
#11042 added snapshot-level filterOptions whose project labels are
repo.displayName -- the same unbounded source this PR already bounds for
card.repoName, but one level up where dropping a card cannot recover it.
An over-long project name would fail isDashboardFilterOptions and take
the entire snapshot with it, which is the exact frozen-board failure the
per-card drop was added to end. Workspace-status labels are already
capped at 32 by workspace-statuses.ts, so only projects needed this.
Co-authored-by: Orca <help@stably.ai>
* fix(dashboard): disambiguate the repo icon cache key
The memoization key joined `source` and `src` with a space, but the
sanitizer's base64 pattern admits whitespace inside a valid `src`. A
rejected icon can therefore split the same concatenation differently and
inherit an accepted icon's cached verdict, reaching the pop-out's
`<img src>` without ever being sanitized. Length-prefix the source.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(workspace-space): serialize local disk and cap traversal memory
Prevent resource exhaustion during large workspace scans by limiting
local disk access to one concurrent `du` call and capping portable
traversal memory to 100k entries or 64 MiB per worktree. Fixes July 27
incident with 298 worktrees causing host stalls and renderer OOM.
Portable traversals now use fixed-worker iterative frames instead of
recursive promises. Capacity failures become unavailable rows. Behavior
below limits is unchanged.
* fix(workspace-space): bound concurrent SSH fallback traversals
Desktop-side SSH fallback traversals run in the main process with independent admission
budgets. Without limiting, up to six concurrent traversals could stack six 64 MiB budgets.
Cap remote fallback traversals to 2 concurrent, keeping aggregate admission at 2 × 64 MiB.
Also make capacity error messages reflect configured limits instead of hardcoded defaults.
* fix(terminal): release an abandoned synchronized-output frame on reveal
Alt-screen agent TUIs (OpenCode/OpenTUI, Codex, grok) bracket every repaint
in `?2026h … ?2026l`. Hiding a pane mid-bracket — which a worktree switch or
cold-park lands on routinely, since these brackets are written many times a
second — leaves xterm's `decPrivateModes.synchronizedOutput` latched.
RenderService.refreshRows checks that latch *before* rendering, so while it
holds, every repaint Orca owns is a no-op: the forced render-pause repaint,
the plain `refresh()` fallback, and the shared glyph-atlas rebuild all render
zero rows while the xterm buffer is perfectly correct. Release the latch at
the two reveal repaint entry points so those repaints actually paint.
Also adds an OpenCode-shaped alt-screen e2e fixture and spec. The existing
inline-TUI convergence spec covers the normal-buffer shape (live block glued
to the bottom, history scrolling into scrollback); this covers the
full-screen alternate-buffer shape, where nothing scrolls and so no row ever
self-heals through the scroll path.
Scope note: xterm arms a 1s watchdog that clears this latch on its own, so
this closes a bounded window rather than the whole STA-2694 report. The e2e
spec passes with and without the production change for that reason; the unit
tests are what pin the behavior. Refs STA-2694.
* fix(terminal): clear the render model on the plain-refocus repaint path
`schedulePaneRevealPresent` — the atlas-preserving path a plain window
refocus takes — only called `terminal.refresh()`. xterm's renderers are
diff-based: `_updateModel` early-continues on any cell whose code/fg/bg/ext
still match the cached model, so a refresh repaints nothing for a pane whose
buffer never changed. When an occluded window loses its canvas contents while
that model stays populated, the refresh skips exactly the cells that went
stale and the pane keeps compositing pre-hide pixels — until a window resize
reallocates the model, which is the repair users find by hand.
Clear the model first (`RenderService.clear()` → renderer `clear()` →
`_clearModel(true)`) so the refresh becomes a guaranteed full repaint. That
drops cached cells and glyph vertices but NOT the texture atlas, which is
shared by every same-config terminal and whose mid-stream wipe re-arms xterm's
page-merge garble race (xterm.js #4480) — the reason this path is
atlas-preserving in the first place.
Also covers the DOM-renderer fallback in `resetWebglTextureAtlas`:
`clearTextureAtlas()` is what invalidated the model on the WebGL path, so a
pane without an addon had nothing invalidate it and hit the same skip.
Scope note: the e2e spec guards buffer/geometry convergence across the
hide/reveal boundaries and adds idle-agent and headful desktop-hide cases, but
it cannot observe a stale canvas — both oracles built for that (canvas-vs-buffer
ink sampling, screenshot-vs-forced-repaint) were proven blind by injecting the
defect, and the spec header documents why. The unit tests pin the ordering and
the atlas-preservation invariant. Refs STA-2694.
Co-authored-by: Orca <help@stably.ai>
* docs(terminal): hand off the STA-2694 reveal-artifact investigation
Records both fixed defects with their xterm mechanisms, the reveal/wake call
graph, why every e2e oracle for a stale canvas was proven blind, how to arm the
in-app render-desync sentinel on real hardware, and the one unverified lead
(dimension staleness) that would explain why a window resize specifically is
the repair users find. Refs STA-2694.
Co-authored-by: Orca <help@stably.ai>
* Revert "fix(terminal): clear the render model on the plain-refocus repaint path"
This reverts commit 0f7ec4458d37010338f16e70ff06957cb335e074.
* test(terminal): add a draw-command oracle for reveal repaints, and correct the STA-2694 scope
Every pixel oracle tried for STA-2694 was blind: `drawImage` on a
non-preserveDrawingBuffer WebGL canvas returns a re-rendered copy, and
Playwright's screenshot drives a fresh compositor frame that heals a stale paint
before capture. Reading pixels is self-defeating here — the read triggers the
repaint that hides the bug.
Count the WebGL draw commands instead, by wrapping GlyphRenderer.updateCell and
gl.drawElementsInstanced on the live pane. A draw command cannot be healed after
the fact, so "did the reveal actually repaint?" becomes directly observable.
Teeth-verified: removing releaseAbandonedSynchronizedOutput from
schedulePaneRevealPresent fails the stranded-latch test.
Two findings, both of which change previously-committed claims:
1. The 1s watchdog does NOT bound the synchronized-output defect. It is armed
only inside `bufferRows`, and `refreshRows` returns at its `_isPaused` check
first — so while a pane is occluded nothing reaches `bufferRows` and no timer
is ever pending. A pane hidden mid-`?2026h` holds the latch with no watchdog
behind it, indefinitely. ed1eaf55f1's "closes a bounded window" scope note was
wrong; this is the unbounded garble the report describes, and the fix closes
it. Corrected in the module doc comment.
2. It refutes the diff-based-staleness hypothesis behind 0f7ec4458d (reverted in
8d5eacecb4). `_updateModel` does early-continue per unchanged cell, but
`GlyphRenderer.render` then copies vertices for EVERY row up to
`lineLengths[y]` and issues ONE full-viewport draw — measured identical
instance counts (562) for a diff-skipped and a model-cleared refresh, with
updateCell at 0 vs 561. The DOM renderer likewise replaceChildren()s every
row unconditionally. Clearing the model could not change what reached the
screen, and `_clearModel(true)` zeroes every glyph vertex while
`RenderService.clear()` fires no repaint of its own — so it opened a
blank-viewport window (also asserted here) for no benefit.
Also keeps the idle-agent and headful desktop-hide cases from the reverted
commit, since those were independent of the refuted production change, and
rewrites the alt-screen spec header to point paint questions at this oracle.
Refs STA-2694.
Co-authored-by: Orca <help@stably.ai>
* docs(terminal): rewrite the STA-2694 handoff after the refutation
Records that the garble window is unbounded (the 1s watchdog never arms for an
occluded pane), that the diff-based-staleness hypothesis was refuted by
measurement and reverted, why pixel oracles are structurally blind here, and the
two leads now closed by measurement (dimension staleness, lazy atlas bindings).
Refs STA-2694.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): capture visual proof of the STA-2694 stale paint
The earlier screenshot oracles were blind because they compared a revealed pane
against a repaired one and both ran the same repaint code. Capturing the defect
directly works instead, because the mechanism is self-preserving: while
synchronizedOutput is latched, refreshRows returns before reaching the renderer,
so a compositor frame just re-composites the existing canvas texture and the
stale pixels survive the screenshot rather than being healed by it.
Latch a frame, write a full new frame the pane cannot paint, and capture. The
screenshot comes back byte-identical to the pre-hide one while the buffer holds
the new frame — the buffer/screen divergence users report — and differs after
the reveal repaint runs. Asserts both halves, so it fails if either the defect
stops reproducing or the fix stops repairing it.
Refs STA-2694.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): note where the xterm gate-order double is pinned for real
The unit double encodes RenderService's paused-then-latch gate order, which can
drift on an xterm upgrade. Point at the e2e oracle that pins the same order
against the real renderer, so a future upgrade has a trail to the authoritative
check. Refs STA-2694.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): add a perf budget for the synchronized-output release
releaseAbandonedSynchronizedOutput runs inside resetWebglTextureAtlas, which a
streaming alt-screen TUI can reach through the terminal-output atlas recovery
path — not only on reveal. Measure rather than assert that this costs nothing.
Steady state (a TUI that closes every frame it opens): 200 bracketed frames
produce zero releases, zero extra draw calls, and an unmeasurable early-out
cost. Worst case (every reveal finds a latched frame): 50 latched atlas resets
at 0.08ms each. Both are asserted with headroom, so the guard catches a future
change that makes this scan the buffer per pane rather than flaking on machine
speed. Refs STA-2694.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): address review — drive real code paths, close vacuity gaps
CodeRabbit caught a genuine tautology in the perf budget: it timed a
hand-copied mirror of the early-out rather than the shipped function, so the
assertion would have held even if the real code grew a buffer scan. Driving
resetWebglTextureAtlases instead moved the measured cost from ~0 to ~0.03ms per
call, which is the honest number for the whole recovery; bound re-set to 0.4ms
(10x measured).
Other review fixes:
- Assert the draw counts both perf tests were measuring and logging but never
checking, so the 'no extra draws' titles now mean something.
- Fail fast when decPrivateModes is unavailable; previously the latched test
would pass without ever exercising the fix.
- Re-check the latch right after the worktree switch in the mid-frame test: the
pane is visible until then, so the 1s watchdog can arm and clear it before the
hide, making the run vacuous.
- Count scheduleRevealPresent invocations instead of returning a literal true,
so a missing test hook no longer masquerades as a production failure.
- Assert the latch clears on every reveal iteration, not just the last.
- Make the fixture heartbeat write atomic (tmp + rename); writeFileSync
truncates first, so a reader could see '' and read it as frame 0.
- Relabel assertRevealPixelsNeedNoRepair as the weak secondary check it is; it
contradicted the file header by calling itself 'the decisive paint assertion'.
Refs STA-2694.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(linux): restore Ubuntu 20.04 launch by pinning node-pty glibc symbols (#9902)
The bundled node-pty pty.node is compiled from source in release CI on
ubuntu-latest (glibc 2.39). glibc's 2.32-2.34 libpthread/libutil merge
relocated openpty/forkpty (GLIBC_2.34) and pthread_sigmask (GLIBC_2.32)
into libc under new symbol versions, so the from-source build bound to
versions absent on Ubuntu 20.04 (glibc 2.31). The main process imports
node-pty at startup, so the app crashed on launch. pty.node is the sole
blocker (Electron needs GLIBC_2.25; other native modules <= 2.17).
- Patch node-pty: a .symver shim pins the 3 symbols to their pre-merge
version (GLIBC_2.2.5 x64 / GLIBC_2.17 arm64), and Linux-only ldflags
force libutil.so.1/libpthread.so.0 back into DT_NEEDED. Guarded to
Linux; macOS/Windows untouched.
- Add a packaging gate (verify-linux-glibc-floor.cjs, afterPack): reads
each bundled native binary's objdump -p version needs and fails the
Linux build if any strong GLIBC_/GLIBCXX_/CXXABI_ node exceeds stock
Ubuntu 20.04 (glibc 2.31 / GLIBCXX_3.4.28 / CXXABI_1.3.12). Catches
GLIBC_ABI_DT_RELR, rejects GLIBC_PRIVATE, skips weak needs, fail-closed.
- Docs + tests; the lazy sherpa-onnx speech prebuilt (GLIBCXX_3.4.29,
never loaded at launch) is a documented libstdc++-floor exemption.
* fix(linux): assert DT_NEEDED provider deps in the glibc-floor gate
Harden the packaging gate (flagged in adversarial re-eval): the version-floor
check alone can false-pass if the patch's forced `-l:libutil.so.1` ever silently
drops — the pinned openpty@GLIBC_2.2.5 still resolves from libc's compat alias at
build time, but fails to load on Ubuntu 20.04 where openpty/forkpty live only in
libutil. The gate now also asserts that any binary importing openpty/forkpty
keeps libutil.so.1 in DT_NEEDED. Validated on a real symver-pinned .so with
libutil dropped (now fails) vs. present (passes). Documents the recommended
real-host smoke-test follow-up.