Skip to content

Commit 2fee91e

Browse files
feat(Mountain): Buffer terminal output for late-listener replay
Add per-terminal output buffering to solve the same race condition that was fixed for tree-views and SCM in 1599d76. The PTY reader task and SkyBridge's `listen("sky://terminal/*")` installation race: in the bundled-electron profile, the shell's first prompt fires ~50ms after `spawn_command` while Sky's bundle still parses for ~1500ms. Without a buffer, zsh MOTD, direnv exports, fish greetings are silently dropped and the user sees an empty pane until they type. The implementation adds: - `AppendTerminalOutput()`: capture PTY bytes into a 64KB bounded buffer (keeps most recent suffix to preserve the prompt) - `DrainTerminalOutputBuffer()`: retrieve all buffered terminal data - `RemoveTerminalOutputBuffer()`: clean up on terminal exit - Extended `sky:replay-events` to emit `sky://terminal/create` for each active terminal AND replay any buffered `sky://terminal/data` bytes This completes the replay system for all three major feature areas: tree-views, SCM providers, and terminals.
1 parent 1f93b7e commit 2fee91e

2 files changed

Lines changed: 109 additions & 2 deletions

File tree

Source/Environment/TerminalProvider.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,55 @@ use tokio::sync::mpsc as TokioMPSC;
115115
use super::{MountainEnvironment::MountainEnvironment, Utility};
116116
use crate::{ApplicationState::DTO::TerminalStateDTO::TerminalStateDTO, dev_log};
117117

