From 138db7661488f434557ebc6f0dc844ad2115dd16 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Sun, 17 May 2026 13:20:46 +0200 Subject: [PATCH] Implement service worker update lifecycle cleanup --- crates/browser/src/service_worker.rs | 21 +- crates/js/src/service_worker.rs | 421 +++++++++++++++++++++++++-- 2 files changed, 422 insertions(+), 20 deletions(-) diff --git a/crates/browser/src/service_worker.rs b/crates/browser/src/service_worker.rs index 0d6d024..e28f86f 100644 --- a/crates/browser/src/service_worker.rs +++ b/crates/browser/src/service_worker.rs @@ -76,7 +76,10 @@ impl ServiceWorkerManager { if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } - let _ = fs::write(path, store.serialize()); + let tmp_path = path.with_extension("tmp"); + if fs::write(&tmp_path, store.serialize()).is_ok() { + let _ = fs::rename(tmp_path, path); + } } pub fn clear_origin(&self, origin: &Origin) -> bool { @@ -206,4 +209,20 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + + #[test] + fn load_recovers_from_partially_written_metadata() { + let dir = std::env::temp_dir().join("we_service_worker_partial_metadata"); + let _ = fs::remove_dir_all(&dir); + let mgr = ServiceWorkerManager::with_dir(dir.clone()); + let base = Url::parse("https://example.com/app/page.html").unwrap(); + let origin = base.origin(); + let path = mgr.file_path(&origin).unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, b"WESW2\0\x01").unwrap(); + + assert!(mgr.load(&origin).records().is_empty()); + + let _ = fs::remove_dir_all(&dir); + } } diff --git a/crates/js/src/service_worker.rs b/crates/js/src/service_worker.rs index ba8c694..4681792 100644 --- a/crates/js/src/service_worker.rs +++ b/crates/js/src/service_worker.rs @@ -80,6 +80,12 @@ pub struct ServiceWorkerSlot { pub state: ServiceWorkerState, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceWorkerScriptResource { + pub url: String, + pub bytes: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ServiceWorkerRegistrationRecord { pub origin: String, @@ -90,10 +96,21 @@ pub struct ServiceWorkerRegistrationRecord { pub waiting: Option, pub active: Option, pub script_bytes: Vec, + pub imported_scripts: Vec, + pub waiting_script_bytes: Vec, + pub waiting_imported_scripts: Vec, + pub updatefound_count: u32, + pub controlled_clients: u32, } impl ServiceWorkerRegistrationRecord { - fn new(origin: String, script_url: String, scope_url: String, script_bytes: Vec) -> Self { + fn new( + origin: String, + script_url: String, + scope_url: String, + script_bytes: Vec, + imported_scripts: Vec, + ) -> Self { Self { origin, script_url: script_url.clone(), @@ -106,11 +123,24 @@ impl ServiceWorkerRegistrationRecord { state: ServiceWorkerState::Active, }), script_bytes, + imported_scripts, + waiting_script_bytes: Vec::new(), + waiting_imported_scripts: Vec::new(), + updatefound_count: 0, + controlled_clients: 0, } } - pub fn script_changed(&self, next_bytes: &[u8]) -> bool { - self.script_bytes.as_slice() != next_bytes + pub fn script_changed( + &self, + next_bytes: &[u8], + next_imports: &[ServiceWorkerScriptResource], + ) -> bool { + self.script_bytes.as_slice() != next_bytes || self.imported_scripts != next_imports + } + + pub fn has_waiting_worker(&self) -> bool { + self.waiting.is_some() } } @@ -136,12 +166,24 @@ impl RegistrationStore { script: &str, scope: Option<&str>, script_bytes: Vec, + ) -> Result { + self.register_with_imports(document_url, script, scope, script_bytes, Vec::new()) + } + + pub fn register_with_imports( + &mut self, + document_url: &Url, + script: &str, + scope: Option<&str>, + script_bytes: Vec, + imported_scripts: Vec<(&str, Vec)>, ) -> Result { self.register_with_options( document_url, script, scope, script_bytes, + imported_scripts, RegistrationOptions::new(), ) } @@ -152,6 +194,7 @@ impl RegistrationStore { script: &str, scope: Option<&str>, script_bytes: Vec, + imported_scripts: Vec<(&str, Vec)>, options: RegistrationOptions, ) -> Result { validate_document_context(document_url, options)?; @@ -190,11 +233,13 @@ impl RegistrationStore { let origin = registration_origin_key(document_url, &document_origin, options); let script_serialized = script_url.serialize(); let scope_serialized = scope_url.serialize(); + let imported_scripts = normalize_imported_scripts(&script_url, imported_scripts, options)?; let record = ServiceWorkerRegistrationRecord::new( origin.clone(), - script_serialized, + script_serialized.clone(), scope_serialized.clone(), script_bytes, + imported_scripts.clone(), ); if let Some(existing) = self @@ -202,15 +247,33 @@ impl RegistrationStore { .iter_mut() .find(|r| r.origin == origin && r.scope_url == scope_serialized) { - *existing = record.clone(); - } else { - self.records.push(record.clone()); - self.records.sort_by(|a, b| { - a.scope_url - .cmp(&b.scope_url) - .then(a.script_url.cmp(&b.script_url)) + if existing.script_url == script_serialized + && !existing.script_changed(&record.script_bytes, &imported_scripts) + { + return Ok(existing.clone()); + } + existing.installing = Some(ServiceWorkerSlot { + script_url: script_serialized.clone(), + state: ServiceWorkerState::Installing, }); + existing.waiting = Some(ServiceWorkerSlot { + script_url: script_serialized, + state: ServiceWorkerState::Waiting, + }); + existing.lifecycle_state = ServiceWorkerState::Waiting; + existing.waiting_script_bytes = record.script_bytes; + existing.waiting_imported_scripts = imported_scripts; + existing.installing = None; + existing.updatefound_count = existing.updatefound_count.saturating_add(1); + return Ok(existing.clone()); } + + self.records.push(record.clone()); + self.records.sort_by(|a, b| { + a.scope_url + .cmp(&b.scope_url) + .then(a.script_url.cmp(&b.script_url)) + }); Ok(record) } @@ -247,6 +310,74 @@ impl RegistrationStore { self.records.len() != before } + pub fn unregister_with_cache_cleanup( + &mut self, + origin: &Origin, + scope_url: &str, + caches: &mut CacheStorage, + ) -> bool { + let removed = self.unregister(origin, scope_url); + if removed { + caches.clear_origin(origin); + } + removed + } + + pub fn add_controlled_client(&mut self, origin: &Origin, scope_url: &str) -> bool { + let origin = origin.serialize(); + if let Some(record) = self + .records + .iter_mut() + .find(|r| r.origin == origin && r.scope_url == scope_url) + { + record.controlled_clients = record.controlled_clients.saturating_add(1); + true + } else { + false + } + } + + pub fn release_controlled_client(&mut self, origin: &Origin, scope_url: &str) -> bool { + let origin = origin.serialize(); + let Some(record) = self + .records + .iter_mut() + .find(|r| r.origin == origin && r.scope_url == scope_url) + else { + return false; + }; + record.controlled_clients = record.controlled_clients.saturating_sub(1); + if record.controlled_clients == 0 && record.waiting.is_some() { + promote_waiting(record); + } + true + } + + pub fn skip_waiting(&mut self, origin: &Origin, scope_url: &str) -> bool { + let origin = origin.serialize(); + let Some(record) = self + .records + .iter_mut() + .find(|r| r.origin == origin && r.scope_url == scope_url) + else { + return false; + }; + promote_waiting(record) + } + + pub fn activate_waiting_with_cache_cleanup( + &mut self, + origin: &Origin, + scope_url: &str, + caches: &mut CacheStorage, + ) -> bool { + let activated = self.skip_waiting(origin, scope_url); + if activated { + caches.clear_origin(origin); + } + activated + } + pub fn clear_origin(&mut self, origin: &Origin) -> bool { self.clear_origin_key(&origin.serialize()) } @@ -259,6 +390,7 @@ impl RegistrationStore { pub fn serialize(&self) -> Vec { let mut out = Vec::new(); + out.extend_from_slice(b"WESW2\0"); write_u32(&mut out, self.records.len() as u32); for record in &self.records { write_string(&mut out, &record.origin); @@ -269,12 +401,20 @@ impl RegistrationStore { write_slot(&mut out, &record.waiting); write_slot(&mut out, &record.active); write_bytes(&mut out, &record.script_bytes); + write_resources(&mut out, &record.imported_scripts); + write_bytes(&mut out, &record.waiting_script_bytes); + write_resources(&mut out, &record.waiting_imported_scripts); + write_u32(&mut out, record.updatefound_count); + write_u32(&mut out, record.controlled_clients); } out } pub fn deserialize(data: &[u8]) -> Option { - let mut cursor = Cursor { data, pos: 0 }; + if !data.starts_with(b"WESW2\0") { + return None; + } + let mut cursor = Cursor { data, pos: 6 }; let count = cursor.read_u32()? as usize; let mut records = Vec::with_capacity(count); for _ in 0..count { @@ -286,6 +426,11 @@ impl RegistrationStore { let waiting = cursor.read_slot()?; let active = cursor.read_slot()?; let script_bytes = cursor.read_bytes()?; + let imported_scripts = cursor.read_resources()?; + let waiting_script_bytes = cursor.read_bytes()?; + let waiting_imported_scripts = cursor.read_resources()?; + let updatefound_count = cursor.read_u32()?; + let controlled_clients = cursor.read_u32()?; records.push(ServiceWorkerRegistrationRecord { origin, script_url, @@ -295,6 +440,11 @@ impl RegistrationStore { waiting, active, script_bytes, + imported_scripts, + waiting_script_bytes, + waiting_imported_scripts, + updatefound_count, + controlled_clients, }); } if cursor.pos == data.len() { @@ -368,6 +518,14 @@ impl CacheStorage { self.entries.retain(|entry| entry.origin != origin); self.entries.len() != before } + + pub fn delete_cache(&mut self, origin: &Origin, cache_name: &str) -> bool { + let origin = origin.serialize(); + let before = self.entries.len(); + self.entries + .retain(|entry| !(entry.origin == origin && entry.cache_name == cache_name)); + self.entries.len() != before + } } fn validate_document_context(url: &Url, options: RegistrationOptions) -> Result<(), String> { @@ -418,6 +576,39 @@ fn same_registration_origin( document_origin.same_origin(target_origin) } +fn normalize_imported_scripts( + script_url: &Url, + imported_scripts: Vec<(&str, Vec)>, + options: RegistrationOptions, +) -> Result, String> { + let mut resources = Vec::with_capacity(imported_scripts.len()); + for (url, bytes) in imported_scripts { + let url = resolve_supported_url(url, script_url, options)?; + resources.push(ServiceWorkerScriptResource { + url: url.serialize(), + bytes, + }); + } + resources.sort_by(|a, b| a.url.cmp(&b.url)); + Ok(resources) +} + +fn promote_waiting(record: &mut ServiceWorkerRegistrationRecord) -> bool { + let Some(waiting) = record.waiting.take() else { + return false; + }; + record.active = Some(ServiceWorkerSlot { + script_url: waiting.script_url.clone(), + state: ServiceWorkerState::Active, + }); + record.script_url = waiting.script_url; + record.script_bytes = std::mem::take(&mut record.waiting_script_bytes); + record.imported_scripts = std::mem::take(&mut record.waiting_imported_scripts); + record.installing = None; + record.lifecycle_state = ServiceWorkerState::Active; + true +} + fn registration_origin_key( document_url: &Url, document_origin: &Origin, @@ -532,12 +723,23 @@ fn make_registration_object( Property::builtin(make_slot_value(ctx, &record.active)), ctx.shapes, ); + data.insert_property( + "updatefoundCount".to_string(), + Property::builtin(Value::Number(record.updatefound_count as f64)), + ctx.shapes, + ); let unregister = make_native(ctx.gc, "unregister", sw_registration_unregister); data.insert_property( "unregister".to_string(), Property::builtin(Value::Function(unregister)), ctx.shapes, ); + let skip_waiting = make_native(ctx.gc, "skipWaiting", sw_registration_skip_waiting); + data.insert_property( + "skipWaiting".to_string(), + Property::builtin(Value::Function(skip_waiting)), + ctx.shapes, + ); Value::Object(ctx.gc.alloc(HeapObject::Object(data))) } @@ -684,6 +886,28 @@ fn sw_registration_unregister( Ok(promise_resolved(ctx, Value::Boolean(removed))) } +fn sw_registration_skip_waiting( + _args: &[Value], + ctx: &mut NativeContext, +) -> Result { + let scope = match ctx.this { + Value::Object(obj) => get_string_prop(ctx, obj, "scope"), + _ => None, + }; + let document_url = match current_document_url(ctx) { + Ok(url) => url, + Err(e) => return Ok(promise_rejected(ctx, e)), + }; + let activated = match (ctx.dom_bridge, scope) { + (Some(bridge), Some(scope)) => bridge + .service_workers + .borrow_mut() + .skip_waiting(&document_url.origin(), &scope), + _ => false, + }; + Ok(promise_resolved(ctx, Value::Boolean(activated))) +} + /// Register `navigator.serviceWorker` on the main-thread VM. pub fn init_service_worker_api(vm: &mut Vm) { let service_worker_ref = { @@ -770,6 +994,14 @@ fn write_bytes(out: &mut Vec, bytes: &[u8]) { out.extend_from_slice(bytes); } +fn write_resources(out: &mut Vec, resources: &[ServiceWorkerScriptResource]) { + write_u32(out, resources.len() as u32); + for resource in resources { + write_string(out, &resource.url); + write_bytes(out, &resource.bytes); + } +} + fn write_slot(out: &mut Vec, slot: &Option) { match slot { Some(slot) => { @@ -814,6 +1046,18 @@ impl<'a> Cursor<'a> { let state = ServiceWorkerState::from_str(&self.read_string()?)?; Some(Some(ServiceWorkerSlot { script_url, state })) } + + fn read_resources(&mut self) -> Option> { + let count = self.read_u32()? as usize; + let mut resources = Vec::with_capacity(count); + for _ in 0..count { + resources.push(ServiceWorkerScriptResource { + url: self.read_string()?, + bytes: self.read_bytes()?, + }); + } + Some(resources) + } } #[cfg(test)] @@ -928,6 +1172,7 @@ mod tests { "sw.js", None, b"file-script".to_vec(), + Vec::new(), RegistrationOptions::allow_file_urls_for_tests(), ) .unwrap(); @@ -958,25 +1203,110 @@ mod tests { let bytes = store.serialize(); let loaded = RegistrationStore::deserialize(&bytes).unwrap(); assert_eq!(loaded, store); - assert!(!loaded.records()[0].script_changed(b"body-v1")); - assert!(loaded.records()[0].script_changed(b"body-v2")); + assert!(!loaded.records()[0].script_changed(b"body-v1", &[])); + assert!(loaded.records()[0].script_changed(b"body-v2", &[])); } #[test] - fn registering_existing_scope_replaces_script_bytes_for_update_detection() { + fn no_op_update_keeps_active_worker_without_updatefound() { + let mut store = RegistrationStore::new(); + let base = base_url(); + let first = store + .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) + .unwrap(); + let second = store + .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) + .unwrap(); + + assert_eq!(store.records().len(), 1); + assert_eq!(first, second); + assert!(store.records()[0].waiting.is_none()); + assert_eq!(store.records()[0].updatefound_count, 0); + } + + #[test] + fn changed_script_update_enters_waiting_without_replacing_active() { let mut store = RegistrationStore::new(); let base = base_url(); store - .register(&base, "/sw-v1.js", Some("/app/"), b"body-v1".to_vec()) + .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) .unwrap(); let updated = store - .register(&base, "/app/sw-v2.js", Some("/app/"), b"body-v2".to_vec()) + .register(&base, "/app/sw.js", Some("/app/"), b"body-v2".to_vec()) .unwrap(); assert_eq!(store.records().len(), 1); - assert_eq!(updated.script_url, "https://example.com/app/sw-v2.js"); + assert_eq!(updated.script_url, "https://example.com/app/sw.js"); + assert_eq!(updated.script_bytes, b"body-v1"); + assert_eq!(updated.waiting_script_bytes, b"body-v2"); + assert!(updated.has_waiting_worker()); + assert_eq!(updated.updatefound_count, 1); + } + + #[test] + fn imported_script_change_triggers_waiting_update() { + let mut store = RegistrationStore::new(); + let base = base_url(); + store + .register_with_imports( + &base, + "/app/sw.js", + Some("/app/"), + b"body-v1".to_vec(), + vec![("dep.js", b"dep-v1".to_vec())], + ) + .unwrap(); + let updated = store + .register_with_imports( + &base, + "/app/sw.js", + Some("/app/"), + b"body-v1".to_vec(), + vec![("dep.js", b"dep-v2".to_vec())], + ) + .unwrap(); + + assert!(updated.has_waiting_worker()); + assert_eq!(updated.updatefound_count, 1); + assert_eq!(updated.imported_scripts[0].bytes, b"dep-v1"); + assert_eq!(updated.waiting_imported_scripts[0].bytes, b"dep-v2"); + } + + #[test] + fn waiting_worker_promotes_on_skip_waiting() { + let mut store = RegistrationStore::new(); + let base = base_url(); + let scope = "https://example.com/app/"; + store + .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) + .unwrap(); + store + .register(&base, "/app/sw.js", Some("/app/"), b"body-v2".to_vec()) + .unwrap(); + + assert!(store.skip_waiting(&base.origin(), scope)); + let record = &store.records()[0]; + assert!(record.waiting.is_none()); + assert_eq!(record.script_bytes, b"body-v2"); + assert_eq!(record.lifecycle_state, ServiceWorkerState::Active); + } + + #[test] + fn active_worker_serves_clients_until_release_promotes_waiting() { + let mut store = RegistrationStore::new(); + let base = base_url(); + let scope = "https://example.com/app/"; + store + .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) + .unwrap(); + assert!(store.add_controlled_client(&base.origin(), scope)); + store + .register(&base, "/app/sw.js", Some("/app/"), b"body-v2".to_vec()) + .unwrap(); + + assert_eq!(store.records()[0].script_bytes, b"body-v1"); + assert!(store.release_controlled_client(&base.origin(), scope)); assert_eq!(store.records()[0].script_bytes, b"body-v2"); - assert!(store.records()[0].script_changed(b"body-v3")); } #[test] @@ -1018,6 +1348,36 @@ mod tests { .is_none()); } + #[test] + fn unregister_and_activation_cleanup_obsolete_caches() { + let mut store = RegistrationStore::new(); + let mut caches = CacheStorage::new(); + let base = base_url(); + let request = Url::parse("https://example.com/app/data.json").unwrap(); + let scope = "https://example.com/app/"; + store + .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) + .unwrap(); + caches.put(&base.origin(), "v1", &request, b"cached".to_vec()); + + assert!(store.unregister_with_cache_cleanup(&base.origin(), scope, &mut caches)); + assert!(caches + .match_request(&base.origin(), "v1", &request) + .is_none()); + + store + .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) + .unwrap(); + store + .register(&base, "/app/sw.js", Some("/app/"), b"body-v2".to_vec()) + .unwrap(); + caches.put(&base.origin(), "v2", &request, b"cached".to_vec()); + assert!(store.activate_waiting_with_cache_cleanup(&base.origin(), scope, &mut caches)); + assert!(caches + .match_request(&base.origin(), "v2", &request) + .is_none()); + } + fn eval_with_service_worker(source: &str) -> String { let program = Parser::parse(source).expect("parse failed"); let func = compiler::compile(&program).expect("compile failed"); @@ -1078,4 +1438,27 @@ mod tests { "SecurityError: service worker script must be same-origin" ); } + + #[test] + fn js_update_exposes_waiting_worker_and_skip_waiting() { + let result = eval_with_service_worker( + r#" + result = ""; + navigator.serviceWorker.register("sw.js").then(function() { + return navigator.serviceWorker.register("sw-v2.js"); + }).then(function(reg) { + var before = reg.active.scriptURL + "|" + reg.waiting.state + "|" + reg.updatefoundCount; + return reg.skipWaiting().then(function(promoted) { + return navigator.serviceWorker.getRegistration().then(function(next) { + result = before + "|" + promoted + "|" + next.active.scriptURL; + }); + }); + }); + "#, + ); + assert_eq!( + result, + "https://example.com/app/sw.js|waiting|1|true|https://example.com/app/sw-v2.js" + ); + } } -- 2.51.2