Skip to content

Commit f443555

Browse files
fix(core): enable history lookup on windows (#7457)
- Add portable history log id helper to support inode-like tracking on Unix and creation time on Windows - Refactor history metadata and lookup to share code paths and allow nonzero log ids across platforms - Add coverage for lookup stability after appends
1 parent ff4ca99 commit f443555

File tree

1 file changed

+148
-50
lines changed

1 file changed

+148
-50
lines changed

codex-rs/core/src/message_history.rs

Lines changed: 148 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use std::fs::File;
1818
use std::fs::OpenOptions;
1919
use std::io::Result;
2020
use std::io::Write;
21+
use std::path::Path;
2122
use std::path::PathBuf;
2223

2324
use serde::Deserialize;
@@ -42,7 +43,7 @@ const HISTORY_FILENAME: &str = "history.jsonl";
4243
const MAX_RETRIES: usize = 10;
4344
const RETRY_SLEEP: Duration = Duration::from_millis(100);
4445

45-
#[derive(Serialize, Deserialize, Debug, Clone)]
46+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
4647
pub struct HistoryEntry {
4748
pub session_id: String,
4849
pub ts: u64,
@@ -142,23 +143,54 @@ pub(crate) async fn append_entry(
142143
/// the current number of entries by counting newline characters.
143144
pub(crate) async fn history_metadata(config: &Config) -> (u64, usize) {
144145
let path = history_filepath(config);
146+
history_metadata_for_file(&path).await
147+
}
145148

146-
#[cfg(unix)]
147-
let log_id = {
148-
use std::os::unix::fs::MetadataExt;
149-
// Obtain metadata (async) to get the identifier.
150-
let meta = match fs::metadata(&path).await {
151-
Ok(m) => m,
152-
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (0, 0),
153-
Err(_) => return (0, 0),
154-
};
155-
meta.ino()
149+
/// Given a `log_id` (on Unix this is the file's inode number,
150+
/// on Windows this is the file's creation time) and a zero-based
151+
/// `offset`, return the corresponding `HistoryEntry` if the identifier matches
152+
/// the current history file **and** the requested offset exists. Any I/O or
153+
/// parsing errors are logged and result in `None`.
154+
///
155+
/// Note this function is not async because it uses a sync advisory file
156+
/// locking API.
157+
#[cfg(any(unix, windows))]
158+
pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<HistoryEntry> {
159+
let path = history_filepath(config);
160+
lookup_history_entry(&path, log_id, offset)
161+
}
162+
163+
/// On Unix systems, ensure the file permissions are `0o600` (rw-------). If the
164+
/// permissions cannot be changed the error is propagated to the caller.
165+
#[cfg(unix)]
166+
async fn ensure_owner_only_permissions(file: &File) -> Result<()> {
167+
let metadata = file.metadata()?;
168+
let current_mode = metadata.permissions().mode() & 0o777;
169+
if current_mode != 0o600 {
170+
let mut perms = metadata.permissions();
171+
perms.set_mode(0o600);
172+
let perms_clone = perms.clone();
173+
let file_clone = file.try_clone()?;
174+
tokio::task::spawn_blocking(move || file_clone.set_permissions(perms_clone)).await??;
175+
}
176+
Ok(())
177+
}
178+
179+
#[cfg(windows)]
180+
// On Windows, simply succeed.
181+
async fn ensure_owner_only_permissions(_file: &File) -> Result<()> {
182+
Ok(())
183+
}
184+
185+
async fn history_metadata_for_file(path: &Path) -> (u64, usize) {
186+
let log_id = match fs::metadata(path).await {
187+
Ok(metadata) => history_log_id(&metadata).unwrap_or(0),
188+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (0, 0),
189+
Err(_) => return (0, 0),
156190
};
157-
#[cfg(not(unix))]
158-
let log_id = 0u64;
159191

160192
// Open the file.
161-
let mut file = match fs::File::open(&path).await {
193+
let mut file = match fs::File::open(path).await {
162194
Ok(f) => f,
163195
Err(_) => return (log_id, 0),
164196
};
@@ -179,21 +211,12 @@ pub(crate) async fn history_metadata(config: &Config) -> (u64, usize) {
179211
(log_id, count)
180212
}
181213

182-
/// Given a `log_id` (on Unix this is the file's inode number) and a zero-based
183-
/// `offset`, return the corresponding `HistoryEntry` if the identifier matches
184-
/// the current history file **and** the requested offset exists. Any I/O or
185-
/// parsing errors are logged and result in `None`.
186-
///
187-
/// Note this function is not async because it uses a sync advisory file
188-
/// locking API.
189-
#[cfg(unix)]
190-
pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<HistoryEntry> {
214+
#[cfg(any(unix, windows))]
215+
fn lookup_history_entry(path: &Path, log_id: u64, offset: usize) -> Option<HistoryEntry> {
191216
use std::io::BufRead;
192217
use std::io::BufReader;
193-
use std::os::unix::fs::MetadataExt;
194218

195-
let path = history_filepath(config);
196-
let file: File = match OpenOptions::new().read(true).open(&path) {
219+
let file: File = match OpenOptions::new().read(true).open(path) {
197220
Ok(f) => f,
198221
Err(e) => {
199222
tracing::warn!(error = %e, "failed to open history file");
@@ -209,7 +232,9 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<Hist
209232
}
210233
};
211234

212-
if metadata.ino() != log_id {
235+
let current_log_id = history_log_id(&metadata)?;
236+
237+
if log_id != 0 && current_log_id != log_id {
213238
return None;
214239
}
215240

@@ -256,31 +281,104 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<Hist
256281
None
257282
}
258283

259-
/// Fallback stub for non-Unix systems: currently always returns `None`.
260-
#[cfg(not(unix))]
261-
pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<HistoryEntry> {
262-
let _ = (log_id, offset, config);
263-
None
264-
}
284+
fn history_log_id(metadata: &std::fs::Metadata) -> Option<u64> {
285+
#[cfg(unix)]
286+
{
287+
use std::os::unix::fs::MetadataExt;
288+
Some(metadata.ino())
289+
}
265290

266-
/// On Unix systems ensure the file permissions are `0o600` (rw-------). If the
267-
/// permissions cannot be changed the error is propagated to the caller.
268-
#[cfg(unix)]
269-
async fn ensure_owner_only_permissions(file: &File) -> Result<()> {
270-
let metadata = file.metadata()?;
271-
let current_mode = metadata.permissions().mode() & 0o777;
272-
if current_mode != 0o600 {
273-
let mut perms = metadata.permissions();
274-
perms.set_mode(0o600);
275-
let perms_clone = perms.clone();
276-
let file_clone = file.try_clone()?;
277-
tokio::task::spawn_blocking(move || file_clone.set_permissions(perms_clone)).await??;
291+
#[cfg(windows)]
292+
{
293+
use std::os::windows::fs::MetadataExt;
294+
Some(metadata.creation_time())
278295
}
279-
Ok(())
280296
}
281297

282-
#[cfg(not(unix))]
283-
async fn ensure_owner_only_permissions(_file: &File) -> Result<()> {
284-
// For now, on non-Unix, simply succeed.
285-
Ok(())
298+
#[cfg(all(test, any(unix, windows)))]
299+
mod tests {
300+
use super::*;
301+
use pretty_assertions::assert_eq;
302+
use std::fs::File;
303+
use std::io::Write;
304+
use tempfile::TempDir;
305+
306+
#[tokio::test]
307+
async fn lookup_reads_history_entries() {
308+
let temp_dir = TempDir::new().expect("create temp dir");
309+
let history_path = temp_dir.path().join(HISTORY_FILENAME);
310+
311+
let entries = vec![
312+
HistoryEntry {
313+
session_id: "first-session".to_string(),
314+
ts: 1,
315+
text: "first".to_string(),
316+
},
317+
HistoryEntry {
318+
session_id: "second-session".to_string(),
319+
ts: 2,
320+
text: "second".to_string(),
321+
},
322+
];
323+
324+
let mut file = File::create(&history_path).expect("create history file");
325+
for entry in &entries {
326+
writeln!(
327+
file,
328+
"{}",
329+
serde_json::to_string(entry).expect("serialize history entry")
330+
)
331+
.expect("write history entry");
332+
}
333+
334+
let (log_id, count) = history_metadata_for_file(&history_path).await;
335+
assert_eq!(count, entries.len());
336+
337+
let second_entry =
338+
lookup_history_entry(&history_path, log_id, 1).expect("fetch second history entry");
339+
assert_eq!(second_entry, entries[1]);
340+
}
341+
342+
#[tokio::test]
343+
async fn lookup_uses_stable_log_id_after_appends() {
344+
let temp_dir = TempDir::new().expect("create temp dir");
345+
let history_path = temp_dir.path().join(HISTORY_FILENAME);
346+
347+
let initial = HistoryEntry {
348+
session_id: "first-session".to_string(),
349+
ts: 1,
350+
text: "first".to_string(),
351+
};
352+
let appended = HistoryEntry {
353+
session_id: "second-session".to_string(),
354+
ts: 2,
355+
text: "second".to_string(),
356+
};
357+
358+
let mut file = File::create(&history_path).expect("create history file");
359+
writeln!(
360+
file,
361+
"{}",
362+
serde_json::to_string(&initial).expect("serialize initial entry")
363+
)
364+
.expect("write initial entry");
365+
366+
let (log_id, count) = history_metadata_for_file(&history_path).await;
367+
assert_eq!(count, 1);
368+
369+
let mut append = std::fs::OpenOptions::new()
370+
.append(true)
371+
.open(&history_path)
372+
.expect("open history file for append");
373+
writeln!(
374+
append,
375+
"{}",
376+
serde_json::to_string(&appended).expect("serialize appended entry")
377+
)
378+
.expect("append history entry");
379+
380+
let fetched =
381+
lookup_history_entry(&history_path, log_id, 1).expect("lookup appended history entry");
382+
assert_eq!(fetched, appended);
383+
}
286384
}

0 commit comments

Comments
 (0)