From 92638e6c9ff1108fbe629448188d0965d8015c3e Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 18 Sep 2026 06:40:44 +0000 Subject: [PATCH] core: writer-owned relocation The worker no longer copies rows when an array cannot grow in place. It reserves a zeroed region, returns the staged descriptor with a commit flag, and queues every other request until the main thread (the only writer) has copied the old bytes and sent commit-rows. Writes that raced the old worker-side copy were lost; lights in the glTF viewer regularly ended up at the origin because of it. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b2dd-db32-712d-99ce-12ce4a86d979 Co-authored-by: Heaust Azure --- core/index.js | 48 +++++++++----- core/lib.rs | 95 +++++++++++++++++++--------- core/render_data.rs | 107 +++++++++++++++++++++++--------- core/render_data_tests.rs | 87 ++++++++++++++++++++------ core/worker.js | 21 ++++++- coredocs/guide/shared-memory.md | 6 +- coredocs/reference/errors.md | 3 +- coredocs/reference/worker.md | 7 ++- 8 files changed, 278 insertions(+), 96 deletions(-) diff --git a/core/index.js b/core/index.js index 08d2ba8..996d831 100644 --- a/core/index.js +++ b/core/index.js @@ -75,23 +75,17 @@ export class YawnCore { async createRows({ name, rows, stride, format }) { await this.ready; - const descriptor = await this.#request("create-rows", { name, rows, stride, format }); - const array = this.#arrays.get(name)?.update(descriptor) - ?? new SharedRows(this.#buffer, descriptor); - this.#arrays.set(name, array); - return array; + const result = await this.#request("create-rows", { name, rows, stride, format }); + return this.#adopt(result.rows, result.commit ? [name] : []); } async createRowsBatch(rows) { await this.ready; if (!Array.isArray(rows) || !rows.length) throw new TypeError("ROWS"); - const descriptors = await this.#request("create-rows-batch", { rows }); - return descriptors.map(descriptor => { - const array = this.#arrays.get(descriptor.name)?.update(descriptor) - ?? new SharedRows(this.#buffer, descriptor); - this.#arrays.set(descriptor.name, array); - return array; - }); + const result = await this.#request("create-rows-batch", { rows }); + const arrays = result.rows.map(descriptor => this.#adopt(descriptor)); + this.#commit(result.commit); + return arrays; } async deleteRows(name) { @@ -103,11 +97,37 @@ export class YawnCore { /** Reserves `count` contiguous rows and resolves with the first row ID. */ async allocateObject(name, count = 1) { await this.ready; - const { id, rows } = await this.#request("allocate-object", { name, count }); - this.#arrays.get(name).update(rows); + const { id, rows, commit } = await this.#request("allocate-object", { name, count }); + this.#adopt(rows, commit ? [name] : []); return id; } + /** + * Publishes a descriptor the worker returned. When the rows moved, this thread owns the copy: + * the old bytes are copied synchronously into the new region and the worker is told to commit + * before anything else can be requested, so no write to the array is ever lost. + */ + #adopt(descriptor, commit = []) { + const current = this.#arrays.get(descriptor.name); + if (!current) { + const array = new SharedRows(this.#buffer, descriptor); + this.#arrays.set(descriptor.name, array); + return array; + } + if (descriptor.offset !== current.descriptor.offset) { + const { offset, bytes } = current.descriptor; + new Uint8Array(this.#buffer, descriptor.offset, bytes).set(new Uint8Array(this.#buffer, offset, bytes)); + } + current.update(descriptor); + this.#commit(commit); + return current; + } + + #commit(names) { + if (!names.length) return; + this.#request("commit-rows", { names }).catch(error => this.#fail(error.code)); + } + async deleteObject(name, id) { await this.ready; return this.#request("delete-object", { name, id }); diff --git a/core/lib.rs b/core/lib.rs index 051027b..4aabc85 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -93,6 +93,8 @@ impl Core { serde_json::to_string(&gpu.as_ref().map(|gpu| &gpu.features[..]).unwrap_or(&[])).unwrap() } + /// Creates or grows an array. When `commit` is set the rows moved: the caller must copy the old + /// bytes into the returned descriptor and send `commit_rows` before any other request. pub fn create_rows( &self, name: String, @@ -100,20 +102,18 @@ impl Core { stride: u32, format: String, ) -> Result { - let rows = self + let (rows, moved) = self .data .borrow_mut() .create_rows(name, rows, stride, format) .map_err(JsError::new)?; - if let Some(gpu) = self.gpu.borrow().as_ref() { - self.store - .borrow_mut() - .refresh_rows(&rows.name, gpu, &self.data.borrow()) - .map_err(|error| JsError::new(&error))?; + if !moved { + self.refresh_rows(&rows.name)?; } - Ok(serde_json::to_string(&rows).unwrap()) + Ok(serde_json::json!({ "rows": rows, "commit": moved }).to_string()) } + /// Like `create_rows` for several arrays; `commit` lists the arrays that moved. pub fn create_rows_batch(&self, source: &str) -> Result { let requests: Vec = serde_json::from_str(source).map_err(|_| JsError::new("ROWS"))?; @@ -123,25 +123,66 @@ impl Core { let refresh = requests .iter() .any(|request| self.store.borrow().uses_rows(&request.name)); - let descriptors = { + let results = { let mut data = self.data.borrow_mut(); - requests + let results = requests .into_iter() .map(|request| { data.create_rows(request.name, request.rows, request.stride, request.format) - .map_err(JsError::new) }) - .collect::, _>>()? - }; - if refresh { - if let Some(gpu) = self.gpu.borrow().as_ref() { - self.store - .borrow_mut() - .refresh(gpu, &self.data.borrow()) - .map_err(|error| JsError::new(&error))?; + .collect::, _>>(); + if results.is_err() { + data.discard_pending(); } + results.map_err(JsError::new)? + }; + let commit = results + .iter() + .filter(|(_, moved)| *moved) + .map(|(rows, _)| rows.name.clone()) + .collect::>(); + if refresh && results.iter().any(|(_, moved)| !moved) { + self.refresh()?; } - Ok(serde_json::to_string(&descriptors).unwrap()) + let descriptors = results + .into_iter() + .map(|(rows, _)| rows) + .collect::>(); + Ok(serde_json::json!({ "rows": descriptors, "commit": commit }).to_string()) + } + + /// Publishes relocations the caller finished copying, then rebuilds the GPU side once. + pub fn commit_rows(&self, source: &str) -> Result<(), JsError> { + let names: Vec = serde_json::from_str(source).map_err(|_| JsError::new("ROWS"))?; + self.data + .borrow_mut() + .commit(&names) + .map_err(JsError::new)?; + if names.iter().any(|name| self.store.borrow().uses_rows(name)) { + self.refresh()?; + } + self.data.borrow_mut().mark_dirty(); + Ok(()) + } + + fn refresh_rows(&self, name: &str) -> Result<(), JsError> { + if let Some(gpu) = self.gpu.borrow().as_ref() { + self.store + .borrow_mut() + .refresh_rows(name, gpu, &self.data.borrow()) + .map_err(|error| JsError::new(&error))?; + } + Ok(()) + } + + fn refresh(&self) -> Result<(), JsError> { + if let Some(gpu) = self.gpu.borrow().as_ref() { + self.store + .borrow_mut() + .refresh(gpu, &self.data.borrow()) + .map_err(|error| JsError::new(&error))?; + } + Ok(()) } pub fn delete_rows(&self, name: String) -> Result<(), JsError> { @@ -154,8 +195,9 @@ impl Core { .map_err(JsError::new) } + /// Reserves rows; `commit` is set when the array moved, exactly like `create_rows`. pub fn allocate_object(&self, name: &str, count: u32) -> Result { - let (id, grew) = self + let (id, grew, moved) = self .data .borrow_mut() .allocate_object(name, count) @@ -163,18 +205,13 @@ impl Core { let rows = self .data .borrow() - .rows(name) + .staged(name) .ok_or_else(|| JsError::new("ROWS_UNKNOWN"))? .clone(); - if grew { - if let Some(gpu) = self.gpu.borrow().as_ref() { - self.store - .borrow_mut() - .refresh_rows(name, gpu, &self.data.borrow()) - .map_err(|error| JsError::new(&error))?; - } + if grew && !moved { + self.refresh_rows(name)?; } - Ok(serde_json::json!({ "id": id, "rows": rows }).to_string()) + Ok(serde_json::json!({ "id": id, "rows": rows, "commit": moved }).to_string()) } pub fn delete_object(&self, name: &str, id: u32) -> Result<(), JsError> { diff --git a/core/render_data.rs b/core/render_data.rs index 0a33cb4..87dd3c4 100644 --- a/core/render_data.rs +++ b/core/render_data.rs @@ -27,15 +27,19 @@ impl FreeList { /// Claims exactly `[offset, offset + size)` when that range is entirely free. fn take(&mut self, offset: u32, size: u32) -> bool { - let Some(&available) = self.0.get(&offset) else { + let Some((&block, &block_size)) = self.0.range(..=offset).next_back() else { return false; }; - if available < size { + if offset + size > block + block_size { return false; } - self.0.remove(&offset); - if available > size { - self.0.insert(offset + size, available - size); + self.0.remove(&block); + if offset > block { + self.0.insert(block, offset - block); + } + let end = offset + size; + if end < block + block_size { + self.0.insert(end, block + block_size - end); } true } @@ -58,7 +62,7 @@ impl FreeList { } } -#[derive(Clone, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] pub struct Rows { pub name: String, pub rows: u32, @@ -82,6 +86,8 @@ pub struct RenderData { free: FreeList, rows: HashMap, slots: HashMap, + /// Relocated descriptors the writer has not committed yet; `rows` keeps the old storage live. + pending: HashMap, } impl RenderData { @@ -99,19 +105,22 @@ impl RenderData { free: FreeList(BTreeMap::from([(0, capacity)])), rows: HashMap::new(), slots: HashMap::new(), + pending: HashMap::new(), }; data.create_rows("signals".into(), 1, 32, "f32".into())?; data.write_signal(5, 1.0); Ok(data) } + /// Creates or grows an array. The flag is set when the rows moved: the returned descriptor + /// is then staged and the writer must copy the old bytes into it and `commit` it. pub fn create_rows( &mut self, name: String, rows: u32, stride: u32, format: String, - ) -> Result { + ) -> Result<(Rows, bool), &'static str> { if name.is_empty() || rows == 0 || stride < 16 @@ -125,19 +134,19 @@ impl RenderData { return Err("ROWS"); } if rows <= current.rows { - return Ok(current); + return Ok((current, false)); } if name == "signals" { return Err("ROWS_BUILTIN"); } let added = rows - current.rows; - let descriptor = self.grow_rows(current, rows)?; + let (descriptor, moved) = self.grow_rows(current, rows)?; self.slots .get_mut(&descriptor.name) .unwrap() .free .release(descriptor.rows - added, added); - return Ok(descriptor); + return Ok((descriptor, moved)); } let bytes = rows.checked_mul(stride).ok_or("ARENA_OOM")?; let offset = self.reserve(bytes)?; @@ -155,13 +164,16 @@ impl RenderData { let mut slots = Slots::default(); slots.free.release(0, rows); self.slots.insert(descriptor.name.clone(), slots); - Ok(descriptor) + Ok((descriptor, false)) } pub fn delete_rows(&mut self, name: &str) -> Result<(), &'static str> { if name == "signals" { return Err("ROWS_BUILTIN"); } + if self.pending.contains_key(name) { + return Err("ROWS_PENDING"); + } if self .slots .get(name) @@ -192,7 +204,12 @@ impl RenderData { } /// Reserves `count` contiguous rows and returns the first one; grows geometrically when full. - pub fn allocate_object(&mut self, name: &str, count: u32) -> Result<(u32, bool), &'static str> { + /// The growth flags mirror `create_rows`: `(grew, moved)`. + pub fn allocate_object( + &mut self, + name: &str, + count: u32, + ) -> Result<(u32, bool, bool), &'static str> { if name == "signals" { return Err("ROWS_BUILTIN"); } @@ -201,6 +218,7 @@ impl RenderData { } let slots = self.slots.get_mut(name).ok_or("ROWS_UNKNOWN")?; let mut grew = false; + let mut moved = false; let id = match slots.free.allocate(count, 1) { Some(id) => id, None => { @@ -211,7 +229,7 @@ impl RenderData { .ok_or("OBJECT_LIMIT")? .max(descriptor.rows.checked_add(count).ok_or("OBJECT_LIMIT")?); let old_rows = descriptor.rows; - self.grow_rows(descriptor, rows)?; + moved = self.grow_rows(descriptor, rows)?.1; grew = true; let slots = self.slots.get_mut(name).unwrap(); slots.free.release(old_rows, rows - old_rows); @@ -219,7 +237,35 @@ impl RenderData { } }; self.slots.get_mut(name).unwrap().spans.insert(id, count); - Ok((id, grew)) + Ok((id, grew, moved)) + } + + /// The descriptor the writer should use: staged rows win over committed ones. + pub fn staged(&self, name: &str) -> Option<&Rows> { + self.pending.get(name).or_else(|| self.rows.get(name)) + } + + /// Publishes staged relocations after the writer copied the old bytes into them. + pub fn commit(&mut self, names: &[String]) -> Result<(), &'static str> { + for name in names { + let staged = self.pending.remove(name).ok_or("ROWS_PENDING")?; + let old = self.rows.insert(name.clone(), staged).unwrap(); + self.release(old.offset - self.base, old.bytes); + } + if !self.pending.is_empty() { + return Err("ROWS_PENDING"); + } + Ok(()) + } + + /// Drops every staged relocation, for example when a batch fails half-way. + pub fn discard_pending(&mut self) { + for staged in std::mem::take(&mut self.pending).into_values() { + self.release(staged.offset - self.base, staged.bytes); + let rows = self.rows[&staged.name].rows; + let slots = self.slots.get_mut(&staged.name).unwrap(); + assert!(slots.free.take(rows, staged.rows - rows)); + } } pub fn delete_object(&mut self, name: &str, id: u32) -> Result<(), &'static str> { @@ -232,31 +278,36 @@ impl RenderData { Ok(()) } - fn grow_rows(&mut self, mut descriptor: Rows, rows: u32) -> Result { + /// Extends in place when the next arena block is free; otherwise stages a zeroed region for + /// the writer to fill and `commit`, because only the writer knows when its last write landed. + fn grow_rows(&mut self, mut descriptor: Rows, rows: u32) -> Result<(Rows, bool), &'static str> { + if self.pending.contains_key(&descriptor.name) { + return Err("ROWS_PENDING"); + } let bytes = rows.checked_mul(descriptor.stride).ok_or("ARENA_OOM")?; let old_offset = descriptor.offset - self.base; let old_end = old_offset + descriptor.bytes; let extra = bytes - descriptor.bytes; - if self.free.take(old_end, extra) { - self.arena[self.arena_start + old_end as usize - ..self.arena_start + (old_end + extra) as usize] - .fill(0); - } else { + let moved = !self.free.take(old_end, extra); + if moved { let offset = self.reserve(bytes)?; let start = self.arena_start + offset as usize; self.arena[start..start + bytes as usize].fill(0); - self.arena.copy_within( - self.arena_start + old_offset as usize..self.arena_start + old_end as usize, - start, - ); - self.release(old_offset, descriptor.bytes); descriptor.offset = self.base + offset; + } else { + self.arena[self.arena_start + old_end as usize + ..self.arena_start + (old_end + extra) as usize] + .fill(0); } descriptor.rows = rows; descriptor.bytes = bytes; - self.rows - .insert(descriptor.name.clone(), descriptor.clone()); - Ok(descriptor) + if moved { + &mut self.pending + } else { + &mut self.rows + } + .insert(descriptor.name.clone(), descriptor.clone()); + Ok((descriptor, moved)) } pub fn update_signals(&mut self, delta: f32, frame: u32, elapsed: f32, fps: u32) { diff --git a/core/render_data_tests.rs b/core/render_data_tests.rs index 110d9a6..e7e5d92 100644 --- a/core/render_data_tests.rs +++ b/core/render_data_tests.rs @@ -40,22 +40,23 @@ fn spans_reuse_coalesced_gaps_before_growing() { let mut data = RenderData::new(4096).unwrap(); data.create_rows("v".into(), 8, 16, "f32".into()).unwrap(); - assert_eq!(data.allocate_object("v", 2).unwrap(), (0, false)); - assert_eq!(data.allocate_object("v", 3).unwrap(), (2, false)); - assert_eq!(data.allocate_object("v", 3).unwrap(), (5, false)); + assert_eq!(data.allocate_object("v", 2).unwrap(), (0, false, false)); + assert_eq!(data.allocate_object("v", 3).unwrap(), (2, false, false)); + assert_eq!(data.allocate_object("v", 3).unwrap(), (5, false, false)); assert_eq!(rows_of(&data, "v"), 8); // Freeing the two middle spans leaves one coalesced hole of 6 rows. data.delete_object("v", 2).unwrap(); data.delete_object("v", 5).unwrap(); assert_eq!(data.delete_object("v", 5), Err("OBJECT_UNKNOWN")); - assert_eq!(data.allocate_object("v", 6).unwrap(), (2, false)); + assert_eq!(data.allocate_object("v", 6).unwrap(), (2, false, false)); assert_eq!(rows_of(&data, "v"), 8); - // A span that does not fit doubles capacity instead of growing to the exact need. - assert_eq!(data.allocate_object("v", 1).unwrap(), (8, true)); + // A span that does not fit doubles capacity instead of growing to the exact need; the only + // array in the arena extends in place, so nothing is staged. + assert_eq!(data.allocate_object("v", 1).unwrap(), (8, true, false)); assert_eq!(rows_of(&data, "v"), 16); - assert_eq!(data.allocate_object("v", 40).unwrap(), (9, true)); + assert_eq!(data.allocate_object("v", 40).unwrap(), (9, true, false)); assert_eq!(rows_of(&data, "v"), 56); assert_eq!(data.allocate_object("v", 0), Err("ROWS")); } @@ -64,8 +65,8 @@ fn spans_reuse_coalesced_gaps_before_growing() { fn deleting_a_span_zeroes_every_row_and_keeps_neighbours() { let mut data = RenderData::new(4096).unwrap(); data.create_rows("v".into(), 4, 16, "u32".into()).unwrap(); - let (first, _) = data.allocate_object("v", 2).unwrap(); - let (second, _) = data.allocate_object("v", 1).unwrap(); + let (first, ..) = data.allocate_object("v", 2).unwrap(); + let (second, ..) = data.allocate_object("v", 1).unwrap(); let base = (data.rows("v").unwrap().offset - data.base) as usize + data.arena_start; data.arena[base..base + 48].fill(0xAB); @@ -80,20 +81,68 @@ fn deleting_a_span_zeroes_every_row_and_keeps_neighbours() { } #[test] -fn explicit_growth_feeds_the_span_allocator_and_relocation_keeps_content() { +fn relocation_is_staged_until_the_writer_commits() { let mut data = RenderData::new(4096).unwrap(); data.create_rows("a".into(), 1, 16, "u32".into()).unwrap(); data.create_rows("b".into(), 1, 16, "u32".into()).unwrap(); - assert_eq!(data.allocate_object("a", 1).unwrap(), (0, false)); - let base = (data.rows("a").unwrap().offset - data.base) as usize + data.arena_start; + assert_eq!(data.allocate_object("a", 1).unwrap(), (0, false, false)); + let old = data.rows("a").unwrap().clone(); + let base = (old.offset - data.base) as usize + data.arena_start; data.arena[base..base + 16].fill(0xCD); - let grown = data.create_rows("a".into(), 5, 16, "u32".into()).unwrap(); - assert_eq!(grown.rows, 5); - assert!(data.bytes("a").unwrap()[..16] - .iter() - .all(|byte| *byte == 0xCD)); + // "b" sits right behind "a", so growing "a" must move it: the worker stages a zeroed region + // and keeps rendering from (and the arena owning) the old bytes. + let (staged, moved) = data.create_rows("a".into(), 5, 16, "u32".into()).unwrap(); + assert!(moved); + assert_eq!(staged.rows, 5); + assert_ne!(staged.offset, old.offset); + assert_eq!(data.rows("a").unwrap().offset, old.offset); + assert_eq!(data.staged("a").unwrap().offset, staged.offset); + assert_eq!( + data.create_rows("a".into(), 6, 16, "u32".into()), + Err("ROWS_PENDING") + ); + assert_eq!(data.delete_rows("a"), Err("ROWS_PENDING")); + assert!(!data.free.take(old.offset - data.base, old.bytes)); + + // The writer keeps writing the old rows until it copies them over and commits. + data.arena[base + 4..base + 8].fill(0xEF); + let target = (staged.offset - data.base) as usize + data.arena_start; + data.arena.copy_within(base..base + 16, target); + data.commit(&["a".into()]).unwrap(); + assert_eq!(data.rows("a").unwrap().offset, staged.offset); + assert_eq!( + &data.bytes("a").unwrap()[..8], + &[0xCD, 0xCD, 0xCD, 0xCD, 0xEF, 0xEF, 0xEF, 0xEF] + ); assert!(data.bytes("a").unwrap()[16..].iter().all(|byte| *byte == 0)); - assert_eq!(data.allocate_object("a", 4).unwrap(), (1, false)); - assert_eq!(data.allocate_object("a", 1).unwrap(), (5, true)); + assert!(data.free.take(old.offset - data.base, old.bytes)); + assert_eq!(data.commit(&["a".into()]), Err("ROWS_PENDING")); + + // Newly staged rows already feed the span allocator; the writer owns the staged region. + data.create_rows("c".into(), 1, 16, "u32".into()).unwrap(); + assert_eq!(data.allocate_object("a", 4).unwrap(), (1, false, false)); + let (id, grew, moved) = data.allocate_object("a", 1).unwrap(); + assert_eq!((id, grew, moved), (5, true, true)); + assert_eq!(data.staged("a").unwrap().rows, 10); + assert_eq!(data.rows("a").unwrap().rows, 5); + data.commit(&["a".into()]).unwrap(); + assert_eq!(rows_of(&data, "a"), 10); +} + +#[test] +fn discarding_a_staged_relocation_restores_capacity() { + let mut data = RenderData::new(4096).unwrap(); + data.create_rows("a".into(), 2, 16, "u32".into()).unwrap(); + data.create_rows("b".into(), 1, 16, "u32".into()).unwrap(); + assert_eq!(data.allocate_object("a", 2).unwrap(), (0, false, false)); + let (staged, moved) = data.create_rows("a".into(), 8, 16, "u32".into()).unwrap(); + assert!(moved); + + data.discard_pending(); + assert_eq!(rows_of(&data, "a"), 2); + assert!(data.free.take(staged.offset - data.base, staged.bytes)); + // Rows 2..8 must not be handed out: they only existed in the discarded region. + assert_eq!(data.allocate_object("a", 1).unwrap(), (2, true, false)); + assert_eq!(rows_of(&data, "a"), 4); } diff --git a/core/worker.js b/core/worker.js index 551e643..11cf0e2 100644 --- a/core/worker.js +++ b/core/worker.js @@ -2,10 +2,13 @@ import initWasm, { Core } from "./pkg/yawn_core.js"; let core; let profileTimer; +// While a relocation is staged, every request except its commit waits here. +let awaitingCommit = false; +const queue = []; const fail = code => { throw new Error(code); }; -addEventListener("message", async ({ data: message }) => { +async function handle(message) { try { let result; if (message?.type === "init") { @@ -21,15 +24,22 @@ addEventListener("message", async ({ data: message }) => { switch (message?.type) { case "create-rows": result = JSON.parse(core.create_rows(message.name, message.rows, message.stride, message.format)); + awaitingCommit = result.commit; break; case "create-rows-batch": result = JSON.parse(core.create_rows_batch(JSON.stringify(message.rows))); + awaitingCommit = result.commit.length > 0; + break; + case "commit-rows": + awaitingCommit = false; + core.commit_rows(JSON.stringify(message.names)); break; case "delete-rows": core.delete_rows(message.name); break; case "allocate-object": result = JSON.parse(core.allocate_object(message.name, message.count ?? 1)); + awaitingCommit = result.commit; break; case "delete-object": core.delete_object(message.name, message.id); @@ -74,4 +84,13 @@ addEventListener("message", async ({ data: message }) => { } catch (error) { postMessage({ request: message?.request, error: error?.message ?? "CORE_ERROR" }); } +} + +addEventListener("message", async ({ data: message }) => { + if (awaitingCommit && message?.type !== "commit-rows") { + queue.push(message); + return; + } + await handle(message); + while (!awaitingCommit && queue.length) await handle(queue.shift()); }); diff --git a/coredocs/guide/shared-memory.md b/coredocs/guide/shared-memory.md index 6f3dcc3..ef3ce46 100644 --- a/coredocs/guide/shared-memory.md +++ b/coredocs/guide/shared-memory.md @@ -25,7 +25,11 @@ function rowFor(buffer, d, index) { } ``` -The buffer stays the same, but a growing row array may relocate. Replace the cached descriptor—and recreate cached views—after every `create-rows`, batch result, and `allocate-object` result. `allocate-object` always returns `{ id, rows: descriptor }`, even without growth. Never infer an address from a prior descriptor. +The buffer stays the same, but a growing row array may relocate. Replace the cached descriptor—and recreate cached views—after every `create-rows`, batch result, and `allocate-object` result. `allocate-object` always returns `{ id, rows: descriptor, commit }`, even without growth. Never infer an address from a prior descriptor. + +### Relocation is owned by the writer + +The worker never copies rows. When growth cannot extend in place it reserves a zeroed region, returns that descriptor with `commit: true` (`create-rows`, `allocate-object`) or the moved names in `commit` (`create-rows-batch`), and keeps rendering from—and the arena owning—the old rows. The main thread is the only writer, so it is the only party that knows its last write landed: on receiving the result it copies the old bytes into the new region, swaps its descriptor, and sends `commit-rows { names }`. The worker then publishes the new descriptor, frees the old rows, and rebuilds the GPU buffers bound to them. Until that commit arrives the worker queues every other request, so a staged relocation is a short, exclusive transaction and no SAB write made at any moment is lost. `index.js` does all of this inside `createRows`, `createRowsBatch`, and `allocateObject`; a raw client must copy and commit synchronously in the same task that received the result. ## Slots diff --git a/coredocs/reference/errors.md b/coredocs/reference/errors.md index e66c980..13999b0 100644 --- a/coredocs/reference/errors.md +++ b/coredocs/reference/errors.md @@ -23,12 +23,13 @@ Worker failures are string codes in `{ request, error }`. GPU/browser validation | `ROWS_BUILTIN` | Attempt to grow/delete/allocate from `signals`. | | `ROWS_UNKNOWN` | Row name does not exist (also possible during frame upload). | | `ROWS_ACTIVE` | Delete blocked by active object IDs or active graph reference. | +| `ROWS_PENDING` | A relocation is staged and uncommitted: growing or deleting that array again, or a `commit-rows` that names a non-staged array or leaves one staged. | | `ARENA_OOM` | Byte multiplication overflow or no aligned free arena block. | | `OBJECT_LIMIT` | Slot ID cannot increment past u32. | | `OBJECT_UNKNOWN` | Delete ID is not active for that row array. | | `FPS` | FPS exceeds 1000. | -On row growth, consume the returned descriptor before the next access. Batch creation is not rollback-safe. Do not retry allocation blindly after an uncertain transport outcome: it may allocate a second slot. +On row growth, consume the returned descriptor before the next access and commit relocations synchronously. In-place batch creations are not rolled back on a later failure. Do not retry allocation blindly after an uncertain transport outcome: it may allocate a second slot. ## Graph parse and planning diff --git a/coredocs/reference/worker.md b/coredocs/reference/worker.md index cd41e31..86c461a 100644 --- a/coredocs/reference/worker.md +++ b/coredocs/reference/worker.md @@ -5,10 +5,11 @@ All fields below are top-level beside `type` and `request`. Except for `init` an | `type` | Fields | Transfer list | Success `result` | Constraints / effects | |---|---|---|---|---| | `init` | `canvas: OffscreenCanvas`, `arenaBytes: number` | `[canvas]` | `{ buffer: SharedArrayBuffer, rows: Descriptor[], features: string[] }` | Once only; initializes WASM/WebGPU. `features` lists optional device features that were available (`timestamp-query`, `indirect-first-instance`). | -| `create-rows` | `name`, `rows`, `stride`, `format` | — | descriptor | Nonempty name; rows > 0; stride ≥16 and multiple of 16; format f32/u32/i32. Existing name may only retain format/stride and grow. | -| `create-rows-batch` | `rows: Array<{name,rows,stride,format}>` | — | descriptor array in input order | Nonempty. Sequential, **not transactional**: earlier creations can survive a later failure. Active GPU resources refresh once after successful batch. | +| `create-rows` | `name`, `rows`, `stride`, `format` | — | `{ rows: descriptor, commit: boolean }` | Nonempty name; rows > 0; stride ≥16 and multiple of 16; format f32/u32/i32. Existing name may only retain format/stride and grow. `commit: true` means the rows moved: copy the old bytes into the descriptor and send `commit-rows`. | +| `create-rows-batch` | `rows: Array<{name,rows,stride,format}>` | — | `{ rows: descriptor[] (input order), commit: string[] }` | Nonempty. Sequential; in-place creations/growths survive a later failure, staged relocations are discarded. `commit` lists the arrays that moved. Active GPU resources refresh once for in-place growth. | +| `commit-rows` | `names: string[]` | — | `undefined` | Publishes staged relocations after the caller copied them. Must name every staged array. Frees the old rows, refreshes bound GPU buffers once, sets `sabDirty`. While a relocation is staged the worker queues every other request until this arrives. | | `delete-rows` | `name` | — | `undefined` | Not `signals`, not active slots, not referenced by active graph. | -| `allocate-object` | `name`, `count: u32 = 1` | — | `{ id, rows: descriptor }` | Not `signals`; `count` > 0; reserves `count` contiguous rows starting at `id`; may grow/relocate. | +| `allocate-object` | `name`, `count: u32 = 1` | — | `{ id, rows: descriptor, commit: boolean }` | Not `signals`; `count` > 0; reserves `count` contiguous rows starting at `id`; may grow. `commit` as in `create-rows`. | | `delete-object` | `name`, `id: u32` | — | `undefined` | ID must be the first row of an active span; the whole span is zeroed. | | `sync-rows` | `name`, `first: u32 = 0`, `count: u32` | — | `undefined` | Uploads rows `first .. first + count` of the array into every active GPU buffer bound to it (any `sync`) and sets `sabDirty`. `ROW_RANGE` when the span exceeds the array. No-op without an active loadout. | | `compile-graph` | `serialized: string` | — | graph ID string | Parses and stores; same ID replaces previously stored graph. Does not activate it. |