Skip to content

Commit 5ff09f3

Browse files
committed
fix: stabilize workspace transport and session persistence
1 parent 4196387 commit 5ff09f3

37 files changed

Lines changed: 2262 additions & 180 deletions

apps/server/src/services/agent.rs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::*;
2+
use crate::services::utf8_stream::Utf8StreamDecoder;
23

34
const DEFAULT_PTY_COLS: u16 = 120;
45
const DEFAULT_PTY_ROWS: u16 = 30;
@@ -259,11 +260,41 @@ pub(crate) fn agent_start(
259260
std::thread::spawn(move || {
260261
let mut reader = reader;
261262
let mut buf = [0u8; 4096];
263+
let mut decoder = Utf8StreamDecoder::new();
262264
loop {
263265
match reader.read(&mut buf) {
264-
Ok(0) => break,
266+
Ok(0) => {
267+
let text = decoder.finish();
268+
if !text.is_empty() {
269+
if let Ok(mut lifecycle_state) = lifecycle_fallback_state_out.lock() {
270+
if let Some((kind, source_event, data)) =
271+
fallback_agent_lifecycle_from_output(&mut lifecycle_state, &text)
272+
{
273+
emit_agent_lifecycle(
274+
&app_handle,
275+
&workspace_id_out,
276+
&session_out,
277+
kind,
278+
source_event,
279+
&data,
280+
);
281+
}
282+
}
283+
emit_agent(
284+
&app_handle,
285+
&workspace_id_out,
286+
&session_out,
287+
"stdout",
288+
&text,
289+
);
290+
let state: State<AppState> = state_handle.state();
291+
let _ =
292+
append_session_stream(state, &workspace_id_out, session_out_num, &text);
293+
}
294+
break;
295+
}
265296
Ok(n) => {
266-
let text = String::from_utf8_lossy(&buf[..n]).to_string();
297+
let text = decoder.push(&buf[..n]);
267298
if text.is_empty() {
268299
continue;
269300
}

apps/server/src/services/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub(crate) mod filesystem;
55
pub(crate) mod git;
66
pub(crate) mod system;
77
pub(crate) mod terminal;
8+
pub(crate) mod utf8_stream;
89
pub(crate) mod workspace;
910
pub(crate) mod workspace_runtime;
1011
pub(crate) mod workspace_watch;

apps/server/src/services/terminal.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::*;
2+
use crate::services::utf8_stream::Utf8StreamDecoder;
23

34
const DEFAULT_PTY_COLS: u16 = 120;
45
const DEFAULT_PTY_ROWS: u16 = 30;
@@ -83,11 +84,25 @@ pub(crate) fn terminal_create(
8384
std::thread::spawn(move || {
8485
let mut reader = reader;
8586
let mut buf = [0u8; 4096];
87+
let mut decoder = Utf8StreamDecoder::new();
8688
loop {
8789
match reader.read(&mut buf) {
88-
Ok(0) => break,
90+
Ok(0) => {
91+
let text = decoder.finish();
92+
if !text.is_empty() {
93+
emit_terminal(&app_handle, &workspace_id_out, terminal_id, &text);
94+
let state: State<AppState> = state_handle.state();
95+
let _ = append_workspace_terminal_output(
96+
state,
97+
&workspace_id_out,
98+
terminal_id,
99+
&text,
100+
);
101+
}
102+
break;
103+
}
89104
Ok(n) => {
90-
let text = String::from_utf8_lossy(&buf[..n]).to_string();
105+
let text = decoder.push(&buf[..n]);
91106
if text.is_empty() {
92107
continue;
93108
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
pub(crate) struct Utf8StreamDecoder {
2+
pending: Vec<u8>,
3+
}
4+
5+
impl Utf8StreamDecoder {
6+
pub(crate) fn new() -> Self {
7+
Self {
8+
pending: Vec::new(),
9+
}
10+
}
11+
12+
pub(crate) fn push(&mut self, chunk: &[u8]) -> String {
13+
if chunk.is_empty() {
14+
return String::new();
15+
}
16+
17+
self.pending.extend_from_slice(chunk);
18+
let mut output = String::new();
19+
20+
loop {
21+
match std::str::from_utf8(&self.pending) {
22+
Ok(valid) => {
23+
output.push_str(valid);
24+
self.pending.clear();
25+
break;
26+
}
27+
Err(error) => {
28+
let valid_up_to = error.valid_up_to();
29+
if valid_up_to > 0 {
30+
let valid = std::str::from_utf8(&self.pending[..valid_up_to])
31+
.unwrap_or_default();
32+
output.push_str(valid);
33+
self.pending.drain(..valid_up_to);
34+
}
35+
36+
match error.error_len() {
37+
Some(len) => {
38+
output.push('\u{FFFD}');
39+
self.pending.drain(..len);
40+
}
41+
None => break,
42+
}
43+
}
44+
}
45+
}
46+
47+
output
48+
}
49+
50+
pub(crate) fn finish(&mut self) -> String {
51+
if self.pending.is_empty() {
52+
return String::new();
53+
}
54+
let flushed = String::from_utf8_lossy(&self.pending).to_string();
55+
self.pending.clear();
56+
flushed
57+
}
58+
}
59+
60+
#[cfg(test)]
61+
mod tests {
62+
use super::Utf8StreamDecoder;
63+
64+
#[test]
65+
fn decodes_multibyte_characters_split_across_chunks() {
66+
let mut decoder = Utf8StreamDecoder::new();
67+
68+
let first = decoder.push(&[0xE4, 0xBD]);
69+
let second = decoder.push(&[0xA0, 0xE5, 0xA5, 0xBD]);
70+
let flushed = decoder.finish();
71+
72+
assert_eq!(first, "");
73+
assert_eq!(second, "你好");
74+
assert_eq!(flushed, "");
75+
}
76+
77+
#[test]
78+
fn preserves_invalid_bytes_with_replacement_and_continues() {
79+
let mut decoder = Utf8StreamDecoder::new();
80+
81+
let output = decoder.push(&[0x66, 0x80, 0x6F, 0x6F]);
82+
83+
assert_eq!(output, "f\u{FFFD}oo");
84+
}
85+
}

apps/server/src/ws/protocol.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,40 @@ pub(crate) enum WsEnvelope {
1313
pub(crate) enum WsClientEnvelope {
1414
Ping { ts: i64 },
1515
Pong { ts: i64 },
16+
AgentSend {
17+
workspace_id: String,
18+
session_id: String,
19+
input: String,
20+
append_newline: Option<bool>,
21+
fencing_token: i64,
22+
},
23+
TerminalWrite {
24+
workspace_id: String,
25+
terminal_id: u64,
26+
input: String,
27+
fencing_token: i64,
28+
},
29+
TerminalResize {
30+
workspace_id: String,
31+
terminal_id: u64,
32+
cols: u16,
33+
rows: u16,
34+
fencing_token: i64,
35+
},
36+
AgentResize {
37+
workspace_id: String,
38+
session_id: String,
39+
cols: u16,
40+
rows: u16,
41+
fencing_token: i64,
42+
},
43+
SessionUpdate {
44+
workspace_id: String,
45+
session_id: u64,
46+
patch: SessionPatch,
47+
fencing_token: i64,
48+
},
49+
WorkspaceControllerHeartbeat {
50+
workspace_id: String,
51+
},
1652
}

0 commit comments

Comments
 (0)