diff --git a/FAQ.md b/FAQ.md index 2304197e..cc1b76d9 100644 --- a/FAQ.md +++ b/FAQ.md @@ -668,6 +668,7 @@ allowed IP. Same security rules: no password, trusted LAN only. - Desktop-only and hidden in the browser: **archive uploads**, **Payloads** (sending a file from disk), saving a save backup or downloading files to your computer, and attaching files to a bug report. Everything that works on the PS5 itself is the same as on desktop. +- The docker image can be built using `PS5UPLOAD_BASE_URL` to change the root path of the app. **Q: What is "Stream install"?** It installs a `.pkg` **straight from your computer**: the PS5 pulls the bytes over your network, so diff --git a/README.md b/README.md index ed8b1430..2431a519 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,8 @@ with **Settings → Engine URL**. - An upload keeps running when you close the tab. - **No password.** Anyone allowed can read, write and delete on your PS5. Keep it on a trusted LAN, never on the internet. +- You may build the docker container with the `PS5UPLOAD_BASE_URL` to host the webui through a + reverse proxy. This cannot be changed on runtime. ## FAQ diff --git a/client/src/layout/AppShell.tsx b/client/src/layout/AppShell.tsx index 705bb243..2c3361c7 100644 --- a/client/src/layout/AppShell.tsx +++ b/client/src/layout/AppShell.tsx @@ -1275,7 +1275,7 @@ export default function AppShell() {
diff --git a/client/src/layout/Sidebar.tsx b/client/src/layout/Sidebar.tsx
index 980c1cdd..bfbb4e65 100644
--- a/client/src/layout/Sidebar.tsx
+++ b/client/src/layout/Sidebar.tsx
@@ -152,7 +152,7 @@ export default function Sidebar() {
}`}
>
diff --git a/client/src/main.tsx b/client/src/main.tsx
index 026d3d2d..0ebfd8a7 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -90,7 +90,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
= 2 checked above");
+ for h in &hs {
+ h.abort();
+ }
+ }
+
/// How many times every connection was killed (the periodic killer and manual
/// `kill_all` calls alike); a kill that found nothing open is not counted.
pub fn kills(&self) -> u64 {
diff --git a/engine/crates/ava1-ctest/tests/governor.rs b/engine/crates/ava1-ctest/tests/governor.rs
index acf226ac..b1970d30 100644
--- a/engine/crates/ava1-ctest/tests/governor.rs
+++ b/engine/crates/ava1-ctest/tests/governor.rs
@@ -194,7 +194,14 @@ async fn a_rate_capped_link_is_reported_as_the_network() {
// Under the sanitizers (the C) or coverage instrumentation (the Rust sender) one side runs
// several times slower and becomes the bottleneck itself: the classification only means
// something at native speed. The transfer must still succeed.
- if cfg!(ava1_ctest_sanitize) || std::env::var_os("CARGO_LLVM_COV").is_some() {
+ // On a shared CI runner (CI set) the debug Rust sender is slower than the 8 MiB/s cap, so
+ // the receiver keeps up and the link is not always the sole bottleneck the whole job: the
+ // label then describes the runner, not the code (same reason as `a_slow_disk`). Checked
+ // locally and in `make test`.
+ if cfg!(ava1_ctest_sanitize)
+ || std::env::var_os("CARGO_LLVM_COV").is_some()
+ || std::env::var_os("CI").is_some()
+ {
return;
}
let seen = seen.lock().unwrap();
@@ -204,6 +211,11 @@ async fn a_rate_capped_link_is_reported_as_the_network() {
#[tokio::test(flavor = "multi_thread")]
async fn lanes_grow_on_a_link_that_scales_with_them() {
+ // A scaling measurement (160 MiB through a per-lane cap): coverage instrumentation slows
+ // the sender enough to stall it outright, and it measures nothing coverage needs.
+ if std::env::var_os("CARGO_LLVM_COV").is_some() {
+ return;
+ }
let d = dir("gov-lanes");
let file = d.join("big.bin");
std::fs::write(&file, vec![9u8; 160 << 20]).unwrap();
@@ -255,7 +267,12 @@ async fn lanes_grow_on_a_link_that_scales_with_them() {
// Under the sanitizers (the C) or coverage instrumentation (the Rust sender) one side runs
// several times slower and becomes the bottleneck itself: the classification only means
// something at native speed. The transfer must still succeed.
- if cfg!(ava1_ctest_sanitize) || std::env::var_os("CARGO_LLVM_COV").is_some() {
+ // A shared CI runner (CI set) cannot always give a fourth lane the headroom to pay off
+ // (3 seen on main, 4+ on the same commit in its PR): the count is checked locally.
+ if cfg!(ava1_ctest_sanitize)
+ || std::env::var_os("CARGO_LLVM_COV").is_some()
+ || std::env::var_os("CI").is_some()
+ {
return;
}
assert!(report.max_lanes >= 4, "max lanes {}", report.max_lanes);
diff --git a/engine/crates/ava1-ctest/tests/upload.rs b/engine/crates/ava1-ctest/tests/upload.rs
index 42bdd2c7..5cd12820 100644
--- a/engine/crates/ava1-ctest/tests/upload.rs
+++ b/engine/crates/ava1-ctest/tests/upload.rs
@@ -137,7 +137,7 @@ async fn lane_killed_mid_chunk_requeues_and_completes() {
pg2.lanes.load(Ordering::Relaxed) > 0
})
.await;
- px2.kill_newest();
+ px2.kill_newest_lane();
}
});
let dest = d.join("out.bin");
diff --git a/engine/crates/ava1/src/send.rs b/engine/crates/ava1/src/send.rs
index f1c1c3dc..bf2dd1b8 100644
--- a/engine/crates/ava1/src/send.rs
+++ b/engine/crates/ava1/src/send.rs
@@ -1682,6 +1682,21 @@ pub async fn run_upload(
if let Some(st) = stalled {
let limit = stall_limit(sh.window.lock().unwrap().outstanding());
if st.since.elapsed() >= limit {
+ // Tell the receiver the job is over before giving up, like every
+ // other fatal exit (ERR_IO, ERR_VERIFY, ERR_CANCELLED). Without
+ // this the console is never told a stalled job died: it keeps the
+ // job alive holding the destination, so the next attempt's JobOpen
+ // is refused BUSY ("another transfer is writing to this
+ // destination") until its retries run out. The journal and the
+ // `.ava-part` stay, so the JobCancel frees the destination for a
+ // clean resume rather than a conflict.
+ let _ = link
+ .control
+ .send(&gen::JobCancel {
+ job_id,
+ reason: gen::ERR_STALLED,
+ })
+ .await;
break Err(SendError::Protocol(format!(
"no queued frame fits the receiver's window ({} bytes granted, the smallest queued frame is {} bytes) and nothing was sent, received or credited for {:?}",
st.grant, st.smallest, limit
@@ -4074,6 +4089,7 @@ mod tests {
// (one group) can never fit and no Credit can arrive — the job must fail with a
// Protocol error naming the grant, not park forever.
let size = 8usize << 20;
+ let cancel_seen = Arc::new(AtomicU32::new(0));
let (mut link, _lane_seen, _keep) = fake_link(
FakeReceiver {
credit: 512 << 10,
@@ -4082,6 +4098,7 @@ mod tests {
credit_after_ack: None,
malformed_status: false,
retry_unknown: false,
+ cancel_seen: cancel_seen.clone(),
..Default::default()
},
None,
@@ -4116,6 +4133,15 @@ mod tests {
matches!(&err, SendError::Protocol(msg) if msg.contains("524288") && msg.contains("window")),
"{err:?}"
);
+ // The receiver is told the job is over (reason ERR_STALLED) so it releases the
+ // destination; without it the next attempt's JobOpen is refused BUSY.
+ tokio::time::timeout(Duration::from_secs(10), async {
+ while cancel_seen.load(Ordering::SeqCst) != gen::ERR_STALLED as u32 {
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ })
+ .await
+ .expect("the receiver saw the JobCancel (reason ERR_STALLED) within the bound");
let _ = std::fs::remove_dir_all(&dir);
}
diff --git a/engine/crates/ps5upload-core/src/fs_ops.rs b/engine/crates/ps5upload-core/src/fs_ops.rs
index 380a67f6..d7163c0e 100644
--- a/engine/crates/ps5upload-core/src/fs_ops.rs
+++ b/engine/crates/ps5upload-core/src/fs_ops.rs
@@ -1621,7 +1621,7 @@ mod tests {
use std::sync::atomic::Ordering;
if self
.busy
- .try_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1))
+ .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1))
.is_ok()
{
return Err(mgmt::MgmtError {
diff --git a/engine/crates/ps5upload-engine/src/lib.rs b/engine/crates/ps5upload-engine/src/lib.rs
index 887d9adc..1dfd79af 100644
--- a/engine/crates/ps5upload-engine/src/lib.rs
+++ b/engine/crates/ps5upload-engine/src/lib.rs
@@ -9834,6 +9834,26 @@ async fn run(cfg: EngineConfig) -> anyhow::Result<()> {
#[cfg(feature = "webui")]
let app = app.fallback(webui::spa_fallback);
+ let app = if let Ok(base) = std::env::var("PS5UPLOAD_BASE_URL") {
+ let base = base.trim_end_matches('/');
+ if !base.is_empty() {
+ // Format Base URL
+ let base = if !base.starts_with('/') {
+ format!("/{}", base)
+ } else {
+ base.to_string()
+ };
+ // Create Axum Router using the base URL as a prefix for all routes.
+ axum::Router::new()
+ .nest(&base, app)
+ .route(&format!("{}/", base), get(ui_handler))
+ } else {
+ app
+ }
+ } else {
+ app
+ };
+
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::predicate(|origin, request| {
let Some(host) = request
diff --git a/engine/crates/ps5upload-fpkg/src/build.rs b/engine/crates/ps5upload-fpkg/src/build.rs
index 81616f44..2a123043 100644
--- a/engine/crates/ps5upload-fpkg/src/build.rs
+++ b/engine/crates/ps5upload-fpkg/src/build.rs
@@ -817,7 +817,12 @@ pub fn free_bytes(path: &Path) -> Option