fix(operations): bound handler lifetimes

req: operations/003
This commit is contained in:
slhx agent
2026-07-13 22:06:12 +02:00
parent 2bc68c5777
commit d2ae025e13
7 changed files with 240 additions and 16 deletions
Generated
+1
View File
@@ -585,6 +585,7 @@ dependencies = [
"serde_json",
"thirtyfour 0.35.0",
"tokio",
"tower",
]
[[package]]
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -32,7 +32,7 @@ hemx-axum = { path = "../../hemx-axum", optional = true }
hemx-sync = { path = "../../hemx-sync" }
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"], optional = true }
tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "time"], optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
hemplate = { path = "../../../hemplate/hemplate" }
@@ -41,6 +41,7 @@ hemplate = { path = "../../../hemplate/hemplate" }
hemx-test = { path = "../../hemx-test" }
scraper = "0.23"
thirtyfour = "0.35"
tower = { version = "0.5", features = ["util"] }
[build-dependencies]
hemx-build = { path = "../../hemx-build" }
+129 -14
View File
@@ -1,7 +1,8 @@
use axum::extract::{Query, State};
use axum::extract::{Query, Request, State};
use axum::http::{HeaderMap, StatusCode};
use axum::middleware::{self, Next};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::IntoResponse;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use futures_util::{stream, StreamExt};
@@ -18,6 +19,7 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::fs::{self, OpenOptions};
use std::future::Future;
use std::io::Write;
use std::net::SocketAddr;
use std::path::PathBuf;
@@ -26,6 +28,9 @@ use std::time::Duration;
const COLUMNS: [(&str, &str); 3] = [("backlog", "Backlog"), ("doing", "Doing"), ("done", "Done")];
const ACKNOWLEDGEMENT_STREAM_BUFFER_LIMIT: usize = 64;
const ORDINARY_HANDLER_TIMEOUT: Duration = Duration::from_secs(10);
const STARTUP_REPLAY_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_SYNC_STORE_BYTES: usize = 1024 * 1024;
const ACKNOWLEDGEMENT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
const ACKNOWLEDGEMENT_RECONNECT_BACKOFF: [Duration; 3] = [
Duration::from_millis(100),
@@ -228,15 +233,26 @@ struct PersistedAcknowledgement {
}
impl SyncStore {
fn load(&self) -> Result<SyncState, String> {
async fn load(&self) -> Result<SyncState, String> {
if !self.0.exists() {
return Ok(SyncState {
next_sequence: 1,
..SyncState::default()
});
}
let bytes =
fs::read(&self.0).map_err(|error| format!("read {}: {error}", self.0.display()))?;
let bytes = run_with_timeout(
STARTUP_REPLAY_TIMEOUT,
"startup sync-store read/replay",
tokio::fs::read(&self.0),
)
.await?
.map_err(|error| format!("read {}: {error}", self.0.display()))?;
if bytes.len() > MAX_SYNC_STORE_BYTES {
return Err(format!(
"sync store {} exceeds {MAX_SYNC_STORE_BYTES} bytes",
self.0.display()
));
}
let persisted: PersistedSync = serde_json::from_slice(&bytes)
.map_err(|error| format!("decode {}: {error}", self.0.display()))?;
if !matches!(persisted.schema_version, 1 | 2) || persisted.next_sequence == 0 {
@@ -408,20 +424,46 @@ struct Presence {
count: u64,
}
async fn run_with_timeout<F, T>(
duration: Duration,
operation: &'static str,
future: F,
) -> Result<T, String>
where
F: Future<Output = T>,
{
tokio::time::timeout(duration, future)
.await
.map_err(|_| format!("{operation} timed out after {} ms", duration.as_millis()))
}
async fn bounded_handler(duration: Duration, request: Request, next: Next) -> Response {
match run_with_timeout(duration, "ordinary request", next.run(request)).await {
Ok(response) => response,
Err(message) => (StatusCode::GATEWAY_TIMEOUT, message).into_response(),
}
}
async fn ordinary_handler_timeout(request: Request, next: Next) -> Response {
bounded_handler(ORDINARY_HANDLER_TIMEOUT, request, next).await
}
#[tokio::main]
async fn main() {
let sync_store = std::env::var_os("HEMX_KANBAN_SYNC_STORE")
.map(PathBuf::from)
.map(SyncStore);
let mut sync = sync_store
.as_ref()
.map(SyncStore::load)
.transpose()
.unwrap_or_else(|error| panic!("cannot start with sync store: {error}"))
.unwrap_or_else(|| SyncState {
let mut sync = if let Some(store) = sync_store.as_ref() {
store
.load()
.await
.unwrap_or_else(|error| panic!("cannot start with sync store: {error}"))
} else {
SyncState {
next_sequence: 1,
..SyncState::default()
});
}
};
sync.retained_after = std::env::var("HEMX_KANBAN_RETAINED_AFTER")
.ok()
.and_then(|value| value.parse::<u64>().ok())
@@ -453,16 +495,18 @@ async fn main() {
.unwrap_or(ACKNOWLEDGEMENT_HEARTBEAT_INTERVAL),
});
let app = Router::new()
let ordinary_routes = Router::new()
.route("/", get(home).post(interact))
.route("/events", get(events))
.route("/sync-demo", get(sync_demo))
.route("/sync.js", get(sync_js))
.route("/sync/context", get(sync_context))
.route("/sync/commands", post(sync_command))
.route("/sync/acknowledgements", get(sync_acknowledgements))
.route("/sync/snapshot", get(sync_snapshot))
.route(runtime_js_path(), get(runtime))
.layer(middleware::from_fn(ordinary_handler_timeout));
let app = ordinary_routes
.merge(Router::new().route("/sync/acknowledgements", get(sync_acknowledgements)))
.with_state(state);
let addr = std::env::var("HEMX_KANBAN_ADDR")
@@ -1067,6 +1111,77 @@ mod tests {
select_options_selector, small_text_selector, strong_text_selector,
};
use scraper::{Html, Selector};
use std::sync::atomic::{AtomicBool, Ordering};
use tower::ServiceExt;
struct CancelProof(Arc<AtomicBool>);
impl Drop for CancelProof {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
// req: operations/003
#[tokio::test]
async fn ordinary_handlers_timeout_and_cancel_inflight_work() {
let cancelled = Arc::new(AtomicBool::new(false));
let proof = Arc::clone(&cancelled);
let app = Router::new()
.route(
"/slow",
get(move || {
let proof = Arc::clone(&proof);
async move {
let _cancel_proof = CancelProof(proof);
std::future::pending::<Response>().await
}
}),
)
.layer(middleware::from_fn(|request, next| async move {
bounded_handler(Duration::from_millis(20), request, next).await
}));
let response = app
.oneshot(
Request::get("/slow")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
assert!(cancelled.load(Ordering::SeqCst));
}
// req: operations/003
#[tokio::test]
async fn startup_replay_timeout_cancels_inflight_work_with_a_named_error() {
let cancelled = Arc::new(AtomicBool::new(false));
let proof = Arc::clone(&cancelled);
let error = run_with_timeout(Duration::from_millis(20), "startup replay", async move {
let _cancel_proof = CancelProof(proof);
std::future::pending::<()>().await;
})
.await
.expect_err("startup replay must time out");
assert_eq!(error, "startup replay timed out after 20 ms");
assert!(cancelled.load(Ordering::SeqCst));
}
#[tokio::test]
async fn startup_replay_rejects_oversized_store_before_decoding() {
let path = std::env::temp_dir().join(format!(
"hemx-kanban-oversized-store-{}.json",
std::process::id()
));
fs::write(&path, vec![b' '; MAX_SYNC_STORE_BYTES + 1]).unwrap();
let error = match SyncStore(path.clone()).load().await {
Ok(_) => panic!("oversized store must be rejected"),
Err(error) => error,
};
let _ = fs::remove_file(path);
assert!(error.contains("exceeds 1048576 bytes"), "{error}");
}
fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses")
+77
View File
@@ -2196,6 +2196,83 @@ async fn adversarial_wire_inputs_are_rejected_before_partial_application() -> We
result.and(quit)
}
#[tokio::test]
async fn ordinary_browser_request_exposes_deadline_and_cancels_on_pagehide() -> WebDriverResult<()>
{
// test req: operations/003
let app_port = available_port();
let app_addr = format!("127.0.0.1:{app_port}");
let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example"));
app_command.env("HEMX_KANBAN_ADDR", &app_addr);
let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT)
.expect("start hemx-kanban");
let webdriver_port = available_port();
let webdriver_addr = format!("127.0.0.1:{webdriver_port}");
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT)
.expect("start ready geckodriver");
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async {
driver.goto(&format!("http://{app_addr}/")).await?;
wait_until(
&driver,
"return document.querySelector('[data-hemx-root]')?.getAttribute('data-hemx-request-timeout-ms') === '10000'",
)
.await?;
let proof = driver
.execute_async(
r#"
const done = arguments[arguments.length - 1];
(async () => {
const root = document.querySelector('[data-hemx-root]');
const form = root.querySelector('form');
form.querySelector('[name=title]').value = 'cancel me';
let started = false;
let abortDetail;
window.fetch = (_url, init) => new Promise((_resolve, reject) => {
started = true;
init.signal.addEventListener('abort', () => {
abortDetail = { name: init.signal.reason.name, message: init.signal.reason.message };
reject(init.signal.reason);
}, { once: true });
});
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
while (!started) await new Promise((resolve) => setTimeout(resolve, 1));
window.dispatchEvent(new PageTransitionEvent('pagehide'));
while (!abortDetail || form.hasAttribute('data-hemx-pending')) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
done({
abortDetail,
timeoutMs: root.getAttribute('data-hemx-request-timeout-ms'),
pending: form.hasAttribute('data-hemx-pending'),
});
})().catch((error) => done({ error: String(error), stack: error?.stack }));
"#,
Vec::new(),
)
.await?
.json()
.clone();
assert!(proof["error"].is_null(), "ordinary cancellation failed: {proof}");
assert_eq!(proof["timeoutMs"], "10000");
assert_eq!(proof["abortDetail"]["name"], "AbortError");
assert!(proof["abortDetail"]["message"]
.as_str()
.is_some_and(|message| message.contains("cancelled because page is hidden")));
assert_eq!(proof["pending"], false);
Ok(())
}
.await;
let quit = driver.quit().await;
result.and(quit)
}
#[tokio::test]
async fn acknowledgement_stream_bounds_reconnect_buffering_heartbeat_and_cancellation(
) -> WebDriverResult<()> {
+16
View File
@@ -21,6 +21,14 @@
let currentOperationId = null;
const clientHandlers = new Map();
const clientRuns = new WeakMap();
const activeRequests = new Set();
const REQUEST_TIMEOUT_MS = 10_000;
window.addEventListener("pagehide", () => {
for (const controller of activeRequests) {
controller.abort(new DOMException("hemx request cancelled because page is hidden", "AbortError"));
}
});
function roots() {
const found = [];
@@ -315,6 +323,11 @@
}
const abort = new AbortController();
const timeout = setTimeout(
() => abort.abort(new DOMException(`hemx request timed out after ${REQUEST_TIMEOUT_MS} ms`, "TimeoutError")),
REQUEST_TIMEOUT_MS,
);
activeRequests.add(abort);
let finish;
const done = new Promise((resolve) => { finish = resolve; });
const method = String((form && form.getAttribute("method")) || "POST").toUpperCase();
@@ -346,6 +359,8 @@
emit(rootOf(target), "hemx:error", { message: String(error), status: error.status || null });
}
} finally {
clearTimeout(timeout);
activeRequests.delete(abort);
if (pending.get(target)?.abort === abort) {
pending.delete(target);
showPending(target, false);
@@ -1080,6 +1095,7 @@
function start() {
roots().forEach((root) => {
root.setAttribute("data-hemx-request-timeout-ms", String(REQUEST_TIMEOUT_MS));
try {
bootstrapState(root);
} catch (error) {
+14
View File
@@ -191,6 +191,20 @@ fn runtime_confirms_before_handler_dispatch() {
assert!(source.contains("return;"));
}
#[test]
fn runtime_bounds_and_cancels_ordinary_requests() {
// test req: operations/003
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const REQUEST_TIMEOUT_MS = 10_000"));
assert!(source.contains("hemx request timed out after ${REQUEST_TIMEOUT_MS} ms"));
assert!(source.contains("window.addEventListener(\"pagehide\""));
assert!(source.contains("hemx request cancelled because page is hidden"));
assert!(source.contains("activeRequests.add(abort)"));
assert!(source.contains("activeRequests.delete(abort)"));
assert!(source.contains("data-hemx-request-timeout-ms"));
}
#[test]
fn runtime_fetches_with_same_origin_credentials() {
// req: auth/005