feat: 编辑器实时探测并显示已关联虚拟环境
fix: 按系统 ANSI 代码页解码子进程输出,修复中文日志乱码
This commit is contained in:
@@ -10,6 +10,7 @@ use std::{
|
||||
};
|
||||
|
||||
use chrono::Utc;
|
||||
use encoding_rs::Encoding;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
@@ -39,6 +40,77 @@ const TERMINATION_WAIT_SECS: u64 = 5;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const DEFAULT_RETENTION_DAYS: u64 = 30;
|
||||
const DEFAULT_MAX_TOTAL_LOG_BYTES: u64 = 512 * 1024 * 1024;
|
||||
const LOG_ENCODING_ENV: &str = "TASK_PILOT_LOG_ENCODING";
|
||||
|
||||
/// 子进程输出的解码方式。
|
||||
///
|
||||
/// `TASK_PILOT_LOG_ENCODING=auto`(默认)逐行先按 UTF-8 严格解码,失败时回退到系统
|
||||
/// ANSI 代码页;填写编码标签(`gbk`、`utf-8`、`windows-1251` 等)则始终按该编码解码。
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct OutputEncoding {
|
||||
forced: Option<&'static Encoding>,
|
||||
fallback: &'static Encoding,
|
||||
}
|
||||
|
||||
impl OutputEncoding {
|
||||
pub fn from_env() -> Self {
|
||||
let fallback = ansi_code_page_encoding();
|
||||
let forced = std::env::var(LOG_ENCODING_ENV).ok().and_then(|value| {
|
||||
let label = value.trim().to_ascii_lowercase();
|
||||
if label.is_empty() || label == "auto" {
|
||||
None
|
||||
} else {
|
||||
Encoding::for_label(label.as_bytes())
|
||||
}
|
||||
});
|
||||
|
||||
Self { forced, fallback }
|
||||
}
|
||||
|
||||
pub fn decode(&self, bytes: &[u8]) -> String {
|
||||
if let Some(encoding) = self.forced {
|
||||
let (text, _) = encoding.decode_without_bom_handling(bytes);
|
||||
return text.into_owned();
|
||||
}
|
||||
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(text) => text.to_owned(),
|
||||
Err(_) => {
|
||||
let (text, _) = self.fallback.decode_without_bom_handling(bytes);
|
||||
text.into_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实际使用的编码名,写入运行日志便于排查乱码。
|
||||
pub fn name(&self) -> &'static str {
|
||||
self.forced.unwrap_or(self.fallback).name()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn ansi_code_page_encoding() -> &'static Encoding {
|
||||
let code_page = unsafe { windows_sys::Win32::Globalization::GetACP() };
|
||||
code_page_encoding(code_page)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn ansi_code_page_encoding() -> &'static Encoding {
|
||||
encoding_rs::UTF_8
|
||||
}
|
||||
|
||||
fn code_page_encoding(code_page: u32) -> &'static Encoding {
|
||||
let label = match code_page {
|
||||
936 => "gbk".to_owned(),
|
||||
950 => "big5".to_owned(),
|
||||
932 => "shift_jis".to_owned(),
|
||||
949 => "euc-kr".to_owned(),
|
||||
65001 => "utf-8".to_owned(),
|
||||
other => format!("windows-{other}"),
|
||||
};
|
||||
|
||||
Encoding::for_label(label.as_bytes()).unwrap_or(encoding_rs::UTF_8)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LogRetentionConfig {
|
||||
@@ -92,6 +164,7 @@ struct ExecutorInner {
|
||||
http: reqwest::Client,
|
||||
log_config: LogRetentionConfig,
|
||||
cleanup_lock: AsyncMutex<()>,
|
||||
output_encoding: OutputEncoding,
|
||||
}
|
||||
|
||||
struct RunningHandle {
|
||||
@@ -107,6 +180,7 @@ struct LogSink {
|
||||
run_id: i64,
|
||||
events: broadcast::Sender<ExecutionEvent>,
|
||||
secrets: Arc<Vec<String>>,
|
||||
encoding: OutputEncoding,
|
||||
}
|
||||
|
||||
struct ScriptOutcome {
|
||||
@@ -124,6 +198,8 @@ enum ProcessEnd {
|
||||
impl TaskExecutor {
|
||||
pub fn new(pool: SqlitePool, logs_dir: PathBuf, log_config: LogRetentionConfig) -> Self {
|
||||
let (events, _) = broadcast::channel(512);
|
||||
let output_encoding = OutputEncoding::from_env();
|
||||
tracing::info!(encoding = output_encoding.name(), "task output decoding");
|
||||
Self {
|
||||
inner: Arc::new(ExecutorInner {
|
||||
pool,
|
||||
@@ -138,6 +214,7 @@ impl TaskExecutor {
|
||||
.unwrap_or_default(),
|
||||
log_config,
|
||||
cleanup_lock: AsyncMutex::new(()),
|
||||
output_encoding,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -353,6 +430,7 @@ impl TaskExecutor {
|
||||
run_id,
|
||||
self.inner.events.clone(),
|
||||
secrets,
|
||||
self.inner.output_encoding,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -517,6 +595,7 @@ impl TaskExecutor {
|
||||
run_id,
|
||||
self.inner.events.clone(),
|
||||
sensitive_values(&task.action),
|
||||
self.inner.output_encoding,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -638,7 +717,7 @@ impl TaskExecutor {
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let body_text = String::from_utf8_lossy(&response_body);
|
||||
let body_text = self.inner.output_encoding.decode(&response_body);
|
||||
if !body_text.is_empty() {
|
||||
sink.http(&body_text).await;
|
||||
}
|
||||
@@ -697,6 +776,7 @@ impl LogSink {
|
||||
run_id: i64,
|
||||
events: broadcast::Sender<ExecutionEvent>,
|
||||
secrets: Vec<String>,
|
||||
encoding: OutputEncoding,
|
||||
) -> std::io::Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
@@ -721,9 +801,14 @@ impl LogSink {
|
||||
run_id,
|
||||
events,
|
||||
secrets: Arc::new(secrets),
|
||||
encoding,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_output(&self, bytes: &[u8]) -> String {
|
||||
self.encoding.decode(bytes)
|
||||
}
|
||||
|
||||
async fn append(&self, text: &str) {
|
||||
let redacted = redact_text(text, &self.secrets);
|
||||
let bytes = redacted.as_bytes();
|
||||
@@ -783,7 +868,7 @@ where
|
||||
match reader.read_until(b'\n', &mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let text = String::from_utf8_lossy(&line);
|
||||
let text = sink.decode_output(&line);
|
||||
sink.append(&format!("[{stream}] {text}")).await;
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -957,6 +1042,54 @@ mod tests {
|
||||
assert!(!is_sensitive_name("PYTHONUNBUFFERED"));
|
||||
}
|
||||
|
||||
fn auto_encoding(fallback: &'static Encoding) -> OutputEncoding {
|
||||
OutputEncoding {
|
||||
forced: None,
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_utf8_output_without_touching_it() {
|
||||
let encoding = auto_encoding(encoding_rs::GBK);
|
||||
|
||||
assert_eq!(encoding.decode("中文输出".as_bytes()), "中文输出");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_gbk_output_through_the_system_fallback() {
|
||||
let encoding = auto_encoding(encoding_rs::GBK);
|
||||
// "中文" 的 GBK 编码
|
||||
let gbk = [0xD6, 0xD0, 0xCE, 0xC4];
|
||||
|
||||
assert_eq!(encoding.decode(&gbk), "中文");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_encoding_skips_utf8_sniffing() {
|
||||
let encoding = OutputEncoding {
|
||||
forced: Some(encoding_rs::GBK),
|
||||
fallback: encoding_rs::UTF_8,
|
||||
};
|
||||
let gbk = [0xD6, 0xD0, 0xCE, 0xC4];
|
||||
|
||||
assert_eq!(encoding.decode(&gbk), "中文");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undecodable_bytes_still_produce_text() {
|
||||
let encoding = auto_encoding(encoding_rs::UTF_8);
|
||||
|
||||
assert!(encoding.decode(&[0xFF, 0xFE, 0xFF]).contains('\u{fffd}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_windows_code_pages_to_encodings() {
|
||||
assert_eq!(code_page_encoding(936).name(), "GBK");
|
||||
assert_eq!(code_page_encoding(65001).name(), "UTF-8");
|
||||
assert_eq!(code_page_encoding(1252).name(), "windows-1252");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_enforces_total_log_capacity() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user