fix(operations): bound handler lifetimes
req: operations/003
This commit is contained in:
Generated
+1
@@ -585,6 +585,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thirtyfour 0.35.0",
|
||||
"tokio",
|
||||
"tower",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user