mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-23 04:12:48 -05:00
engine: a second build of the same output is refused, not raced
Builds claim their .partial (and LZ4 spool) before the job exists; a second build of the same output gets 409 instead of deleting and overwriting the first one's file. The image writer no longer removes a .partial blindly, never removes one it could not create, and a cancel that lands after the image was finished no longer reports it cancelled. The handlers' file checks run off the async workers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
This commit is contained in:
1 parent
f35455afe8
commit
f78cb43dfb
2 files changed
+152
-70
No files matched your search
@@ -269,6 +269,23 @@ pub(crate) async fn fpkg_build_handler(
|
||||
}
|
||||
};
|
||||
|
||||
// The file this build writes: a leftover of an interrupted one goes, and this one is
|
||||
// marked as being written until the build ends. Claimed before the job exists, so a
|
||||
// second build of the same package is refused rather than started.
|
||||
let stem = req
|
||||
.name
|
||||
.clone()
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.or_else(|| req.content_id.clone().filter(|c| !c.trim().is_empty()))
|
||||
.or_else(|| inspection.content_id.clone());
|
||||
let partial = match stem.map(|stem| out.join(format!("{stem}.pkg.partial"))) {
|
||||
Some(path) => match Writing::claim_off_thread(path.clone()).await {
|
||||
Some(w) => Some(w),
|
||||
None => return already_building(&path),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let cancel = register_transfer_cancel(job_id);
|
||||
let bytes = Arc::new(AtomicU64::new(0));
|
||||
@@ -338,16 +355,6 @@ pub(crate) async fn fpkg_build_handler(
|
||||
}
|
||||
});
|
||||
|
||||
// The file this build writes: a leftover of an interrupted one goes, and this one is
|
||||
// marked as being written until the build ends.
|
||||
let stem = req
|
||||
.name
|
||||
.clone()
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.or_else(|| req.content_id.clone().filter(|c| !c.trim().is_empty()))
|
||||
.or_else(|| inspection.content_id.clone());
|
||||
let partial = stem.map(|stem| Writing::new(clear_stale_partial(&out, &stem)));
|
||||
|
||||
let state_for_job = state.clone();
|
||||
let request_source = source_path.clone();
|
||||
let ticker = JobTicker(ticker.abort_handle());
|
||||
@@ -546,7 +553,7 @@ pub(crate) async fn ffpfsc_compress_handler(
|
||||
) -> impl IntoResponse {
|
||||
use ps5upload_fpkg::ffpfsc;
|
||||
let source = resolve_engine_path(&req.source);
|
||||
let size = match std::fs::metadata(&source) {
|
||||
let size = match tokio::fs::metadata(&source).await {
|
||||
Ok(m) if m.is_file() && m.len() > 0 => m.len(),
|
||||
Ok(_) => {
|
||||
return json_err(
|
||||
@@ -575,14 +582,14 @@ pub(crate) async fn ffpfsc_compress_handler(
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "image".to_string());
|
||||
let output = out_dir.join(format!("{stem}.ffpfsc"));
|
||||
if output.exists() {
|
||||
if tokio::fs::try_exists(&output).await.unwrap_or(false) {
|
||||
return json_err(
|
||||
StatusCode::CONFLICT,
|
||||
format!("{} already exists", output.display()),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = std::fs::create_dir_all(&out_dir) {
|
||||
if let Err(e) = tokio::fs::create_dir_all(&out_dir).await {
|
||||
return json_err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("{}: {e}", out_dir.display()),
|
||||
@@ -590,6 +597,11 @@ pub(crate) async fn ffpfsc_compress_handler(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let partial_out = PathBuf::from(format!("{}.partial", output.display()));
|
||||
let Some(writing) = Writing::claim_off_thread(partial_out.clone()).await else {
|
||||
return already_building(&partial_out);
|
||||
};
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let cancel = register_transfer_cancel(job_id);
|
||||
let bytes = Arc::new(AtomicU64::new(0));
|
||||
@@ -638,7 +650,6 @@ pub(crate) async fn ffpfsc_compress_handler(
|
||||
});
|
||||
|
||||
let state_for_job = state.clone();
|
||||
let partial_out = PathBuf::from(format!("{}.partial", output.display()));
|
||||
let ticker = JobTicker(ticker.abort_handle());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// A panic in the work below must not leave the job "running" forever with its
|
||||
@@ -649,7 +660,6 @@ pub(crate) async fn ffpfsc_compress_handler(
|
||||
job_id,
|
||||
started_at_ms,
|
||||
);
|
||||
let writing = Writing::new(partial_out);
|
||||
sweep_output_leftovers(&out_dir);
|
||||
let mut options = ffpfsc::WrapOptions::default();
|
||||
if let Some(level) = req.level {
|
||||
@@ -797,7 +807,7 @@ pub(crate) async fn exfat_build_handler(
|
||||
) -> impl IntoResponse {
|
||||
use ps5upload_fpkg::source::{FolderSource, SourceTree};
|
||||
let source = resolve_engine_path(&req.source);
|
||||
if !source.is_dir() {
|
||||
if !tokio::fs::metadata(&source).await.is_ok_and(|m| m.is_dir()) {
|
||||
return json_err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("{} is not a folder", source.display()),
|
||||
@@ -826,14 +836,14 @@ pub(crate) async fn exfat_build_handler(
|
||||
let out_dir = output_dir(req.output_dir.as_deref());
|
||||
let (out_name, inner_name) = crate::image_build::output_names(&source, format, req.compress);
|
||||
let output = out_dir.join(out_name);
|
||||
if output.exists() {
|
||||
if tokio::fs::try_exists(&output).await.unwrap_or(false) {
|
||||
return json_err(
|
||||
StatusCode::CONFLICT,
|
||||
format!("{} already exists", output.display()),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = std::fs::create_dir_all(&out_dir) {
|
||||
if let Err(e) = tokio::fs::create_dir_all(&out_dir).await {
|
||||
return json_err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("{}: {e}", out_dir.display()),
|
||||
@@ -869,6 +879,15 @@ pub(crate) async fn exfat_build_handler(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let partial_out = PathBuf::from(format!("{}.partial", output.display()));
|
||||
let Some(writing) = Writing::claim_off_thread(partial_out.clone()).await else {
|
||||
return already_building(&partial_out);
|
||||
};
|
||||
let spool = crate::image_build::lz4_spool(&output);
|
||||
let Some(spooling) = Writing::claim_off_thread(spool.clone()).await else {
|
||||
return already_building(&spool);
|
||||
};
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let cancel = register_transfer_cancel(job_id);
|
||||
let bytes = Arc::new(AtomicU64::new(0));
|
||||
@@ -923,7 +942,6 @@ pub(crate) async fn exfat_build_handler(
|
||||
});
|
||||
|
||||
let state_for_job = state.clone();
|
||||
let partial_out = PathBuf::from(format!("{}.partial", output.display()));
|
||||
let ticker = JobTicker(ticker.abort_handle());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// A panic in the work below must not leave the job "running" forever with its
|
||||
@@ -934,8 +952,6 @@ pub(crate) async fn exfat_build_handler(
|
||||
job_id,
|
||||
started_at_ms,
|
||||
);
|
||||
let writing = Writing::new(partial_out);
|
||||
let spooling = Writing::new(crate::image_build::lz4_spool(&output));
|
||||
sweep_output_leftovers(&out_dir);
|
||||
let mut tree = tree;
|
||||
// With LZ4 packs the build packs first; the stage list says so.
|
||||
@@ -1082,33 +1098,46 @@ fn active_partials() -> &'static std::sync::Mutex<std::collections::HashSet<Path
|
||||
SET.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// A `.pkg.partial` left by a build that is not running any more (the app closed, the engine
|
||||
/// died) is removed, so the rebuild the queue starts is not refused as "output already exists".
|
||||
/// One this engine is writing is never touched. Returns the partial's path.
|
||||
fn clear_stale_partial(out: &Path, stem: &str) -> PathBuf {
|
||||
let partial = out.join(format!("{stem}.pkg.partial"));
|
||||
let writing = active_partials()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.contains(&partial);
|
||||
if !writing && partial.is_file() {
|
||||
let _ = std::fs::remove_file(&partial);
|
||||
}
|
||||
partial
|
||||
}
|
||||
|
||||
/// Marks a file as being written by a running build, for as long as it lives: the sweep below
|
||||
/// leaves it alone.
|
||||
/// leaves it alone, and a second build of the same output is refused.
|
||||
pub(crate) struct Writing(PathBuf);
|
||||
|
||||
impl Writing {
|
||||
pub(crate) fn new(path: PathBuf) -> Writing {
|
||||
active_partials()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(path.clone());
|
||||
Writing(path)
|
||||
/// Claims `path` for one build. `None` when another running build already writes it: two
|
||||
/// builds of one output would wreck each other's file. A leftover at `path` from a build
|
||||
/// that is not running any more (the app closed, the engine died) is removed, so the
|
||||
/// rebuild the queue starts is not refused as "output already exists".
|
||||
pub(crate) fn claim(path: PathBuf) -> Option<Writing> {
|
||||
let mut active = active_partials().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if active.contains(&path) {
|
||||
return None;
|
||||
}
|
||||
if path.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
} else if path.is_file() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
active.insert(path.clone());
|
||||
Some(Writing(path))
|
||||
}
|
||||
|
||||
/// [`Writing::claim`] on a blocking thread: clearing a leftover can mean removing a
|
||||
/// whole pack spool.
|
||||
pub(crate) async fn claim_off_thread(path: PathBuf) -> Option<Writing> {
|
||||
tokio::task::spawn_blocking(move || Writing::claim(path))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
/// The 409 for a build whose output another running build is writing.
|
||||
fn already_building(path: &Path) -> axum::response::Response {
|
||||
json_err(
|
||||
StatusCode::CONFLICT,
|
||||
format!("{} is already being built", path.display()),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
impl Drop for Writing {
|
||||
@@ -1471,24 +1500,26 @@ mod extract_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_leftover_partial_goes_but_one_being_written_stays() {
|
||||
fn a_leftover_partial_goes_but_a_second_build_of_the_same_output_is_refused() {
|
||||
let out = std::env::temp_dir().join(format!("ps5upload-partial-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
std::fs::write(out.join("A.pkg.partial"), b"x").unwrap();
|
||||
std::fs::write(out.join("B.pkg.partial"), b"x").unwrap();
|
||||
active_partials()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(out.join("B.pkg.partial"));
|
||||
clear_stale_partial(&out, "A");
|
||||
clear_stale_partial(&out, "B");
|
||||
assert!(!out.join("A.pkg.partial").exists());
|
||||
assert!(out.join("B.pkg.partial").exists());
|
||||
active_partials()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&out.join("B.pkg.partial"));
|
||||
let first = Writing::claim(out.join("A.pkg.partial")).unwrap();
|
||||
assert!(!out.join("A.pkg.partial").exists(), "the leftover goes");
|
||||
std::fs::write(out.join("A.pkg.partial"), b"being written").unwrap();
|
||||
assert!(Writing::claim(out.join("A.pkg.partial")).is_none());
|
||||
assert_eq!(
|
||||
std::fs::read(out.join("A.pkg.partial")).unwrap(),
|
||||
b"being written"
|
||||
);
|
||||
assert_eq!(
|
||||
already_building(&out.join("A.pkg.partial")).status(),
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
drop(first);
|
||||
assert!(Writing::claim(out.join("A.pkg.partial")).is_some());
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1530,7 +1561,9 @@ mod extract_tests {
|
||||
std::fs::write(out.join("movie.mkv.partial"), b"x").unwrap();
|
||||
std::fs::write(out.join("Game.ffpfsc"), b"x").unwrap();
|
||||
// One a running build is writing stays too.
|
||||
let writing = Writing::new(out.join("D.ffpkg.partial"));
|
||||
std::fs::remove_file(out.join("D.ffpkg.partial")).unwrap();
|
||||
let writing = Writing::claim(out.join("D.ffpkg.partial")).unwrap();
|
||||
std::fs::write(out.join("D.ffpkg.partial"), b"x").unwrap();
|
||||
sweep_output_leftovers(&out);
|
||||
let left: std::collections::BTreeSet<String> = std::fs::read_dir(&out)
|
||||
.unwrap()
|
||||
@@ -1550,8 +1583,8 @@ mod extract_tests {
|
||||
// An LZ4 build's pack spool goes too, unless its build is running.
|
||||
std::fs::create_dir_all(out.join("E.exfat.lz4spool")).unwrap();
|
||||
std::fs::write(out.join("E.exfat.lz4spool/ampr_assets-000.pak"), b"x").unwrap();
|
||||
let spooling = Writing::claim(out.join("F.exfat.lz4spool")).unwrap();
|
||||
std::fs::create_dir_all(out.join("F.exfat.lz4spool")).unwrap();
|
||||
let spooling = Writing::new(out.join("F.exfat.lz4spool"));
|
||||
sweep_output_leftovers(&out);
|
||||
assert!(!out.join("E.exfat.lz4spool").exists());
|
||||
assert!(out.join("F.exfat.lz4spool").exists());
|
||||
|
||||
@@ -424,15 +424,17 @@ pub(crate) fn build(
|
||||
let want_empty: Vec<String> = source.empty_dirs().to_vec();
|
||||
let data: u64 = want_sizes.values().sum();
|
||||
|
||||
// The caller has claimed this name (`fpkg_api::Writing`), clearing any leftover; a file
|
||||
// here now is another build's, and `create_new` refuses it rather than wreck it.
|
||||
let partial = PathBuf::from(format!("{}.partial", output.display()));
|
||||
let _ = std::fs::remove_file(&partial);
|
||||
// Opened before the cleanup below applies: a file that is not ours is never removed.
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&partial)
|
||||
.map_err(|e| format!("{}: {e}", partial.display()))?;
|
||||
let result = (|| -> Result<ImageBuilt, String> {
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&partial)
|
||||
.map_err(|e| format!("{}: {e}", partial.display()))?;
|
||||
let mut hashing = HashingTree::new(source);
|
||||
let write_stage = if inner_name.is_some() {
|
||||
"compress"
|
||||
@@ -497,10 +499,16 @@ pub(crate) fn build(
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&partial);
|
||||
}
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err("cancelled".into());
|
||||
settle(result, cancel.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// A failed build that was cancelled says so; a cancel that came after the image was
|
||||
/// finished and renamed changes nothing.
|
||||
fn settle(result: Result<ImageBuilt, String>, cancelled: bool) -> Result<ImageBuilt, String> {
|
||||
match result {
|
||||
Err(_) if cancelled => Err("cancelled".into()),
|
||||
r => r,
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// AMPR LZ4 asset packs for an image (see `ps5upload_fpkg::ampr_pack`): the game's files
|
||||
@@ -543,8 +551,8 @@ pub(crate) fn build_packed(
|
||||
.profile
|
||||
.clone()
|
||||
.unwrap_or_else(|| ampr_pack::default_profile(packs.level, packs.block_shift));
|
||||
// Claimed by the caller like the image's `.partial`, so nothing else writes here.
|
||||
let spool = lz4_spool(output);
|
||||
let _ = std::fs::remove_dir_all(&spool);
|
||||
let mtime = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64);
|
||||
@@ -694,6 +702,47 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Another build's `.partial` under the same name is neither written into nor removed;
|
||||
/// and a cancel that lands after the image was finished does not turn it into a failure.
|
||||
#[test]
|
||||
fn another_builds_partial_is_left_alone_and_a_late_cancel_changes_nothing() {
|
||||
let root = std::env::temp_dir().join(format!("ps5upload-imgp-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let src = game(&root);
|
||||
let out = root.join("x.exfat");
|
||||
let partial = root.join("x.exfat.partial");
|
||||
std::fs::write(&partial, b"theirs").unwrap();
|
||||
let mut tree = FolderSource::open(&src).unwrap();
|
||||
let cancel = AtomicBool::new(false);
|
||||
assert!(build(
|
||||
ImageFormat::Exfat,
|
||||
None,
|
||||
&mut tree,
|
||||
&out,
|
||||
&cancel,
|
||||
&mut |_, _, _| {}
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(std::fs::read(&partial).unwrap(), b"theirs");
|
||||
std::fs::remove_file(&partial).unwrap();
|
||||
|
||||
let done = || ImageBuilt {
|
||||
image_bytes: 1,
|
||||
files: 1,
|
||||
detail: String::new(),
|
||||
};
|
||||
assert!(settle(Ok(done()), true).is_ok());
|
||||
assert_eq!(
|
||||
settle(Err("disk full".into()), true).err().unwrap(),
|
||||
"cancelled"
|
||||
);
|
||||
assert_eq!(
|
||||
settle(Err("disk full".into()), false).err().unwrap(),
|
||||
"disk full"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A file that is not read front to back while the image is written is still checked.
|
||||
#[test]
|
||||
fn a_file_read_out_of_order_is_hashed_afterwards() {
|
||||
|
||||
Reference in new issue
Block a user