Compare commits

...
2 Commits
Author SHA1 Message Date
yynxxxxxandzjjszmx 726de0fe63 Release v0.3.24
Include the Windows index replacement compatibility fix and the verified
large-session and directory-browser changes. Keep v0.3.23 unpublished.

Co-authored-by: zjjszmx <81135758+zjjszmx@users.noreply.github.com>
2026-10-01 14:59:10 +08:00
yynxxxxxandzjjszmx 7d0b2891b9 fix(sessions): release index handles before Windows replacement
Close both owned index handles before atomic installation and retain the
final snapshot verification. Test birth timestamps at Windows SystemTime
precision and run the full Windows suite in the shipping release profile.

Co-authored-by: zjjszmx <81135758+zjjszmx@users.noreply.github.com>
2026-10-01 14:57:27 +08:00
9 changed files with 136 additions and 25 deletions
+1 -1
View File
@@ -223,7 +223,7 @@ jobs:
- name: Test Windows filesystem compatibility
if: matrix.platform == 'windows-latest'
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1
run: cargo test --release --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1
- name: Import Apple signing certificate and notarization key
if: matrix.macos
+3 -1
View File
@@ -2,13 +2,15 @@
记录 Codex-X 的新功能、体验改进和问题修复。
## [v0.3.23] - 2026-10-01
## [v0.3.24] - 2026-10-01
- 会话管理新增工作文件夹侧栏、文件夹与会话置顶,并使用虚拟列表减少大量会话的渲染开销。
- 会话日志改为流式读取和磁盘快照处理,大日志及超长单条记录也能同步;读取、写入或复查失败时明确报错并执行安全回滚,避免日志、数据库索引与同步状态不一致。
- 修复文件夹路径大小写混淆导致显示错误会话、置顶联动和缓存串用的问题,按实际文件系统身份区分目录或合并同目录别名。
- 置顶按 Codex 配置目录分别保存,文件夹暂时不可读或重建后按原始路径保留置顶。旧 PR 预览版的 v1 置顶缓存不自动迁移,需重新置顶。
- 修复 Windows 下会话索引原子替换被文件句柄阻止的问题。
## [v0.3.22] - 2026-09-30
- 修复第三方模型被错误识别为“仅文本”而无法输入图片的问题,并自动升级旧版生成的模型目录。需供应商模型本身支持图片,更新菜单后重启 Codex 生效。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.3.23",
"version": "0.3.24",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+1 -1
View File
@@ -492,7 +492,7 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.3.23"
version = "0.3.24"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.3.23"
version = "0.3.24"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
+120 -13
View File
@@ -730,7 +730,7 @@ fn preflight_session_jsonl_cleanup(codex_dir: &Path) -> Result<()> {
fn commit_index_cleanup(
path: &Path,
source: &fs::File,
source: fs::File,
prepared: PreparedJsonlCleanup,
) -> Result<usize> {
if prepared.removed == 0 {
@@ -738,7 +738,7 @@ fn commit_index_cleanup(
}
ensure_cleanup_source_unchanged(
path,
source,
&source,
&prepared.original_stamp,
&prepared.original_hash,
)?;
@@ -750,11 +750,40 @@ fn commit_index_cleanup(
.output
.as_file()
.set_permissions(permissions)
.map_err(|error| io_err(path, error))?;
prepared
.output
.persist(path)
.map_err(|error| io_err(path, error.error))?;
.map_err(|error| {
io_err(
path,
std::io::Error::new(error.kind(), format!("设置临时会话索引权限失败: {error}")),
)
})?;
// tempfile's Windows persist uses SetFileAttributesW followed by MoveFileExW.
// Close both our destination and staging handles before that replacement;
// the index has no history-style inode/append-lock requirement.
drop(source);
let output = prepared.output.into_temp_path();
let verifier = fs::File::open(path).map_err(|error| {
io_err(
path,
std::io::Error::new(error.kind(), format!("最终复核会话索引失败: {error}")),
)
})?;
let unchanged = ensure_cleanup_source_unchanged(
path,
&verifier,
&prepared.original_stamp,
&prepared.original_hash,
);
drop(verifier);
unchanged?;
output.persist(path).map_err(|error| {
io_err(
path,
std::io::Error::new(
error.error.kind(),
format!("原子替换会话索引失败: {}", error.error),
),
)
})?;
Ok(prepared.removed)
}
@@ -769,7 +798,7 @@ fn remove_jsonl_session_entries(
Err(error) => return Err(io_err(path, error)),
};
let prepared = prepare_jsonl_cleanup(path, &mut source, id_keys, session_ids, false)?;
commit_index_cleanup(path, &source, prepared)
commit_index_cleanup(path, source, prepared)
}
fn remove_session_index_entries(codex_dir: &Path, session_ids: &HashSet<String>) -> Result<usize> {
@@ -1777,22 +1806,100 @@ mod tests {
drop(external);
let grown = fs::read(&path).unwrap();
let result = if history {
commit_history_cleanup_with_writer(&path, &mut source, prepared, |_, _, _, _| {
panic!("growth must be rejected before writing history")
})
let result = commit_history_cleanup_with_writer(
&path,
&mut source,
prepared,
|_, _, _, _| panic!("growth must be rejected before writing history"),
);
drop(source);
result
} else {
commit_index_cleanup(&path, &source, prepared)
commit_index_cleanup(&path, source, prepared)
};
assert!(result
.expect_err("a stale staged result must not replace appended data")
.to_string()
.contains("发生变化"));
assert_eq!(fs::read(&path).unwrap(), grown);
drop(source);
fs::remove_dir_all(codex_dir).unwrap();
}
}
#[cfg(windows)]
#[test]
fn index_replacement_closes_a_delete_denying_source_handle_and_preserves_all_kept_bytes() {
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
let codex_dir = temp_codex_dir();
let path = codex_dir.join("session_index.jsonl");
fs::write(
&path,
b"{\"id\":\"selected\"}\r\nnot-json\r\n{\"id\":\"kept\"}",
)
.unwrap();
let mut initial = fs::File::open(&path).unwrap();
let prepared = prepare_jsonl_cleanup(
&path,
&mut initial,
&["id"],
&HashSet::from(["selected".to_string()]),
false,
)
.unwrap();
drop(initial);
// Allow all verification reads while denying DELETE sharing, which is
// specifically required for Windows rename/replacement operations.
let source = fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
.open(&path)
.unwrap();
assert_eq!(commit_index_cleanup(&path, source, prepared).unwrap(), 1);
assert_eq!(fs::read(&path).unwrap(), b"not-json\r\n{\"id\":\"kept\"}");
fs::remove_dir_all(codex_dir).unwrap();
}
#[test]
fn index_replacement_rejects_same_length_modified_bytes_even_with_restored_mtime() {
let codex_dir = temp_codex_dir();
let path = codex_dir.join("session_index.jsonl");
let original = b"{\"id\":\"selected\"}\n{\"id\":\"kept\"}\n";
fs::write(&path, original).unwrap();
let mut source = fs::File::open(&path).unwrap();
let prepared = prepare_jsonl_cleanup(
&path,
&mut source,
&["id"],
&HashSet::from(["selected".to_string()]),
false,
)
.unwrap();
let mtime = prepared.original_stamp.modified.unwrap();
let mut changed = original.to_vec();
let last = changed.iter().rposition(|byte| *byte == b't').unwrap();
changed[last] = b'x';
let mut external = fs::OpenOptions::new().write(true).open(&path).unwrap();
external.write_all(&changed).unwrap();
external.sync_all().unwrap();
drop(external);
// Set the timestamp through a fresh handle after closing the data writer;
// Windows may otherwise finalize a pending write time when it closes.
let timestamp = fs::OpenOptions::new().write(true).open(&path).unwrap();
timestamp
.set_times(fs::FileTimes::new().set_modified(mtime))
.unwrap();
drop(timestamp);
assert_eq!(fs::metadata(&path).unwrap().modified().unwrap(), mtime);
assert_eq!(fs::metadata(&path).unwrap().len(), original.len() as u64);
let error = commit_index_cleanup(&path, source, prepared)
.expect_err("a matching length and timestamp must not bypass the source digest");
assert!(error.to_string().contains("发生变化"));
assert_eq!(fs::read(&path).unwrap(), changed);
fs::remove_dir_all(codex_dir).unwrap();
}
#[test]
fn history_commit_write_failure_restores_backup_and_keeps_its_lock_and_inode() {
for fail_after_truncate in [false, true] {
@@ -366,7 +366,9 @@ mod tests {
#[test]
fn creation_generation_includes_epoch_sign_full_seconds_and_nanoseconds() {
use std::time::Duration;
let created = UNIX_EPOCH + Duration::new(1234, 17);
// Windows SystemTime uses 100 ns intervals; use exactly representable
// fractions while still testing that subsecond birth precision matters.
let created = UNIX_EPOCH + Duration::new(1234, 100);
let original = unix_directory_identity(1, 9, Some(created)).unwrap();
assert!(original.birth_verified);
assert_eq!(
@@ -374,9 +376,9 @@ mod tests {
unix_directory_identity(1, 9, Some(created)).unwrap()
);
for new_generation in [
UNIX_EPOCH + Duration::new(1234, 18),
UNIX_EPOCH + Duration::new(1234 + (1u64 << 32), 17),
UNIX_EPOCH - Duration::new(1234, 17),
UNIX_EPOCH + Duration::new(1234, 200),
UNIX_EPOCH + Duration::new(1234 + (1u64 << 32), 100),
UNIX_EPOCH - Duration::new(1234, 100),
] {
assert_ne!(
original,
@@ -387,7 +389,7 @@ mod tests {
let file_id = [7; 16];
assert_ne!(
windows_directory_identity(1, &file_id, Some(created)),
windows_directory_identity(1, &file_id, Some(created + Duration::from_nanos(1)))
windows_directory_identity(1, &file_id, Some(created + Duration::from_nanos(100)))
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.3.23",
"version": "0.3.24",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.3.23",
"version": "0.3.24",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",