From 9e133628e58a1775adf902255a7d7906292f28d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 14 Oct 2026 13:38:59 +0200 Subject: [PATCH] 0.2.88 --- .env.example | 3 + Cargo.lock | 108 +++++++++++++++++++++++++++- Cargo.toml | 4 +- PERFORMANCE_TEST_RESULTS.md | 29 ++++++++ README.md | 10 +++ src/app/mod.rs | 8 +++ src/config/mod.rs | 2 + src/config/values.rs | 1 + src/main.rs | 2 + src/websocket/mod.rs | 29 +++++--- src/websocket/pad.rs | 29 +++++--- static/css/styles.css | 10 +++ static/js/markdown.js | 9 +-- static/js/note-editor.js | 23 ++++-- static/js/public.js | 2 +- tests/broadcast-load-simulation.mjs | 27 +++++++ tests/feature-smoke-test.mjs | 8 +++ 17 files changed, 270 insertions(+), 34 deletions(-) create mode 100644 PERFORMANCE_TEST_RESULTS.md create mode 100644 tests/broadcast-load-simulation.mjs diff --git a/.env.example b/.env.example index 289b8b7..8012f84 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ RUSTPAD_PORT=8200 # SQLite — default DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc +# http compression: +HTTP_COMPRESSION=true + # PostgreSQL # DATABASE_URL=postgres://rustpad:rustpad@postgres:5432/rustpad diff --git a/Cargo.lock b/Cargo.lock index 109485e..b646590 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -86,6 +107,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "async-compression" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.91" @@ -712,6 +745,27 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -787,6 +841,24 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "compression-codecs" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1149,6 +1221,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "flume" version = "0.11.1" @@ -2021,6 +2104,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -2581,7 +2674,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.87" +version = "0.2.88" dependencies = [ "argon2", "aws-config", @@ -2835,6 +2928,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -3350,6 +3449,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" dependencies = [ + "async-compression", "bitflags", "bytes", "futures-core", @@ -4026,6 +4126,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 7ab8bcb..119ee92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.87" +version = "0.2.88" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" @@ -28,7 +28,7 @@ slug = "0.1.6" sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "sqlite", "postgres", "mysql", "chrono", "migrate"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] } tower = "0.5.3" -tower-http = { version = "0.7", features = ["fs", "trace", "set-header"] } +tower-http = { version = "0.7", features = ["fs", "trace", "set-header", "compression-br", "compression-gzip"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } hex = "0.4" diff --git a/PERFORMANCE_TEST_RESULTS.md b/PERFORMANCE_TEST_RESULTS.md new file mode 100644 index 0000000..7a779e1 --- /dev/null +++ b/PERFORMANCE_TEST_RESULTS.md @@ -0,0 +1,29 @@ +# Rustpad 0.2.88 — wyniki testów wydajności + +Zmiana: w `src/websocket/pad.rs` i `src/websocket/mod.rs` ograniczono sprawdzanie dostępu przy przychodzących zdarzeniach broadcast do najwyżej jednego sprawdzenia na sekundę na połączenie. Kontrola cykliczna i zdarzenia zmiany hasła pozostają aktywne. W zestawie pozostają optymalizacje renderowania z poprzedniej iteracji. + +## Symulacja deterministyczna (Node.js) + +Założenia: 10 s, 2 edycje/s na użytkownika, każda edycja rozsyłana do wszystkich N odbiorców (w tym nadawcy), bez rzeczywistej sieci, bazy danych, WebSocket czy renderowania. Symulacja porównuje liczby sprawdzeń dostępu, a **nie** rzeczywistą przepustowość ani opóźnienia Rustpada. + +| Użytkownicy | Dostawy zdarzeń | Sprawdzenia przed | Sprawdzenia po | Redukcja | +|---:|---:|---:|---:|---:| +| 5 | 500 | 500 | 50 | 90,00% | +| 100 | 200 000 | 200 000 | 1 000 | 99,50% | +| 300 | 1 800 000 | 1 800 000 | 3 000 | 99,83% | +| 500 | 5 000 000 | 5 000 000 | 5 000 | 99,90% | + +Uruchomienie: `node tests/broadcast-load-simulation.mjs`. + +## Testy + +- `node tests/feature-smoke-test.mjs` — PASS +- `node tests/mention-autocomplete-test.mjs` — PASS +- `node tests/mention-navigation-test.mjs` — PASS +- `node --check static/js/*.js` (osobno dla plików) — PASS +- `cargo test` — NIEURUCHOMIONY (brak Rust/cargo w środowisku) +- Test end-to-end z setkami aktywnych przeglądarek i prawdziwym WebSocket/PostgreSQL — NIEURUCHOMIONY. + +## Ograniczenia + +Nie można zagwarantować płynnej pracy kilkuset użytkowników bez testu na docelowym sprzęcie i bazie. Każdy zapis nadal przechodzi przez serializację zmian i trwałe utrwalenie w bazie; duża liczba operacji może nasycić bazę i sieć. Zmiana odświeżania uprawnień może wydłużyć wykrycie odwołania dostępu w trakcie ciągłego broadcastu do około 1 sekundy; istniejący timer kontroli okresowej pozostaje. Wersja 0.2.88 jest wydaniem z nową poprawką na życzenie użytkownika i nie należy jej mylić z wcześniej wydaną paczką 0.2.88. diff --git a/README.md b/README.md index 93214de..e55d996 100644 --- a/README.md +++ b/README.md @@ -371,3 +371,13 @@ python3 tests/random_data.py \ ``` `--notes-in-workspaces` is applied to every workspace. The example creates 10,000 standalone notes and another 10,000 notes inside 10 workspaces. Wikipedia mode never falls back to generated content. Use `--wikipedia-images`, `--wikipedia-attempts`, `--workers`, `--source-pool-size`, `--scheme https`, `--base-url`, or `--dry-run` as needed. The login value may be a local account e-mail or an LDAP/AD username. + +### HTTP response compression + +Rustpad optionally supports Brotli and gzip for HTTP responses. Add to `.env` (or the YAML config): + +```dotenv +HTTP_COMPRESSION=true +``` + +Defaults to `false`. When enabled, the server selects Brotli or gzip supported by the browser via `Accept-Encoding`, and sends ordinary responses otherwise. This does **not** compress live WebSocket messages. When a reverse proxy already applies HTTP compression, avoid double configuration where possible. diff --git a/src/app/mod.rs b/src/app/mod.rs index 1271921..3ce8f7c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -20,6 +20,7 @@ use axum::{ use pages::*; use tower::{ServiceBuilder, service_fn}; use tower_http::{ + compression::CompressionLayer, services::ServeDir, set_header::SetResponseHeaderLayer, trace::{MakeSpan, TraceLayer}, @@ -48,6 +49,7 @@ pub fn router( static_dir: &str, upload_body_limit_bytes: usize, asset_cache_max_age_seconds: u64, + http_compression: bool, ) -> Router { let asset_version = state.asset_version.clone(); let asset_not_found = service_fn(move |_request| { @@ -259,6 +261,12 @@ pub fn router( .layer(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan)) .layer(middleware::from_fn(require_csrf_token)) .layer(middleware::from_fn(apply_response_header_policy)) + // Negotiate HTTP compression only when enabled; WebSocket frames are unaffected. + .layer( + CompressionLayer::new() + .br(http_compression) + .gzip(http_compression), + ) .with_state(state) } diff --git a/src/config/mod.rs b/src/config/mod.rs index daf4cae..f99a89d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -59,6 +59,7 @@ pub struct Config { pub guest_upload_max_size_bytes: usize, pub asset_version: String, pub asset_cache_max_age_seconds: u64, + pub http_compression: bool, pub file_cache_max_age_seconds: u64, pub files_public_url: Option, pub smtp: Option, @@ -174,6 +175,7 @@ impl Config { guest_upload_max_size_mb, )?, asset_version: env!("CARGO_PKG_VERSION").to_owned(), + http_compression: values.bool("HTTP_COMPRESSION", false)?, asset_cache_max_age_seconds: values .nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?, file_cache_max_age_seconds: values diff --git a/src/config/values.rs b/src/config/values.rs index d56f9ab..da77f6e 100644 --- a/src/config/values.rs +++ b/src/config/values.rs @@ -22,6 +22,7 @@ const KNOWN_CONFIG_KEYS: &[&str] = &[ "GUEST_UPLOAD_ENABLED", "GUEST_UPLOAD_MAX_SIZE_MB", "ASSET_CACHE_MAX_AGE_SECONDS", + "HTTP_COMPRESSION", "FILE_CACHE_MAX_AGE_SECONDS", "REGISTRATION_ENABLED", "ACCOUNT_CONFIRMATION_REQUIRED", diff --git a/src/main.rs b/src/main.rs index 9cc8841..c5a05d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -68,6 +68,7 @@ async fn main() -> Result<(), Box> { guest_upload_enabled = config.guest_upload_enabled, guest_upload_max_size_bytes = config.guest_upload_max_size_bytes, asset_cache_max_age_seconds = config.asset_cache_max_age_seconds, + http_compression = config.http_compression, file_cache_max_age_seconds = config.file_cache_max_age_seconds, files_public_url = config.files_public_url.as_deref().unwrap_or("application origin"), registration_enabled = config.registration_enabled, @@ -173,6 +174,7 @@ async fn main() -> Result<(), Box> { &config.static_dir, config.upload_body_limit_bytes(), config.asset_cache_max_age_seconds, + config.http_compression, ); let address = SocketAddr::new(config.host, config.port); let listener = TcpListener::bind(address).await?; diff --git a/src/websocket/mod.rs b/src/websocket/mod.rs index 9edf700..1d8304d 100644 --- a/src/websocket/mod.rs +++ b/src/websocket/mod.rs @@ -863,6 +863,7 @@ async fn handle_socket( access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); access_refresh.tick().await; let (mut sender, mut receiver) = socket.split(); + let mut last_broadcast_access_check = Instant::now() - Duration::from_secs(2); if send_split( &mut sender, &ServerMessage::Diagnostics { @@ -1053,17 +1054,23 @@ async fn handle_socket( } continue; } - let (read_allowed, _) = current_resource_access( - &state, - "workspace", - &workspace_slug, - &external_tokens, - session_token.as_deref(), - password_ok, - ).await; - if !read_allowed { - let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await; - break; + // Broadcasts can arrive hundreds of times a second per connection. + // Keep access checks off the per-message hot path; the separate + // access_refresh ticker also enforces revocation while idle. + if last_broadcast_access_check.elapsed() >= Duration::from_secs(1) { + let (read_allowed, _) = current_resource_access( + &state, + "workspace", + &workspace_slug, + &external_tokens, + session_token.as_deref(), + password_ok, + ).await; + if !read_allowed { + let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await; + break; + } + last_broadcast_access_check = Instant::now(); } match update { Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{base_revision_id:update.base_revision_id,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,client_id:update.client_id,update_id:update.update_id,operation:update.operation,owner_replacements:update.owner_replacements}).await.is_err(){break;}, diff --git a/src/websocket/pad.rs b/src/websocket/pad.rs index 130613a..a2431b7 100644 --- a/src/websocket/pad.rs +++ b/src/websocket/pad.rs @@ -356,6 +356,7 @@ async fn handle_pad_socket( .await; let _ = channel.send(RoomEvent::Presence(users)); let mut last_chat = Instant::now() - Duration::from_secs(1); + let mut last_broadcast_access_check = Instant::now() - Duration::from_secs(2); let mut access_refresh = tokio::time::interval(Duration::from_secs(10)); access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); access_refresh.tick().await; @@ -552,17 +553,23 @@ async fn handle_pad_socket( } continue; } - let (read_allowed, _) = current_resource_access( - &state, - "pad", - &slug, - &external_tokens, - session_token.as_deref(), - password_ok, - ).await; - if !read_allowed { - let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await; - break; + // Broadcasts can arrive hundreds of times a second per connection. + // Keep access checks off the per-message hot path; the separate + // access_refresh ticker also enforces revocation while idle. + if last_broadcast_access_check.elapsed() >= Duration::from_secs(1) { + let (read_allowed, _) = current_resource_access( + &state, + "pad", + &slug, + &external_tokens, + session_token.as_deref(), + password_ok, + ).await; + if !read_allowed { + let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await; + break; + } + last_broadcast_access_check = Instant::now(); } match update { Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{base_revision_id:u.base_revision_id,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,client_id:u.client_id,update_id:u.update_id,operation:u.operation,owner_replacements:u.owner_replacements}).await.is_err(){break;}, diff --git a/static/css/styles.css b/static/css/styles.css index 982d23e..84247b6 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -4099,6 +4099,16 @@ dialog::backdrop { line-height: inherit; } +.markdown-body .task-list-item--cancelled>.list-item-content { + text-decoration: line-through; + color: var(--text-subtle); +} + +.markdown-body .task-list-item--cancelled>.task-checkbox { + cursor: not-allowed; + opacity: .6; +} + .markdown-body .task-list-item>ul, .markdown-body .task-list-item>ol { min-width: 0; diff --git a/static/js/markdown.js b/static/js/markdown.js index 292ce06..ce8b188 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -274,7 +274,7 @@ function renderStandaloneMedia(line, sourceLine) { } function listLine(line) { - const match = line.match(/^(\s*)([-*+]|(\d+)\.)\s+(?:\[([ xX])\]\s+)?(.+)$/); + const match = line.match(/^(\s*)([-*+]|(\d+)\.)\s+(?:\[([ xX~])\]\s+)?(.+)$/); if (!match) return null; const indent = match[1].replace(/\t/g, " ").length; return { @@ -283,6 +283,7 @@ function listLine(line) { type: match[3] ? "ol" : "ul", number: match[3] ? Number(match[3]) : null, checked: match[4] == null ? null : match[4].toLowerCase() === "x", + cancelled: match[4] === "~", text: match[5], marker: match[3] ? `${match[3]}. ` : `${match[2]} ` }; @@ -302,14 +303,14 @@ function renderList(lines, start, lineOffset = 0, baseIndent = null, forcedType if (!item || item.indent < indent || item.indent !== indent || item.type !== type) break; const sourceLine = index + lineOffset + 1; - const prefix = `${item.whitespace}${item.marker}${item.checked == null ? "" : `[${item.checked ? "x" : " "}] `}`; + const prefix = `${item.whitespace}${item.marker}${item.checked == null ? "" : `[${item.cancelled ? "~" : item.checked ? "x" : " "}] `}`; const valueAttr = ""; - const taskClass = item.checked == null ? "" : " task-list-item"; + const taskClass = item.checked == null ? "" : ` task-list-item${item.cancelled ? " task-list-item--cancelled" : ""}`; hasTask ||= item.checked != null; const gutterOffset = `${(depth + 1) * 1.75}em`; body += `
  • `; if (item.checked != null) { - body += ``; + body += ``; } body += `${inline(item.text)}`; index++; diff --git a/static/js/note-editor.js b/static/js/note-editor.js index d1fd8d1..8120e12 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -1247,6 +1247,21 @@ export function startNoteEditor(adapter) { }); } const render = createRenderQueue(renderNow); + // Collaborative edits can arrive many times per second. Keep typing and + // WebSocket processing responsive by batching expensive Markdown/DOM work. + let deferredRenderTimer = null; + let firstDeferredRenderAt = 0; + function scheduleDocumentRender() { + const now = performance.now(); + if (deferredRenderTimer === null) firstDeferredRenderAt = now; + else clearTimeout(deferredRenderTimer); + const delay = Math.min(180, Math.max(0, 750 - (now - firstDeferredRenderAt))); + deferredRenderTimer = setTimeout(() => { + deferredRenderTimer = null; + firstDeferredRenderAt = 0; + render(); + }, delay); + } function queueCollaborativeOperation(operation, ownerReplacements = []) { if (!collaboration.ready || !canEditDocument()) return false; try { @@ -1407,7 +1422,7 @@ export function startNoteEditor(adapter) { if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length); previousContent = content; editHistory.syncCurrent(); - renderGutter(); + scheduleDocumentRender(); requestAnimationFrame(revealLinkedLine); return; } @@ -1427,7 +1442,7 @@ export function startNoteEditor(adapter) { applyingRemote = false; if (resetHistory) editHistory.reset(); else editHistory.syncCurrent(); - render(); + scheduleDocumentRender(); editor.scrollTop = initialLoad ? 0 : scrollTop; editor.scrollLeft = scrollLeft; syncEditorLayers(); @@ -2238,7 +2253,7 @@ export function startNoteEditor(adapter) { }); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); - if (!checkbox) return; + if (!checkbox || checkbox.disabled) return; if (!canEditDocument()) { checkbox.checked = !checkbox.checked; saveState.textContent = "Read only"; @@ -2488,7 +2503,7 @@ export function startNoteEditor(adapter) { authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length); authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner()); previousContent = nextContent; - render(); + scheduleDocumentRender(); if (applyingRemote || applyingHistory) return; queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship)); editHistory.record(event.inputType || ""); diff --git a/static/js/public.js b/static/js/public.js index b02828c..d72432a 100644 --- a/static/js/public.js +++ b/static/js/public.js @@ -60,7 +60,7 @@ async function renderMediaPlayers() { const nodes = content.querySelectorAll("[d function lockPublicContent(allowTaskUpdates) { content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable')); content.querySelectorAll('.preview-editable').forEach(node => node.classList.remove('preview-editable')); - content.querySelectorAll('.task-checkbox').forEach(box => { box.disabled = !allowTaskUpdates; box.title = allowTaskUpdates ? t("public.task.update", {}, "Update this task") : t("public.task.disabled", {}, "Task updates are disabled by the owner"); }); + content.querySelectorAll('.task-checkbox').forEach(box => { box.disabled = box.disabled || !allowTaskUpdates; box.title = box.disabled ? t("public.task.update", {}, "Update this task") : t("public.task.disabled", {}, "Task updates are disabled by the owner"); }); } function publicAnchorTarget(hash) { const rawId = String(hash || "").replace(/^#/, ""); diff --git a/tests/broadcast-load-simulation.mjs b/tests/broadcast-load-simulation.mjs new file mode 100644 index 0000000..18b8b59 --- /dev/null +++ b/tests/broadcast-load-simulation.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { performance } from 'node:perf_hooks'; +// Deterministic broadcast/access-check simulation. Not a live WebSocket/DB benchmark. +const durationSeconds = 10; +const editsPerUserPerSecond = 2; +for (const users of [5, 100, 300, 500]) { + const checks = new Int32Array(users); + const lastCheckSecond = new Int32Array(users).fill(-1); + const start = performance.now(); + let fanouts = 0; + for (let second = 0; second < durationSeconds; second++) { + for (let event = 0; event < users * editsPerUserPerSecond; event++) { + for (let recipient = 0; recipient < users; recipient++) { + fanouts++; + if (lastCheckSecond[recipient] !== second) { + checks[recipient]++; + lastCheckSecond[recipient] = second; + } + } + } + } + const optimized = checks.reduce((a,b) => a+b, 0); + const baseline = fanouts; + assert.equal(optimized, users * durationSeconds); + assert.ok(optimized <= baseline); + console.log(`${users} clients | ${fanouts.toLocaleString('en-US')} broadcast deliveries | auth reads: ${baseline.toLocaleString('en-US')} -> ${optimized.toLocaleString('en-US')} | reduction ${(100*(1-optimized/baseline)).toFixed(2)}% | simulation ${(performance.now()-start).toFixed(1)} ms`); +} diff --git a/tests/feature-smoke-test.mjs b/tests/feature-smoke-test.mjs index 79ff661..7717645 100644 --- a/tests/feature-smoke-test.mjs +++ b/tests/feature-smoke-test.mjs @@ -51,3 +51,11 @@ html = renderMarkdown('Hello @a[1]\\x and @&" but not @a[1]\\xy'); assert.equal((html.match(/class="markdown-mention"/g)||[]).length, 2, 'escaped nicknames'); assert.doesNotMatch(html, //, 'no raw HTML from nickname'); console.log('PASS: special-character nicknames remain escaped'); + +html = renderMarkdown('- [ ] Open\n- [x] Done\n- [~] Cancelled\n - [~] Nested cancelled'); +has(/task-list-item--cancelled/, html, 'cancelled task has its own class'); +assert.equal((html.match(/task-list-item--cancelled/g) || []).length, 2, 'both cancelled tasks recognized'); +has(/data-source-line="3" disabled aria-label="Cancelled task"/, html, 'cancelled checkbox cannot be clicked'); +has(/data-source-prefix="- \[~\] "/, html, 'cancelled markdown marker preserved'); +has(/data-source-line="1"(?! disabled)/, html, 'open task remains interactive'); +console.log('PASS: cancelled tasks are disabled and retain their source markers');