Skip to content

Commit 3bfb235

Browse files
committed
perf(linter): Implement streaming diagnostics for tsgolint instead of waiting for output to finish (#13098)
This PR resolves the TODO comment at line 126 in `apps/oxlint/src/tsgolint.rs` by implementing streaming diagnostics for tsgolint instead of waiting for the entire process to complete. ## Problem Previously, the tsgolint integration would: 1. Spawn the tsgolint process 2. Wait for the entire process to complete using `child.wait_with_output()` 3. Parse all output at once 4. Send all diagnostics to the diagnostic service This approach meant users had to wait for tsgolint to process all files before seeing any feedback, which could be slow for large codebases. ## Solution The implementation now streams diagnostics as they are emitted: 1. **Streaming stdout reader**: Reads from the tsgolint process stdout in 4KB chunks as data becomes available 2. **Incremental message parsing**: Uses the existing binary protocol parser to process complete messages as they arrive in the buffer 3. **Immediate diagnostic sending**: Sends diagnostics to the diagnostic service via the `error_sender` channel as soon as they are parsed 4. **Proper buffering**: Keeps incomplete messages in the buffer for the next iteration to handle message boundaries correctly ## Key Changes - Replaced `child.wait_with_output()` with streaming stdout reading in a separate thread - Added incremental buffer processing that identifies complete messages using the binary protocol format - Maintained backward compatibility - all existing tests pass - Removed unused `parse_tsgolint_output()` function since parsing now happens incrementally - Added proper error handling for both process exit status and stdout processing ## Benefits - **Faster feedback**: Users see diagnostics as soon as tsgolint emits them, rather than waiting for completion - **Better user experience**: Especially beneficial for large codebases where tsgolint processing takes significant time - **Same reliability**: All existing functionality and error handling is preserved - **No breaking changes**: The API and behavior remain the same from the user's perspective The streaming implementation provides a more responsive experience while maintaining full compatibility with existing tsgolint integration. <!-- START COPILOT CODING AGENT TIPS --> --- 💬 Share your feedback on Copilot coding agent for the chance to win a $200 gift card! Click [here](https://survey.alchemer.com/s3/8343779/Copilot-Coding-agent) to start the survey.
1 parent 7e15446 commit 3bfb235

File tree

1 file changed

+122
-82
lines changed

1 file changed

+122
-82
lines changed

apps/oxlint/src/tsgolint.rs

Lines changed: 122 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::{
22
ffi::OsStr,
3-
io::{Read, Write},
3+
io::{ErrorKind, Read, Write},
44
path::{Path, PathBuf},
55
sync::Arc,
66
};
@@ -117,75 +117,133 @@ impl<'a> TsGoLintState<'a> {
117117

118118
let mut stdin = child.stdin.take().expect("Failed to open tsgolint stdin");
119119

120-
std::thread::spawn(move || {
121-
let json = serde_json::to_string(&json_input).expect("Failed to serialize JSON");
122-
123-
stdin.write_all(json.as_bytes()).expect("Failed to write to tsgolint stdin");
124-
});
125-
126-
// TODO: Stream diagnostics as they are emitted, rather than waiting
127-
let output = child.wait_with_output().expect("Failed to wait for tsgolint process");
128-
129-
if !output.status.success() {
130-
return Err(format!("tsgolint process exited with status: {}", output.status));
120+
// Write the input synchronously and handle BrokenPipe gracefully in case the child
121+
// exits early and closes its stdin.
122+
let json = serde_json::to_string(&json_input).expect("Failed to serialize JSON");
123+
if let Err(e) = stdin.write_all(json.as_bytes()) {
124+
// If the child closed stdin early, avoid crashing on SIGPIPE/BrokenPipe.
125+
if e.kind() != ErrorKind::BrokenPipe {
126+
return Err(format!("Failed to write to tsgolint stdin: {e}"));
127+
}
131128
}
129+
// Explicitly drop stdin to send EOF to the child.
130+
drop(stdin);
131+
132+
// Stream diagnostics as they are emitted, rather than waiting for all output
133+
let mut stdout = child.stdout.take().expect("Failed to open tsgolint stdout");
134+
135+
// Process stdout stream in a separate thread to send diagnostics as they arrive
136+
let cwd_clone = self.cwd.clone();
137+
138+
let stdout_handler = std::thread::spawn(move || -> Result<(), String> {
139+
let mut buffer = Vec::with_capacity(8192);
140+
let mut read_buf = [0u8; 8192];
141+
142+
loop {
143+
match stdout.read(&mut read_buf) {
144+
Ok(0) => break, // EOF
145+
Ok(n) => {
146+
buffer.extend_from_slice(&read_buf[..n]);
147+
148+
// Try to parse complete messages from buffer
149+
let mut cursor = std::io::Cursor::new(buffer.as_slice());
150+
let mut processed_up_to: u64 = 0;
151+
152+
while cursor.position() < buffer.len() as u64 {
153+
let start_pos = cursor.position();
154+
match parse_single_message(&mut cursor) {
155+
Ok(Some(tsgolint_diagnostic)) => {
156+
processed_up_to = cursor.position();
157+
158+
// For now, ignore any `tsgolint` errors.
159+
if tsgolint_diagnostic.r#type == MessageType::Error {
160+
continue;
161+
}
162+
163+
let path = tsgolint_diagnostic.file_path.clone();
164+
let Some(resolved_config) = resolved_configs.get(&path)
165+
else {
166+
// If we don't have a resolved config for this path, skip it. We should always
167+
// have a resolved config though, since we processed them already above.
168+
continue;
169+
};
170+
171+
let severity = resolved_config.rules.iter().find_map(
172+
|(rule, status)| {
173+
if rule.name() == tsgolint_diagnostic.rule {
174+
Some(*status)
175+
} else {
176+
None
177+
}
178+
},
179+
);
180+
181+
let oxc_diagnostic: OxcDiagnostic =
182+
tsgolint_diagnostic.into();
183+
let Some(severity) = severity else {
184+
// If the severity is not found, we should not report the diagnostic
185+
continue;
186+
};
187+
let oxc_diagnostic = oxc_diagnostic.with_severity(
188+
if severity == AllowWarnDeny::Deny {
189+
Severity::Error
190+
} else {
191+
Severity::Warning
192+
},
193+
);
194+
195+
let diagnostics = DiagnosticService::wrap_diagnostics(
196+
cwd_clone.clone(),
197+
path.clone(),
198+
&read_to_string(&path)
199+
.unwrap_or_else(|_| String::new()),
200+
vec![oxc_diagnostic],
201+
);
202+
203+
if error_sender.send((path, diagnostics)).is_err() {
204+
// Receiver has been dropped, stop processing
205+
return Ok(());
206+
}
207+
}
208+
Ok(None) => {
209+
// Successfully parsed but no diagnostic to add
210+
processed_up_to = cursor.position();
211+
}
212+
Err(_) => {
213+
// Could not parse a complete message, break and keep remaining data
214+
cursor.set_position(start_pos);
215+
break;
216+
}
217+
}
218+
}
132219

133-
match parse_tsgolint_output(&output.stdout) {
134-
Ok(parsed) => {
135-
let mut oxc_diagnostics: FxHashMap<PathBuf, Vec<OxcDiagnostic>> =
136-
FxHashMap::default();
137-
for tsgolint_diagnostic in parsed {
138-
// For now, ignore any `tsgolint` errors.
139-
if tsgolint_diagnostic.r#type == MessageType::Error {
140-
continue;
141-
}
142-
143-
let path = tsgolint_diagnostic.file_path.clone();
144-
let Some(resolved_config) = resolved_configs.get(&path) else {
145-
// If we don't have a resolved config for this path, skip it. We should always
146-
// have a resolved config though, since we processed them already above.
147-
continue;
148-
};
149-
150-
let severity = resolved_config.rules.iter().find_map(|(rule, status)| {
151-
if rule.name() == tsgolint_diagnostic.rule {
152-
Some(*status)
153-
} else {
154-
None
220+
// Keep unprocessed data for next iteration
221+
if processed_up_to > 0 {
222+
#[expect(clippy::cast_possible_truncation)]
223+
buffer.drain(..processed_up_to as usize);
155224
}
156-
});
157-
158-
let oxc_diagnostic: OxcDiagnostic = tsgolint_diagnostic.into();
159-
let Some(severity) = severity else {
160-
// If the severity is not found, we should not report the diagnostic
161-
continue;
162-
};
163-
let oxc_diagnostic =
164-
oxc_diagnostic.with_severity(if severity == AllowWarnDeny::Deny {
165-
Severity::Error
166-
} else {
167-
Severity::Warning
168-
});
169-
170-
oxc_diagnostics.entry(path.clone()).or_default().push(oxc_diagnostic);
225+
}
226+
Err(e) => {
227+
return Err(format!("Failed to read from tsgolint stdout: {e}"));
228+
}
171229
}
230+
}
172231

173-
for (file_path, diagnostics) in oxc_diagnostics {
174-
let diagnostics = DiagnosticService::wrap_diagnostics(
175-
self.cwd.clone(),
176-
file_path.clone(),
177-
&read_to_string(&file_path).unwrap_or_else(|_| String::new()),
178-
diagnostics,
179-
);
180-
error_sender
181-
.send((file_path.clone(), diagnostics))
182-
.expect("Failed to send diagnostic");
183-
}
232+
Ok(())
233+
});
184234

185-
Ok(())
186-
}
235+
// Wait for process to complete and stdout processing to finish
236+
let exit_status = child.wait().expect("Failed to wait for tsgolint process");
237+
let stdout_result = stdout_handler.join();
238+
239+
if !exit_status.success() {
240+
return Err(format!("tsgolint process exited with status: {exit_status}"));
241+
}
187242

188-
Err(err) => Err(format!("Failed to parse tsgolint output: {err}")),
243+
match stdout_result {
244+
Ok(Ok(())) => Ok(()),
245+
Ok(Err(err)) => Err(err),
246+
Err(_) => Err("Failed to join stdout processing thread".to_string()),
189247
}
190248
});
191249

@@ -304,27 +362,9 @@ impl MessageType {
304362
}
305363
}
306364

307-
/// Parses the binary output from `tsgolint` and returns the diagnostic data.
365+
/// Parses a single message from the binary tsgolint output.
308366
// Messages are encoded as follows:
309367
// | Payload Size (uint32 LE) - 4 bytes | Message Type (uint8) - 1 byte | Payload |
310-
pub fn parse_tsgolint_output(output: &[u8]) -> Result<Vec<TsGoLintDiagnostic>, String> {
311-
let mut diagnostics: Vec<TsGoLintDiagnostic> = Vec::new();
312-
313-
// Parse the output binary data
314-
let mut cursor = std::io::Cursor::new(output);
315-
316-
while cursor.position() < output.len() as u64 {
317-
match parse_single_message(&mut cursor) {
318-
Ok(Some(diagnostic)) => diagnostics.push(diagnostic),
319-
// Do nothing if we successfully parsed a message but it was not a diagnostic we want to add
320-
Ok(None) => {}
321-
Err(e) => return Err(format!("Failed to parse tsgolint output: {e}")),
322-
}
323-
}
324-
325-
Ok(diagnostics)
326-
}
327-
328368
fn parse_single_message(
329369
cursor: &mut std::io::Cursor<&[u8]>,
330370
) -> Result<Option<TsGoLintDiagnostic>, String> {

0 commit comments

Comments
 (0)