diff --git a/Cargo.lock b/Cargo.lock index f74fbee..a5f29a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -585,6 +585,7 @@ dependencies = [ "serde_json", "thirtyfour 0.35.0", "tokio", + "tower", ] [[package]] diff --git a/PLAN.md b/PLAN.md index f8d23fe..88dcf60 100644 --- a/PLAN.md +++ b/PLAN.md @@ -46,7 +46,7 @@ encryption, retention, backup, and deployment policy remain host concerns. - **Build:** materialize `hemx-sync` over an integration transport with idempotent server command processing, snapshot/change cursor, durable acknowledgements, bounded ordered replay, current auth checks, rejection/conflict results, canonical replacement, reconnect jitter/backoff, multi-tab coordination, and redacted diagnostics. - **Refusals:** no default CRDT, transport in core, cached enqueue-time permission, unbounded queue, or silent last-write-wins policy. - **Requirements:** `sync/001-023`, `operations/001-005`, `security/002-005`, `performance/004-005`. -- **Proof:** `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` proves `sync/002` and `sync/003` through generated real WASM: `SyncEffect::send_patch` travels beside ordinary DOM effects, the strict flat scalar schema is accepted, the offline interaction performs zero patch requests while remaining durable, and reconnect uploads with identical opaque operation/idempotency identity before matching acknowledgement removes it. `cargo test -p hemx-wasm --test browser kanban_public_api_offline_sync_journey_converges_without_duplicate_replay -- --exact` proves `sync/023` through the public generated client handler and real WASM: offline durable mutation, unavailable-origin reload projection, reconnect upload, canonical convergence, duplicate idempotency without a second event, conflicting duplicate rejection, invalid-command rejection, and an empty local queue. `cargo test -p hemx-kanban-example --test browser_e2e idempotent_server_command_is_acknowledged_after_reconnect -- --exact` proves duplicate POST delivery yields one identical canonical acknowledgement/sequence, conflicting id reuse is rejected, EventSource reconnects after a server-closed first stream, the acknowledgement is delivered once with its sequence as event id, and a page reload shows the authoritative card in the canonical column. `cargo test -p hemx-kanban-example --test browser_e2e pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack -- --exact` proves automatic platform-store upload, one explicit transient failure, bounded exponential backoff plus jitter, reconnect acknowledgement, pending-before-ack ordering, acknowledged removal, canonical board convergence, and non-retried 409 rejection remaining durable with a visible reason. `cargo test -p hemx-kanban-example --test browser_e2e canonical_acknowledgement_survives_server_restart -- --exact` proves the versioned store is materialized before success, a real process restart reloads the same idempotent acknowledgement/sequence, EventSource replays it by id, and canonical board state is rebuilt. `cargo test -p hemx-kanban-example --test browser_e2e exhausted_offline_retries_keep_command_until_later_reconnect -- --exact` proves three bounded retries exhaust into visible offline/manual-recovery state while the command remains durable, then a later retry acknowledges/removes it and converges canonically. `cargo test -p hemx-kanban-example --test browser_e2e missing_history_rebase_and_user_conflict_resolution_preserve_suffix -- --exact` proves retained-history gap detection, typed/versioned snapshot fallback, deterministic already-canonical convergence, atomic snapshot/cursor commit, then a divergent canonical update producing explicit conflict with a two-command queue; the user’s canonical-state action removes only the conflicted head, retains and resumes the suffix, and reaches canonical convergence with an empty queue. `cargo test -p hemx-kanban-example --test browser_e2e keep_local_retry_preserves_conflicted_command_and_suffix_order -- --exact` proves the keep-local action derives one resolution identity; a permanent rejection preserves order and re-enables the choice; three later transport failures still preserve the original conflicted head plus suffix and expose manual recovery; final acceptance removes only the head and converges the untouched suffix without duplication or loss. `cargo test -p hemx-kanban-example --test browser_e2e redacted_sync_diagnostics_are_bounded_and_leak_no_sensitive_material -- --exact` proves a 15-second-old queue is bucketed rather than timestamped, cursor and acknowledgement latency advance after recovery, diagnostics stay under a fixed small readout, and command id/payload/actor/session/cookie material never reaches the diagnostics surface; the conflict and keep-local tests additionally assert conflict/rejection counters at their actual failure boundaries. `cargo test -p hemx-kanban-example --test browser_e2e two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_application -- --exact` proves one retry-exhausted leader/one explicit standby, zero follower upload before takeover, lease takeover after the leader closes, one canonical sequence/event, one queue removal, and one board application. `cargo test -p hemx-kanban-example --test browser_e2e upload_backpressure_keeps_pending_work_visible_and_recoverable -- --exact` proves one in-flight upload, a two-acknowledgement activation limit, one retained durable command with visible recovery state, and explicit retry draining the final command without loss. `cargo test -p hemx-kanban-example --test browser_e2e mixed_queue_removes_accepted_prefix_and_retains_rejected_tail -- --exact` proves an accepted prefix is canonically applied and removed once before a permanent rejection stops processing, exposes its typed HTTP/server cause, disables blind retry, and leaves both the rejected command and untouched suffix durable. `cargo test -p hemx-kanban-example --test browser_e2e schema_upgrade_preserves_queued_order_and_local_intent -- --exact` proves a three-command v1 queue migrates atomically to the explicit-target v2 schema, remains byte-for-intent ordered after interrupted upload, then receives canonical sequences 1–3 in original order and drains without loss. `cargo test -p hemx-kanban-example --test browser_e2e account_partition_hides_replay_and_export_until_owner_returns -- --exact` proves a beta editor and alpha viewer enumerate zero commands and issue no replay for an alpha owner queue, signed-out startup cannot open a partition, no foreign id/export surface leaks, and only the returning alpha owner can export the intact command then resume it exactly once. `cargo test -p hemx-kanban-example --test browser_e2e canonical_snapshot_and_history_are_tenant_scoped -- --exact` proves alpha and beta sessions receive only their own canonical cards and acknowledgement events with independent sequence 1 cursors, while signed-out snapshot/history reads receive 401 and expose no stream. `cargo test -p hemx-kanban-example --test browser_e2e identical_sync_inputs_reconcile_deterministically -- --exact` proves `sync/022` through a real browser and live sync endpoints: one accepted server result and canonical snapshot plus an ordered command sequence produce byte-equivalent structured reconciliation records on independent cloned inputs, do not mutate inputs, and select the explicit server-authoritative model. `cargo test -p hemx-kanban-example --test browser_e2e sync_requests_timeout_and_cancel_on_pagehide -- --exact` proves the sync request boundary enforces a named finite timeout and reports `TimeoutError`, while page teardown cancels a pending request with `AbortError`, closes the acknowledgement stream, and clears retry/lease timers. Per-requirement audit: every `sync/001-023` row now has concrete browser proof; existing live-browser proofs also cover `operations/001-002`, `operations/005`, `security/002`, `security/004`, `performance/004`, and `performance/005`, while this slice closes the sync request/stream leg of `operations/003`. `cargo test -p hemx-kanban-example --test browser_e2e adversarial_wire_inputs_are_rejected_before_partial_application -- --exact` proves malformed, oversized, truncated, trailing, unknown-version, and invalid-kind batch/state/command inputs fail without UI mutation; `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` additionally proves unknown-version and oversized event/state inputs are rejected at the generated real-WASM handler boundary before application code. The runtime now bounds total payloads, fields, vectors, varints, UTF-8, enums, and option discriminants before allocation or dispatch, and the durable queue validates every loaded command before replay, closing `security/005`. `cargo test -p hemx-axum --test response interaction_boundary_honors_media_type_and_host_body_limit -- --exact` proves the mutation extractor rejects missing/unexpected media types with 415 and an Axum host-configured oversized body with 413 before handler dispatch, while a valid bounded form dispatches exactly once; the extractor documentation names `DefaultBodyLimit`, and CSRF remains the existing documented application/middleware policy, closing `security/003`. `cargo test -p hemx-kanban-example --test browser_e2e acknowledgement_stream_bounds_reconnect_buffering_heartbeat_and_cancellation -- --exact` closes `operations/004`: the live stream exposes a finite 15-second production heartbeat (25 ms under proof configuration), exactly three bounded reconnect delays (100/250/500 ms), converts 65 pending events over its 64-event transport budget into one canonical snapshot recovery event, then emits heartbeats after recovery; both proof streams are explicitly closed, and the page teardown path closes its owned stream and reports cancellation. Slice 4 remains open only on the non-sync handler/background-replay legs of `operations/003`. The next coherent slice is `operations/003`: apply and prove finite timeout/cancellation policy for ordinary server handlers and startup/background replay, not just sync requests. +- **Proof:** `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` proves `sync/002` and `sync/003` through generated real WASM: `SyncEffect::send_patch` travels beside ordinary DOM effects, the strict flat scalar schema is accepted, the offline interaction performs zero patch requests while remaining durable, and reconnect uploads with identical opaque operation/idempotency identity before matching acknowledgement removes it. `cargo test -p hemx-wasm --test browser kanban_public_api_offline_sync_journey_converges_without_duplicate_replay -- --exact` proves `sync/023` through the public generated client handler and real WASM: offline durable mutation, unavailable-origin reload projection, reconnect upload, canonical convergence, duplicate idempotency without a second event, conflicting duplicate rejection, invalid-command rejection, and an empty local queue. `cargo test -p hemx-kanban-example --test browser_e2e idempotent_server_command_is_acknowledged_after_reconnect -- --exact` proves duplicate POST delivery yields one identical canonical acknowledgement/sequence, conflicting id reuse is rejected, EventSource reconnects after a server-closed first stream, the acknowledgement is delivered once with its sequence as event id, and a page reload shows the authoritative card in the canonical column. `cargo test -p hemx-kanban-example --test browser_e2e pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack -- --exact` proves automatic platform-store upload, one explicit transient failure, bounded exponential backoff plus jitter, reconnect acknowledgement, pending-before-ack ordering, acknowledged removal, canonical board convergence, and non-retried 409 rejection remaining durable with a visible reason. `cargo test -p hemx-kanban-example --test browser_e2e canonical_acknowledgement_survives_server_restart -- --exact` proves the versioned store is materialized before success, a real process restart reloads the same idempotent acknowledgement/sequence, EventSource replays it by id, and canonical board state is rebuilt. `cargo test -p hemx-kanban-example --test browser_e2e exhausted_offline_retries_keep_command_until_later_reconnect -- --exact` proves three bounded retries exhaust into visible offline/manual-recovery state while the command remains durable, then a later retry acknowledges/removes it and converges canonically. `cargo test -p hemx-kanban-example --test browser_e2e missing_history_rebase_and_user_conflict_resolution_preserve_suffix -- --exact` proves retained-history gap detection, typed/versioned snapshot fallback, deterministic already-canonical convergence, atomic snapshot/cursor commit, then a divergent canonical update producing explicit conflict with a two-command queue; the user’s canonical-state action removes only the conflicted head, retains and resumes the suffix, and reaches canonical convergence with an empty queue. `cargo test -p hemx-kanban-example --test browser_e2e keep_local_retry_preserves_conflicted_command_and_suffix_order -- --exact` proves the keep-local action derives one resolution identity; a permanent rejection preserves order and re-enables the choice; three later transport failures still preserve the original conflicted head plus suffix and expose manual recovery; final acceptance removes only the head and converges the untouched suffix without duplication or loss. `cargo test -p hemx-kanban-example --test browser_e2e redacted_sync_diagnostics_are_bounded_and_leak_no_sensitive_material -- --exact` proves a 15-second-old queue is bucketed rather than timestamped, cursor and acknowledgement latency advance after recovery, diagnostics stay under a fixed small readout, and command id/payload/actor/session/cookie material never reaches the diagnostics surface; the conflict and keep-local tests additionally assert conflict/rejection counters at their actual failure boundaries. `cargo test -p hemx-kanban-example --test browser_e2e two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_application -- --exact` proves one retry-exhausted leader/one explicit standby, zero follower upload before takeover, lease takeover after the leader closes, one canonical sequence/event, one queue removal, and one board application. `cargo test -p hemx-kanban-example --test browser_e2e upload_backpressure_keeps_pending_work_visible_and_recoverable -- --exact` proves one in-flight upload, a two-acknowledgement activation limit, one retained durable command with visible recovery state, and explicit retry draining the final command without loss. `cargo test -p hemx-kanban-example --test browser_e2e mixed_queue_removes_accepted_prefix_and_retains_rejected_tail -- --exact` proves an accepted prefix is canonically applied and removed once before a permanent rejection stops processing, exposes its typed HTTP/server cause, disables blind retry, and leaves both the rejected command and untouched suffix durable. `cargo test -p hemx-kanban-example --test browser_e2e schema_upgrade_preserves_queued_order_and_local_intent -- --exact` proves a three-command v1 queue migrates atomically to the explicit-target v2 schema, remains byte-for-intent ordered after interrupted upload, then receives canonical sequences 1–3 in original order and drains without loss. `cargo test -p hemx-kanban-example --test browser_e2e account_partition_hides_replay_and_export_until_owner_returns -- --exact` proves a beta editor and alpha viewer enumerate zero commands and issue no replay for an alpha owner queue, signed-out startup cannot open a partition, no foreign id/export surface leaks, and only the returning alpha owner can export the intact command then resume it exactly once. `cargo test -p hemx-kanban-example --test browser_e2e canonical_snapshot_and_history_are_tenant_scoped -- --exact` proves alpha and beta sessions receive only their own canonical cards and acknowledgement events with independent sequence 1 cursors, while signed-out snapshot/history reads receive 401 and expose no stream. `cargo test -p hemx-kanban-example --test browser_e2e identical_sync_inputs_reconcile_deterministically -- --exact` proves `sync/022` through a real browser and live sync endpoints: one accepted server result and canonical snapshot plus an ordered command sequence produce byte-equivalent structured reconciliation records on independent cloned inputs, do not mutate inputs, and select the explicit server-authoritative model. `cargo test -p hemx-kanban-example --test browser_e2e sync_requests_timeout_and_cancel_on_pagehide -- --exact` proves the sync request boundary enforces a named finite timeout and reports `TimeoutError`, while page teardown cancels a pending request with `AbortError`, closes the acknowledgement stream, and clears retry/lease timers. Per-requirement audit: every `sync/001-023` row now has concrete browser proof; existing live-browser proofs also cover `operations/001-002`, `operations/005`, `security/002`, `security/004`, `performance/004`, and `performance/005`, while this slice closes the sync request/stream leg of `operations/003`. `cargo test -p hemx-kanban-example --test browser_e2e adversarial_wire_inputs_are_rejected_before_partial_application -- --exact` proves malformed, oversized, truncated, trailing, unknown-version, and invalid-kind batch/state/command inputs fail without UI mutation; `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` additionally proves unknown-version and oversized event/state inputs are rejected at the generated real-WASM handler boundary before application code. The runtime now bounds total payloads, fields, vectors, varints, UTF-8, enums, and option discriminants before allocation or dispatch, and the durable queue validates every loaded command before replay, closing `security/005`. `cargo test -p hemx-axum --test response interaction_boundary_honors_media_type_and_host_body_limit -- --exact` proves the mutation extractor rejects missing/unexpected media types with 415 and an Axum host-configured oversized body with 413 before handler dispatch, while a valid bounded form dispatches exactly once; the extractor documentation names `DefaultBodyLimit`, and CSRF remains the existing documented application/middleware policy, closing `security/003`. `cargo test -p hemx-kanban-example --test browser_e2e acknowledgement_stream_bounds_reconnect_buffering_heartbeat_and_cancellation -- --exact` closes `operations/004`: the live stream exposes a finite 15-second production heartbeat (25 ms under proof configuration), exactly three bounded reconnect delays (100/250/500 ms), converts 65 pending events over its 64-event transport budget into one canonical snapshot recovery event, then emits heartbeats after recovery; both proof streams are explicitly closed, and the page teardown path closes its owned stream and reports cancellation. `cargo test -p hemx-kanban-example --bin hemx-kanban-example timeout -- --nocapture` completes `operations/003`: ordinary server routes are cancelled after a finite 10-second production deadline and return 504, startup store read/replay is cancelled after five seconds, oversized startup state is rejected before decoding, and focused tests prove dropped in-flight work plus named timeout diagnostics. `cargo test -p hemx-kanban-example --test browser_e2e ordinary_browser_request_exposes_deadline_and_cancels_on_pagehide -- --exact` proves the ordinary browser request boundary exposes its finite 10-second deadline, aborts the real form request with `AbortError` on page teardown, and clears pending UI state; stream routes remain governed by the `operations/004` lifecycle rather than the ordinary-handler timeout. Every Slice 4 requirement now has concrete proof; the next coherent slice is to run the complete Slice 4 browser/requirements audit, close it if clean, and advance to Slice 5 rather than adding more sync surface. ## Slice 5 — local-first multiplayer Kanban milestone diff --git a/examples/kanban/Cargo.toml b/examples/kanban/Cargo.toml index 60001bb..125c047 100644 --- a/examples/kanban/Cargo.toml +++ b/examples/kanban/Cargo.toml @@ -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" } diff --git a/examples/kanban/src/main.rs b/examples/kanban/src/main.rs index 9b5da37..5dc0308 100644 --- a/examples/kanban/src/main.rs +++ b/examples/kanban/src/main.rs @@ -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 { + async fn load(&self) -> Result { 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( + duration: Duration, + operation: &'static str, + future: F, +) -> Result +where + F: Future, +{ + 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::().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); + + 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::().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") diff --git a/examples/kanban/tests/browser_e2e.rs b/examples/kanban/tests/browser_e2e.rs index c618278..e008aac 100644 --- a/examples/kanban/tests/browser_e2e.rs +++ b/examples/kanban/tests/browser_e2e.rs @@ -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<()> { diff --git a/hemx-js/runtime/hemx.js b/hemx-js/runtime/hemx.js index 23cbebe..eea978d 100644 --- a/hemx-js/runtime/hemx.js +++ b/hemx-js/runtime/hemx.js @@ -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) { diff --git a/hemx-js/tests/runtime.rs b/hemx-js/tests/runtime.rs index 672f8af..4bbf017 100644 --- a/hemx-js/tests/runtime.rs +++ b/hemx-js/tests/runtime.rs @@ -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