118+
// Per-terminal recent-output buffer. The PTY reader task races SkyBridge's
119+
// `listen("sky://terminal/data", ...)` install: in the bundled-electron
120+
// profile, the shell's first prompt + any startup chatter (zsh's MOTD,
121+
// `direnv` exports, fish's greeting, …) fires within ~50 ms of
122+
// `localPty:createProcess` while Sky's bundle is still parsing for ~1500 ms.
123+
// Without buffering, those bytes vanish and the user sees an empty pane
124+
// until they type something to coax fresh output. We buffer up to
125+
// `MAX_BUFFERED_BYTES` per terminal and replay on `sky:replay-events`.
126+
//
127+
// The buffer is bounded; on overflow we drop oldest bytes (keep the most
128+
// recent suffix). 64 KB is enough for ~600 lines of typical zsh/bash
129+
// startup; tail-cropping preserves the prompt the user actually needs to
130+
// see.
131+
const MAX_BUFFERED_BYTES:usize = 64 * 1024;
132+
133+
static TERMINAL_OUTPUT_BUFFER:std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<u64, Vec<u8>>>> =
134+
std::sync::OnceLock::new();
135+
136+
fn TerminalOutputBuffer() -> &'static std::sync::Mutex<std::collections::HashMap<u64, Vec<u8>>> {
137+
TERMINAL_OUTPUT_BUFFER.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
138+
}
139+
140+
pub fn AppendTerminalOutput(TerminalId:u64, Bytes:&[u8]) {
141+
if let Ok(mut Map) = TerminalOutputBuffer().lock() {
142+
let Entry = Map.entry(TerminalId).or_insert_with(Vec::new);
143+
Entry.extend_from_slice(Bytes);
144+
// Drop oldest if over cap. Keep the trailing MAX_BUFFERED_BYTES so
145+
// the prompt + most-recent context survive.
146+
if Entry.len() > MAX_BUFFERED_BYTES {
147+
let DropCount = Entry.len() - MAX_BUFFERED_BYTES;
148+
Entry.drain(..DropCount);
149+
}
150+
}
151+
}
152+
153+
pub fn DrainTerminalOutputBuffer() -> Vec<(u64, Vec<u8>)> {
154+
if let Ok(Map) = TerminalOutputBuffer().lock() {
155+
Map.iter().map(|(K, V)| (*K, V.clone())).collect()
156+
} else {
157+
Vec::new()
158+
}
159+
}
160+
161+
pub fn RemoveTerminalOutputBuffer(TerminalId:u64) {
162+
if let Ok(mut Map) = TerminalOutputBuffer().lock() {
163+
Map.remove(&TerminalId);
164+
}
165+
}
166+
118167
#[async_trait]
119168
impl TerminalProvider for MountainEnvironment {
120169
/// Creates a new terminal instance, spawns a PTY, and manages its I/O.
@@ -220,6 +269,14 @@ impl TerminalProvider for MountainEnvironment {
220269
loop {
221270
match PTYReader.read(&mut Buffer) {
222271
Ok(count) if count > 0 => {
272+
// Buffer the bytes for replay-on-late-listener. The
273+
// SkyBridge install completes ~1500 ms after Cocoon
274+
// activates, and the shell's first prompt fires
275+
// immediately after `spawn_command`. Without a
276+
// buffer the prompt is silently lost and the user
277+
// sees an empty terminal pane until they type.
278+
AppendTerminalOutput(TermIDForOutput, &Buffer[..count]);
279+
223280
let DataString = String::from_utf8_lossy(&Buffer[..count]).to_string();
224281

225282
// Fan out in two directions so both consumers see
@@ -329,6 +386,9 @@ impl TerminalProvider for MountainEnvironment {
329386
if let Ok(mut Guard) = EnvironmentClone.ApplicationState.Feature.Terminals.ActiveTerminals.lock() {
330387
Guard.remove(&TermIDForExit);
331388
}
389+
// Drop the recent-output replay buffer; nothing left to replay
390+
// after the shell has exited.
391+
RemoveTerminalOutputBuffer(TermIDForExit);
332392

333393
// Tell Sky the xterm panel should drop - mirrors the `sky://`
334394
// create emit above. Without this, the UI keeps a ghost panel

Source/IPC/WindServiceHandlers/mod.rs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2076,6 +2076,8 @@ pub async fn mountain_ipc_invoke(app_handle:AppHandle, command:String, args:Vec<
20762076
let mut TreeViewCount:usize = 0;
20772077
let mut ScmCount:usize = 0;
20782078
let mut CommandCount:usize = 0;
2079+
let mut TerminalCount:usize = 0;
2080+
let mut TerminalDataBytes:usize = 0;
20792081
if let Ok(TreeViews) = runtime.Environment.ApplicationState.Feature.TreeViews.ActiveTreeViews.lock() {
20802082
for (ViewId, Dto) in TreeViews.iter() {
20812083
let Payload = serde_json::json!({
@@ -2164,17 +2166,62 @@ pub async fn mountain_ipc_invoke(app_handle:AppHandle, command:String, args:Vec<
21642166
}
21652167
}
21662168
}
2169+
// Replay terminals: each active terminal needs its `create`
2170+
// event AND any buffered stdout the PTY reader produced
2171+
// before SkyBridge's `listen("sky://terminal/*")` was
2172+
// installed. Without this, the shell's first prompt
2173+
// (zsh's MOTD, fish greeting, `direnv export`, …) is
2174+
// silently dropped and the user sees an empty pane until
2175+
// they type.
2176+
if let Ok(Terminals) = runtime
2177+
.Environment
2178+
.ApplicationState
2179+
.Feature
2180+
.Terminals
2181+
.ActiveTerminals
2182+
.lock()
2183+
{
2184+
for (TerminalId, Arc) in Terminals.iter() {
2185+
let (Name, Pid) = if let Ok(State) = Arc.lock() {
2186+
(State.Name.clone(), State.OSProcessIdentifier.unwrap_or(0))
2187+
} else {
2188+
(String::new(), 0)
2189+
};
2190+
let CreatePayload = serde_json::json!({
2191+
"id": *TerminalId,
2192+
"name": Name,
2193+
"pid": Pid,
2194+
});
2195+
if app_handle.emit("sky://terminal/create", CreatePayload).is_ok() {
2196+
TerminalCount += 1;
2197+
}
2198+
}
2199+
}
2200+
for (TerminalId, Bytes) in
2201+
crate::Environment::TerminalProvider::DrainTerminalOutputBuffer()
2202+
{
2203+
let DataString = String::from_utf8_lossy(&Bytes).to_string();
2204+
TerminalDataBytes += Bytes.len();
2205+
let _ = app_handle.emit(
2206+
"sky://terminal/data",
2207+
serde_json::json!({ "id": TerminalId, "data": DataString }),
2208+
);
2209+
}
21672210
dev_log!(
21682211
"sky-emit",
2169-
"[SkyEmit] replay-events tree-views={} scm={} commands={}",
2212+
"[SkyEmit] replay-events tree-views={} scm={} commands={} terminals={} terminal-bytes={}",
21702213
TreeViewCount,
21712214
ScmCount,
2172-
CommandCount
2215+
CommandCount,
2216+
TerminalCount,
2217+
TerminalDataBytes
21732218
);
21742219
Ok(serde_json::json!({
21752220
"treeViews": TreeViewCount,
21762221
"scmProviders": ScmCount,
21772222
"commands": CommandCount,
2223+
"terminals": TerminalCount,
2224+
"terminalDataBytes": TerminalDataBytes,
21782225
}))
21792226
},
21802227

0 commit comments

Comments
 (0)