mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Bound memory baseline files and recover incomplete snapshots (#57240)
## Why Memory baseline snapshots need bounded file reads, and recovery must keep existing memory files visible to consolidation rather than treating them as already processed. ## What changed - Limit baseline files to 10 MiB, skip oversized working files, and check stored blob sizes before reading them. - Version baselines and replace missing, outdated, or unusable metadata with an empty baseline, preserving working files as additions. Also invalidate the baseline when a tracked file grows beyond the limit. - Serialize baseline initialization, resets, and diffs with a cross-process lock. Isolate repository access and pin index writes to the baseline worktree. ## Testing Add coverage for oversized files, legacy baseline migration, interrupted resets, cross-process locking, and consolidation with unchanged inputs after baseline recovery. GitOrigin-RevId: 34ecc9e5700a88ed37eff83a90f08d8a9b0d9f29
This commit is contained in:
committed by
copyberry
parent
3d8ca703aa
commit
333d7bde0f
@@ -6,14 +6,23 @@ use gix::objs::tree::EntryKind;
|
||||
use gix::objs::tree::EntryMode;
|
||||
use similar::TextDiff;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::fs::FileTypeExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tokio::task;
|
||||
|
||||
use crate::operations::run_git_for_status;
|
||||
|
||||
// Match the default file limit for automatic ghost snapshots. A baseline is derived data.
|
||||
const MAX_BASELINE_FILE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
const BASELINE_VERSION_FILE: &str = "codex-baseline-version";
|
||||
const BASELINE_VERSION: &[u8] = b"1\n";
|
||||
|
||||
const BASELINE_COMMIT_MESSAGE: &str =
|
||||
"Initialize Codex git baseline\n\nCo-authored-by: Codex <noreply@openai.com>";
|
||||
|
||||
@@ -67,96 +76,182 @@ struct GitBaselineFileEntry {
|
||||
/// This is intentionally destructive for `root/.git`. It is meant for internal directories where
|
||||
/// git is used only as a baseline/diff implementation detail, not for user repositories.
|
||||
pub async fn reset_git_repository(root: &Path) -> anyhow::Result<()> {
|
||||
let root = root.to_path_buf();
|
||||
task::spawn_blocking(move || reset_git_repository_sync(&root)).await?
|
||||
with_baseline_lock(root, |root| {
|
||||
reset_git_repository_sync(root, BaselineContents::Current)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Ensures `root` has a usable git baseline repository.
|
||||
/// Ensures the internal directory has a usable, size-bounded baseline.
|
||||
///
|
||||
/// Existing usable `.git/` metadata is preserved. Missing or unusable metadata is replaced with a
|
||||
/// fresh one-commit baseline.
|
||||
/// Old or incomplete baselines are discarded without reading their blobs. The replacement is
|
||||
/// empty so all eligible working files remain visible to the next consolidation as additions.
|
||||
pub async fn ensure_git_baseline_repository(root: &Path) -> anyhow::Result<()> {
|
||||
let root = root.to_path_buf();
|
||||
task::spawn_blocking(move || {
|
||||
fs::create_dir_all(&root)
|
||||
.with_context(|| format!("create git baseline root {}", root.display()))?;
|
||||
if root.join(".git").is_dir()
|
||||
&& let Ok(repo) = gix::open(&root)
|
||||
&& head_file_entries(&repo).is_ok()
|
||||
{
|
||||
with_baseline_lock(root, ensure_git_baseline_repository_sync).await
|
||||
}
|
||||
|
||||
fn ensure_git_baseline_repository_sync(root: &Path) -> anyhow::Result<()> {
|
||||
let git_dir = root.join(".git");
|
||||
let mut version = Vec::new();
|
||||
// Never follow a gitfile or directory symlink into another repository.
|
||||
if fs::symlink_metadata(&git_dir).is_ok_and(|metadata| metadata.is_dir())
|
||||
&& fs::File::open(git_dir.join(BASELINE_VERSION_FILE))
|
||||
.and_then(|file| {
|
||||
file.take(BASELINE_VERSION.len() as u64 + 1)
|
||||
.read_to_end(&mut version)
|
||||
})
|
||||
.is_ok()
|
||||
&& version == BASELINE_VERSION
|
||||
&& let Ok(repo) = gix::open::Options::isolated().open(&git_dir)
|
||||
{
|
||||
let repo = repo.to_thread_local();
|
||||
if repo.common_dir() == repo.git_dir() && head_file_entries(&repo).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
reset_git_repository_sync(&root)
|
||||
}
|
||||
reset_git_repository_sync(root, BaselineContents::Empty)
|
||||
}
|
||||
|
||||
// Imports and background consolidation can overlap, including across app-server processes.
|
||||
// A nested .git keeps the lock outside both the replaceable baseline and memory scans.
|
||||
async fn with_baseline_lock<T: Send + 'static>(
|
||||
root: &Path,
|
||||
operation: impl FnOnce(&Path) -> anyhow::Result<T> + Send + 'static,
|
||||
) -> anyhow::Result<T> {
|
||||
let root = root.to_owned();
|
||||
task::spawn_blocking(move || {
|
||||
fs::create_dir_all(&root)?;
|
||||
let root = fs::canonicalize(root)?;
|
||||
let control = baseline_control(&root);
|
||||
for dir in [root.join(".codex-baseline"), control.clone()] {
|
||||
match fs::create_dir(&dir) {
|
||||
Ok(()) => {}
|
||||
Err(err)
|
||||
if err.kind() == std::io::ErrorKind::AlreadyExists
|
||||
&& fs::symlink_metadata(&dir)?.is_dir()
|
||||
&& fs::canonicalize(&dir)? == dir => {}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
anyhow::ensure!(
|
||||
!control.join("HEAD").try_exists()?,
|
||||
"baseline control directory contains a repository"
|
||||
);
|
||||
let lock = fs::File::options()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(control.join("codex-baseline.lock"))?;
|
||||
lock.lock().context("lock git baseline")?;
|
||||
operation(&root)
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
fn reset_git_repository_sync(root: &Path) -> anyhow::Result<()> {
|
||||
fs::create_dir_all(root)
|
||||
.with_context(|| format!("create git baseline root {}", root.display()))?;
|
||||
remove_git_metadata(root)?;
|
||||
let repo = gix::init(root).with_context(|| format!("init git repo {}", root.display()))?;
|
||||
commit_current_tree(&repo, BASELINE_COMMIT_MESSAGE)?;
|
||||
write_index_from_head(root)?;
|
||||
fn baseline_control(root: &Path) -> PathBuf {
|
||||
root.join(".codex-baseline").join(".git")
|
||||
}
|
||||
|
||||
fn discard_baseline_path(path: &Path) -> anyhow::Result<()> {
|
||||
match path.symlink_metadata() {
|
||||
#[cfg(windows)]
|
||||
Ok(meta) if meta.file_type().is_symlink_dir() => fs::remove_dir(path)?,
|
||||
Ok(meta) if meta.is_dir() => {
|
||||
// Invalidate before deletion: interruption must not leave trusted partial metadata.
|
||||
match fs::remove_file(path.join(BASELINE_VERSION_FILE)) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
fs::remove_dir_all(path)?;
|
||||
}
|
||||
Ok(_) => fs::remove_file(path)?,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the diff between the latest baseline reset and the current directory contents.
|
||||
enum BaselineContents {
|
||||
Empty,
|
||||
Current,
|
||||
}
|
||||
|
||||
fn reset_git_repository_sync(root: &Path, contents: BaselineContents) -> anyhow::Result<()> {
|
||||
let git_dir = root.join(".git");
|
||||
// Only metadata is disposable. An interrupted reset leaves no version marker, so the next
|
||||
// operation retries with an empty baseline rather than trusting a partial snapshot.
|
||||
discard_baseline_path(&git_dir)?;
|
||||
let repo = gix::ThreadSafeRepository::init_opts(
|
||||
root,
|
||||
gix::create::Kind::WithWorktree,
|
||||
gix::create::Options::default(),
|
||||
gix::open::Options::isolated(),
|
||||
)?
|
||||
.to_thread_local();
|
||||
let tree_id = match contents {
|
||||
BaselineContents::Empty => repo.write_object(Tree::default())?.detach(),
|
||||
BaselineContents::Current => write_tree(&repo, root)?,
|
||||
};
|
||||
let signature = codex_signature();
|
||||
let mut time = gix::date::parse::TimeBuf::default();
|
||||
let signature = signature.to_ref(&mut time);
|
||||
repo.commit_as(
|
||||
signature,
|
||||
signature,
|
||||
"HEAD",
|
||||
BASELINE_COMMIT_MESSAGE,
|
||||
tree_id,
|
||||
Vec::<ObjectId>::new(),
|
||||
)?;
|
||||
write_index_from_head(root)?;
|
||||
fs::write(git_dir.join(BASELINE_VERSION_FILE), BASELINE_VERSION)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_index_from_head(root: &Path) -> anyhow::Result<()> {
|
||||
// Pin the index write to this worktree. Relative paths also work with Git for Windows
|
||||
// when Rust has canonicalized the working directory to a verbatim path.
|
||||
run_git_for_status(
|
||||
root,
|
||||
["read-tree", "--reset", "HEAD"],
|
||||
Some(&[
|
||||
("GIT_DIR".into(), ".git".into()),
|
||||
("GIT_COMMON_DIR".into(), ".git".into()),
|
||||
("GIT_INDEX_FILE".into(), ".git/index".into()),
|
||||
("GIT_OBJECT_DIRECTORY".into(), ".git/objects".into()),
|
||||
("GIT_WORK_TREE".into(), ".".into()),
|
||||
]),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the diff between the latest baseline and the current directory's bounded files.
|
||||
pub async fn diff_since_latest_init(root: &Path) -> anyhow::Result<GitBaselineDiff> {
|
||||
let root = root.to_path_buf();
|
||||
task::spawn_blocking(move || {
|
||||
let repo = gix::open(&root).with_context(|| format!("open git repo {}", root.display()))?;
|
||||
let head_entries = head_file_entries(&repo)?;
|
||||
let current_entries = current_file_entries(&repo, &root)?;
|
||||
with_baseline_lock(root, |root| {
|
||||
ensure_git_baseline_repository_sync(root)?;
|
||||
let mut repo = gix::open::Options::isolated().open(root)?.to_thread_local();
|
||||
let mut head_entries = head_file_entries(&repo)?;
|
||||
let mut current_entries = BTreeMap::new();
|
||||
let mut excluded = BTreeSet::new();
|
||||
collect_current_entries(&repo, root, root, &mut current_entries, &mut excluded)?;
|
||||
// Inputs may grow during synchronization, after the initial baseline was prepared.
|
||||
if excluded.iter().any(|path| head_entries.contains_key(path)) {
|
||||
drop(repo);
|
||||
reset_git_repository_sync(root, BaselineContents::Empty)?;
|
||||
repo = gix::open::Options::isolated().open(root)?.to_thread_local();
|
||||
head_entries.clear();
|
||||
}
|
||||
let changes = diff_entries(&head_entries, ¤t_entries);
|
||||
let unified_diff =
|
||||
render_unified_diff(&repo, &root, &head_entries, ¤t_entries, &changes)?;
|
||||
render_unified_diff(&repo, root, &head_entries, ¤t_entries, &changes)?;
|
||||
Ok(GitBaselineDiff {
|
||||
changes,
|
||||
unified_diff,
|
||||
})
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
fn remove_git_metadata(root: &Path) -> anyhow::Result<()> {
|
||||
let git_path = root.join(".git");
|
||||
let metadata = match fs::symlink_metadata(&git_path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(err) => return Err(err).with_context(|| format!("stat {}", git_path.display())),
|
||||
};
|
||||
|
||||
if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() {
|
||||
fs::remove_dir_all(&git_path).with_context(|| format!("remove {}", git_path.display()))
|
||||
} else {
|
||||
fs::remove_file(&git_path).with_context(|| format!("remove {}", git_path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_current_tree(repo: &gix::Repository, message: &str) -> anyhow::Result<()> {
|
||||
let root = repo
|
||||
.workdir()
|
||||
.context("git baseline repo must have a worktree")?;
|
||||
let tree_id = write_tree(repo, root)?;
|
||||
let signature = codex_signature();
|
||||
let mut time = gix::date::parse::TimeBuf::default();
|
||||
let signature_ref = signature.to_ref(&mut time);
|
||||
repo.commit_as(
|
||||
signature_ref,
|
||||
signature_ref,
|
||||
"HEAD",
|
||||
message,
|
||||
tree_id,
|
||||
Vec::<ObjectId>::new(),
|
||||
)
|
||||
.context("commit git baseline repo")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_index_from_head(root: &Path) -> anyhow::Result<()> {
|
||||
run_git_for_status(root, ["read-tree", "--reset", "HEAD"], /*env*/ None)
|
||||
.context("write git baseline index from HEAD")
|
||||
.await
|
||||
}
|
||||
|
||||
fn codex_signature() -> gix::actor::Signature {
|
||||
@@ -195,13 +290,15 @@ fn write_tree(repo: &gix::Repository, dir: &Path) -> anyhow::Result<ObjectId> {
|
||||
oid,
|
||||
});
|
||||
} else if file_type.is_file() {
|
||||
let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
|
||||
let Some((bytes, mode)) = read_baseline_file(&path)? else {
|
||||
continue;
|
||||
};
|
||||
let oid = repo
|
||||
.write_blob(bytes)
|
||||
.with_context(|| format!("write blob {}", path.display()))?
|
||||
.detach();
|
||||
entries.push(Entry {
|
||||
mode: file_mode(&path, EntryKind::Blob)?,
|
||||
mode,
|
||||
filename: os_str_to_bstring(&file_name),
|
||||
oid,
|
||||
});
|
||||
@@ -264,20 +361,12 @@ fn collect_tree_entries(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_file_entries(
|
||||
repo: &gix::Repository,
|
||||
root: &Path,
|
||||
) -> anyhow::Result<BTreeMap<String, GitBaselineFileEntry>> {
|
||||
let mut entries = BTreeMap::new();
|
||||
collect_current_entries(repo, root, root, &mut entries)?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn collect_current_entries(
|
||||
repo: &gix::Repository,
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
entries: &mut BTreeMap<String, GitBaselineFileEntry>,
|
||||
excluded: &mut BTreeSet<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
|
||||
let entry = entry?;
|
||||
@@ -288,14 +377,17 @@ fn collect_current_entries(
|
||||
|
||||
let file_type = entry.file_type()?;
|
||||
if file_type.is_dir() {
|
||||
collect_current_entries(repo, root, &path, entries)?;
|
||||
collect_current_entries(repo, root, &path, entries, excluded)?;
|
||||
} else if file_type.is_file() {
|
||||
let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
|
||||
let Some((bytes, mode)) = read_baseline_file(&path)? else {
|
||||
excluded.insert(relative_slash_path(root, &path)?);
|
||||
continue;
|
||||
};
|
||||
entries.insert(
|
||||
relative_slash_path(root, &path)?,
|
||||
GitBaselineFileEntry {
|
||||
oid: blob_oid(repo, &bytes)?,
|
||||
mode: file_mode(&path, EntryKind::Blob)?,
|
||||
mode,
|
||||
},
|
||||
);
|
||||
} else if file_type.is_symlink() {
|
||||
@@ -313,6 +405,21 @@ fn collect_current_entries(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bounds the read even if a file grows between checking its size and reaching EOF.
|
||||
fn read_baseline_file(path: &Path) -> anyhow::Result<Option<(Vec<u8>, EntryMode)>> {
|
||||
let file = fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
|
||||
let metadata = file.metadata()?;
|
||||
let len = metadata.len();
|
||||
if len > MAX_BASELINE_FILE_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
// Reserve an EOF probe byte so bounded reads of stable files need no reallocation.
|
||||
let mut bytes = Vec::with_capacity(len as usize + 1);
|
||||
file.take(MAX_BASELINE_FILE_BYTES + 1)
|
||||
.read_to_end(&mut bytes)?;
|
||||
Ok((bytes.len() as u64 <= MAX_BASELINE_FILE_BYTES).then_some((bytes, file_mode(&metadata))))
|
||||
}
|
||||
|
||||
fn blob_oid(repo: &gix::Repository, bytes: &[u8]) -> anyhow::Result<ObjectId> {
|
||||
gix::objs::compute_hash(repo.object_hash(), gix::objs::Kind::Blob, bytes)
|
||||
.context("compute git baseline blob oid")
|
||||
@@ -431,6 +538,10 @@ fn render_change_diff(
|
||||
}
|
||||
|
||||
fn read_head_blob(repo: &gix::Repository, entry: &GitBaselineFileEntry) -> anyhow::Result<Vec<u8>> {
|
||||
anyhow::ensure!(
|
||||
repo.find_header(entry.oid)?.size() <= MAX_BASELINE_FILE_BYTES,
|
||||
"baseline blob exceeds the snapshot file limit"
|
||||
);
|
||||
let mut blob = repo.find_blob(entry.oid)?;
|
||||
Ok(blob.take_data())
|
||||
}
|
||||
@@ -444,7 +555,9 @@ fn read_current_file_bytes(root: &Path, relative_path: &str) -> anyhow::Result<V
|
||||
fs::read_link(&path).with_context(|| format!("read symlink {}", path.display()))?;
|
||||
Ok(path_to_bytes(&target))
|
||||
} else {
|
||||
fs::read(&path).with_context(|| format!("read {}", path.display()))
|
||||
read_baseline_file(&path)?
|
||||
.map(|(bytes, _mode)| bytes)
|
||||
.context("file grew beyond the snapshot file limit during diff")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,20 +572,19 @@ fn mode_label(mode: EntryMode) -> &'static str {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn file_mode(path: &Path, default: EntryKind) -> anyhow::Result<EntryMode> {
|
||||
fn file_mode(metadata: &fs::Metadata) -> EntryMode {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mode = fs::metadata(path)?.permissions().mode();
|
||||
Ok(if mode & 0o111 == 0 {
|
||||
default.into()
|
||||
if metadata.permissions().mode() & 0o111 == 0 {
|
||||
EntryKind::Blob.into()
|
||||
} else {
|
||||
EntryKind::BlobExecutable.into()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn file_mode(_path: &Path, default: EntryKind) -> anyhow::Result<EntryMode> {
|
||||
Ok(default.into())
|
||||
fn file_mode(_metadata: &fs::Metadata) -> EntryMode {
|
||||
EntryKind::Blob.into()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -525,6 +637,10 @@ fn path_to_slash_string(path: &Path) -> String {
|
||||
.join("/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "baseline_recovery_tests.rs"]
|
||||
mod recovery_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -533,7 +649,7 @@ mod tests {
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn git_stdout(root: &Path, args: &[&str]) -> String {
|
||||
pub(super) fn git_stdout(root: &Path, args: &[&str]) -> String {
|
||||
let output = Command::new("git")
|
||||
.current_dir(root)
|
||||
.args(args)
|
||||
@@ -555,6 +671,12 @@ mod tests {
|
||||
fs::create_dir_all(&root).expect("create root");
|
||||
fs::write(root.join("MEMORY.md"), "baseline").expect("write memory");
|
||||
|
||||
let oversized = root.join("large-output");
|
||||
fs::File::create(&oversized)
|
||||
.expect("create large file")
|
||||
.set_len(MAX_BASELINE_FILE_BYTES + 1)
|
||||
.expect("size large file");
|
||||
|
||||
reset_git_repository(&root).await.expect("reset repo");
|
||||
|
||||
assert!(root.join(".git").is_dir());
|
||||
@@ -562,8 +684,77 @@ mod tests {
|
||||
let diff = diff_since_latest_init(&root).await.expect("diff");
|
||||
assert!(!diff.has_changes());
|
||||
assert_eq!(diff.unified_diff, "");
|
||||
assert_eq!(git_stdout(&root, &["status", "--porcelain"]), "");
|
||||
assert_eq!(
|
||||
git_stdout(&root, &["status", "--porcelain"]),
|
||||
"?? large-output\n"
|
||||
);
|
||||
assert_eq!(git_stdout(&root, &["ls-files"]), "MEMORY.md\n");
|
||||
|
||||
// An older baseline could already contain the oversized blob.
|
||||
git_stdout(&root, &["add", "large-output"]);
|
||||
git_stdout(
|
||||
&root,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Codex",
|
||||
"-c",
|
||||
"user.email=codex@example.com",
|
||||
"-c",
|
||||
"commit.gpgSign=false",
|
||||
"commit",
|
||||
"-m",
|
||||
"Legacy baseline",
|
||||
],
|
||||
);
|
||||
fs::remove_file(root.join(".git").join(BASELINE_VERSION_FILE)).expect("remove version");
|
||||
fs::write(root.join("MEMORY.md"), "pending edit").expect("edit memory");
|
||||
ensure_git_baseline_repository(&root)
|
||||
.await
|
||||
.expect("migrate legacy baseline");
|
||||
assert_eq!(
|
||||
git_stdout(&root, &["ls-tree", "-r", "--name-only", "HEAD"]),
|
||||
""
|
||||
);
|
||||
assert_eq!(git_stdout(&root, &["ls-files"]), "");
|
||||
let diff = diff_since_latest_init(&root)
|
||||
.await
|
||||
.expect("diff after migration");
|
||||
assert_eq!(
|
||||
diff.changes,
|
||||
vec![GitBaselineChange {
|
||||
status: GitBaselineChangeStatus::Added,
|
||||
path: "MEMORY.md".to_string(),
|
||||
}]
|
||||
);
|
||||
assert!(diff.unified_diff.contains("+pending edit"));
|
||||
assert_eq!(
|
||||
fs::metadata(oversized).expect("worktree file").len(),
|
||||
MAX_BASELINE_FILE_BYTES + 1
|
||||
);
|
||||
|
||||
// Phase 2 may grow a tracked input after preparation and before computing its diff.
|
||||
fs::write(root.join("memory_summary.md"), "summary").expect("write summary");
|
||||
reset_git_repository(&root)
|
||||
.await
|
||||
.expect("reset bounded baseline");
|
||||
fs::File::options()
|
||||
.write(true)
|
||||
.open(root.join("MEMORY.md"))
|
||||
.expect("open tracked")
|
||||
.set_len(MAX_BASELINE_FILE_BYTES + 1)
|
||||
.expect("grow tracked");
|
||||
let diff = diff_since_latest_init(&root)
|
||||
.await
|
||||
.expect("diff after input synchronization");
|
||||
assert_eq!(
|
||||
diff.changes,
|
||||
vec![GitBaselineChange {
|
||||
status: GitBaselineChangeStatus::Added,
|
||||
path: "memory_summary.md".to_string(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(git_stdout(&root, &["ls-files"]), "");
|
||||
assert_eq!(git_stdout(&root, &["diff", "HEAD", "--stat"]), "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -579,9 +770,18 @@ mod tests {
|
||||
.expect("ensure repo");
|
||||
|
||||
let diff = diff_since_latest_init(&root).await.expect("diff");
|
||||
assert!(!diff.has_changes());
|
||||
assert_eq!(git_stdout(&root, &["status", "--porcelain"]), "");
|
||||
assert_eq!(git_stdout(&root, &["ls-files"]), "MEMORY.md\n");
|
||||
assert_eq!(
|
||||
diff.changes,
|
||||
vec![GitBaselineChange {
|
||||
status: GitBaselineChangeStatus::Added,
|
||||
path: "MEMORY.md".to_string(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
git_stdout(&root, &["status", "--porcelain"]),
|
||||
"?? MEMORY.md\n"
|
||||
);
|
||||
assert_eq!(git_stdout(&root, &["ls-files"]), "");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Exercises cross-process serialization and retrying an interrupted baseline reset.
|
||||
|
||||
use super::baseline_control;
|
||||
use super::diff_since_latest_init;
|
||||
use super::ensure_git_baseline_repository;
|
||||
use super::reset_git_repository;
|
||||
use super::tests::git_stdout;
|
||||
use super::with_baseline_lock;
|
||||
use anyhow::Result;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::BufRead;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_operations_share_the_workspace_lock() -> Result<()> {
|
||||
const CHILD_ROOT: &str = "CODEX_TEST_BASELINE_LOCK_ROOT";
|
||||
const READY: &str = "holding workspace lock";
|
||||
if let Some(root) = std::env::var_os(CHILD_ROOT) {
|
||||
return with_baseline_lock(Path::new(&root), |_| {
|
||||
println!("{READY}");
|
||||
io::stdout().flush()?;
|
||||
io::stdin().read_exact(&mut [0])?;
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let workspace = tempfile::tempdir()?;
|
||||
let root = workspace.path();
|
||||
fs::write(root.join("MEMORY.md"), "original\n")?;
|
||||
reset_git_repository(root).await?;
|
||||
fs::write(root.join("MEMORY.md"), "updated\n")?;
|
||||
|
||||
let mut child = codex_utils_process::background_command(std::env::current_exe()?)
|
||||
.args([
|
||||
"--exact",
|
||||
"baseline::recovery_tests::concurrent_operations_share_the_workspace_lock",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ROOT, root)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()?;
|
||||
let stdout = io::BufReader::new(child.stdout.as_mut().expect("piped stdout"));
|
||||
assert!(
|
||||
stdout
|
||||
.lines()
|
||||
.any(|line| line.is_ok_and(|line| line.contains(READY))),
|
||||
"child exited before acquiring the lock"
|
||||
);
|
||||
let lock = fs::File::options()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(baseline_control(root).join("codex-baseline.lock"))?;
|
||||
let contended = matches!(lock.try_lock(), Err(fs::TryLockError::WouldBlock));
|
||||
drop(lock);
|
||||
|
||||
let reset = reset_git_repository(root);
|
||||
let ensure = ensure_git_baseline_repository(root);
|
||||
let diff = diff_since_latest_init(root);
|
||||
tokio::pin!(reset, ensure, diff);
|
||||
// Any one operation completing before the holder releases the lock is a regression.
|
||||
let completed_while_locked = tokio::select! {
|
||||
_ = &mut reset => Some("reset"),
|
||||
_ = &mut ensure => Some("ensure"),
|
||||
_ = &mut diff => Some("diff"),
|
||||
() = tokio::time::sleep(Duration::from_millis(500)) => None,
|
||||
};
|
||||
child.stdin.take().expect("piped stdin").write_all(b"x")?;
|
||||
assert!(child.wait()?.success(), "lock holder failed");
|
||||
assert!(contended, "another process must own the lock");
|
||||
assert_eq!(completed_while_locked, None);
|
||||
|
||||
let ((), (), diff) = tokio::try_join!(reset, ensure, diff)?;
|
||||
if diff.has_changes() {
|
||||
assert!(diff.unified_diff.contains("-original\n+updated"));
|
||||
}
|
||||
assert_eq!(git_stdout(root, &["show", "HEAD:MEMORY.md"]), "updated\n");
|
||||
assert_eq!(git_stdout(root, &["show", ":MEMORY.md"]), "updated\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_reset_replays_working_files() -> Result<()> {
|
||||
let workspace = tempfile::tempdir()?;
|
||||
let root = workspace.path();
|
||||
let memory = root.join("MEMORY.md");
|
||||
fs::write(&memory, "original\n")?;
|
||||
reset_git_repository(root).await?;
|
||||
fs::write(&memory, "pending edit\n")?;
|
||||
|
||||
// Simulate exiting after committing the tree but before writing the index and version.
|
||||
fs::remove_file(root.join(".git").join(super::BASELINE_VERSION_FILE))?;
|
||||
fs::remove_file(root.join(".git/index"))?;
|
||||
ensure_git_baseline_repository(root).await?;
|
||||
ensure_git_baseline_repository(root).await?;
|
||||
assert_eq!(
|
||||
git_stdout(root, &["ls-tree", "-r", "--name-only", "HEAD"]),
|
||||
""
|
||||
);
|
||||
assert_eq!(git_stdout(root, &["ls-files"]), "");
|
||||
assert_eq!(fs::read_to_string(&memory)?, "pending edit\n");
|
||||
let diff = diff_since_latest_init(root).await?;
|
||||
assert_eq!(
|
||||
diff.changes,
|
||||
vec![super::GitBaselineChange {
|
||||
status: super::GitBaselineChangeStatus::Added,
|
||||
path: "MEMORY.md".to_string(),
|
||||
}]
|
||||
);
|
||||
assert!(diff.unified_diff.contains("+pending edit"));
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user