oxiproj_transformations/conversions/push_pop.rs
1//! `push` and `pop` pipeline coordinate-stack operations.
2//!
3//! Ported from PROJ `src/conversions/push_pop.cpp` (part of the pipeline
4//! implementation in `src/pipeline.cpp`).
5//!
6//! # Overview
7//!
8//! Within a PROJ pipeline, `push` saves selected coordinate components onto a
9//! stack and `pop` restores them. This lets the pipeline temporarily
10//! overwrite components for an intermediate step and then recover the originals.
11//!
12//! Example pipeline (preserve X/Y, do a vertical-only transform):
13//!
14//! ```text
15//! +proj=pipeline
16//! +step +proj=push +v_1 +v_2
17//! +step +proj=somevert ...
18//! +step +proj=pop +v_1 +v_2
19//! ```
20//!
21//! # Component selection
22//!
23//! The parameters `+v_1`, `+v_2`, `+v_3`, `+v_4` select which of the four
24//! coordinate slots (X/Y/Z/T) are pushed or popped. If **none** of the four
25//! flags is present the operation applies to **all four** components, mirroring
26//! PROJ's default behaviour.
27//!
28//! # Directionality
29//!
30//! | operation | forward | inverse |
31//! |-----------|----------|----------|
32//! | `push` | save | restore |
33//! | `pop` | restore | save |
34//!
35//! # Stack ownership and pipeline scoping
36//!
37//! PROJ stores the four push/pop stacks in each pipeline's own opaque state
38//! (`struct Pipeline::stack[4]` in `pipeline.cpp`); a `push`/`pop` step reaches
39//! it via `P->parent->opaque`, i.e. the specific `Pipeline` object that
40//! contains it. A nested `+proj=pipeline` step gets its *own* `Pipeline`
41//! object (and therefore its own, isolated stack) — two unrelated pipelines,
42//! or a pipeline nested inside another, never see each other's pushed values.
43//!
44//! This crate's [`Operation`] trait has no notion of "the pipeline object that
45//! owns me" (that ownership lives in `oxiproj-engine`'s `Pipeline`, outside
46//! this crate), so per-object storage is not directly available here. Instead
47//! this module reproduces the same isolation with an explicit, opt-in dynamic
48//! scope: [`enter_pipeline_scope`] opens a fresh, empty stack frame that every
49//! `push`/`pop` call on the current thread sees until the returned
50//! [`PipelineStackScope`] guard is dropped; dropping discards that frame
51//! (and anything left on it — e.g. a value pushed by a step whose sibling
52//! step, between the `push` and its matching `pop`, errored out and aborted
53//! the pipeline before the `pop` ran) and restores the enclosing frame.
54//! Nested scopes layer naturally with ordinary Rust call/return nesting,
55//! exactly mirroring nested `Pipeline` objects each getting their own stack.
56//!
57//! A single base frame always exists per thread and is never dropped, so
58//! callers that do not open an explicit scope keep the previous, unscoped
59//! behaviour (one shared frame for the life of the thread) — this is the
60//! degraded fallback used until `oxiproj-engine`'s pipeline driver is wired to
61//! call `enter_pipeline_scope()` around each pipeline object's per-point
62//! traversal.
63//!
64//! # Thread safety
65//!
66//! The stack frames are stored in a `thread_local!` `RefCell`, which is never
67//! shared across threads. The operation structs themselves contain only
68//! `[bool; 4]` and are therefore `Send + Sync`. [`PipelineStackScope`] is
69//! deliberately `!Send`/`!Sync` (it carries a `PhantomData<*const ()>`
70//! marker): the frame it opens and the frame it pops on `Drop` both live in
71//! *this thread's* `thread_local`, so moving the guard to another thread and
72//! dropping it there would pop a frame that thread never opened.
73
74use std::cell::RefCell;
75use std::marker::PhantomData;
76
77use crate::{TransBuild, TransParams};
78use oxiproj_core::{Coord, IoUnits, Operation, ProjError, ProjResult};
79
80// ---------------------------------------------------------------------------
81// Thread-local coordinate stack frames
82// ---------------------------------------------------------------------------
83
84/// One frame of the four coordinate-component stacks: index 0 → X (or λ),
85/// 1 → Y (or φ), 2 → Z, 3 → T.
86type StackFrame = [Vec<f64>; 4];
87
88fn empty_frame() -> StackFrame {
89 [Vec::new(), Vec::new(), Vec::new(), Vec::new()]
90}
91
92thread_local! {
93 /// Per-thread stack of `push`/`pop` coordinate-stack frames. See the
94 /// module-level "Stack ownership and pipeline scoping" section for the
95 /// full rationale. Frame 0 (the base frame) always exists.
96 ///
97 /// Using `RefCell` is safe here because each thread owns its own instance;
98 /// there is never cross-thread aliasing. The operation structs do **not**
99 /// hold a reference to this `RefCell`; they access it only via the
100 /// `thread_local!` key, so they remain `Send + Sync`.
101 static COORD_STACK_FRAMES: RefCell<Vec<StackFrame>> = RefCell::new(vec![empty_frame()]);
102}
103
104// ---------------------------------------------------------------------------
105// Pipeline-scoped stack isolation
106// ---------------------------------------------------------------------------
107
108/// RAII guard returned by [`enter_pipeline_scope`]. Dropping it discards the
109/// stack frame it opened — including any value left on it by an unmatched
110/// `push` — and restores the enclosing frame as current.
111#[must_use = "the pipeline stack scope is only active while this guard is alive; \
112 binding it to `_` drops it immediately and closes the scope"]
113#[derive(Debug)]
114pub struct PipelineStackScope {
115 // Not constructible outside this module; also forces `!Send`/`!Sync` so
116 // the guard cannot be dropped on a thread other than the one that opened
117 // its frame (see the module-level "Thread safety" section).
118 _marker: PhantomData<*const ()>,
119}
120
121/// Open a fresh, isolated `push`/`pop` coordinate-stack frame for the current
122/// thread, returning a guard that closes the frame (discarding anything left
123/// on it) when dropped.
124///
125/// Intended to be called once per constructed pipeline object, around each
126/// point it transforms — mirroring PROJ giving every `Pipeline` its own
127/// opaque `stack[4]`. Doing so gives two guarantees beyond a single shared
128/// thread-wide stack:
129///
130/// * **Cross-pipeline isolation** — two unrelated pipelines (or
131/// `Transformer`s) that both contain `push`/`pop`, used alternately on the
132/// same thread, can never see each other's stacked values, because each
133/// opens its own frame.
134/// * **Error-path cleanup** — if a step between `push` and its matching `pop`
135/// returns an error and the pipeline aborts before reaching the `pop`, the
136/// orphaned value is discarded when the scope guard drops instead of
137/// lingering on a shared stack for a later, unrelated point to pop.
138///
139/// Nested pipelines layer correctly: entering a nested scope while an outer
140/// one is still open (as happens when a nested pipeline's
141/// `forward_4d`/`inverse_4d` runs inside an outer pipeline's step loop)
142/// pushes an additional frame on top and restores the outer frame on drop.
143///
144/// Callers that never invoke this function are unaffected: all `push`/`pop`
145/// operations keep sharing the single base frame, exactly as before this
146/// scoping mechanism was introduced.
147pub fn enter_pipeline_scope() -> PipelineStackScope {
148 COORD_STACK_FRAMES.with(|cell| {
149 cell.borrow_mut().push(empty_frame());
150 });
151 PipelineStackScope {
152 _marker: PhantomData,
153 }
154}
155
156impl Drop for PipelineStackScope {
157 fn drop(&mut self) {
158 COORD_STACK_FRAMES.with(|cell| {
159 let mut frames = cell.borrow_mut();
160 // Never drop the base frame: it is the fallback shared frame used
161 // by callers that don't open an explicit scope.
162 if frames.len() > 1 {
163 frames.pop();
164 }
165 });
166 }
167}
168
169// ---------------------------------------------------------------------------
170// Helpers
171// ---------------------------------------------------------------------------
172
173/// Push selected components of `c` onto the current thread-local stack frame
174/// (the innermost frame opened by [`enter_pipeline_scope`], or the shared
175/// base frame if no scope is open).
176///
177/// Returns `c` unmodified; `push` is a pure side-effect on the stack.
178fn do_push(c: Coord, which: [bool; 4]) -> ProjResult<Coord> {
179 let v = c.v();
180 COORD_STACK_FRAMES.with(|cell| {
181 let mut frames = cell.borrow_mut();
182 if let Some(frame) = frames.last_mut() {
183 for i in 0..4 {
184 if which[i] {
185 frame[i].push(v[i]);
186 }
187 }
188 }
189 // `frames` is never empty (the base frame is never popped), so the
190 // `None` arm is unreachable in practice; it is handled as a no-op
191 // rather than a panic to uphold the no-panics production policy.
192 });
193 Ok(c)
194}
195
196/// Pop selected components from the current thread-local stack frame into
197/// `c`.
198///
199/// Components not selected by `which` are passed through unchanged.
200/// If a stack is empty for a selected component (underflow), that component
201/// is left at its current value — this matches PROJ's silent behaviour.
202fn do_pop(c: Coord, which: [bool; 4]) -> ProjResult<Coord> {
203 let mut v = c.v();
204 COORD_STACK_FRAMES.with(|cell| {
205 let mut frames = cell.borrow_mut();
206 if let Some(frame) = frames.last_mut() {
207 for i in 0..4 {
208 if which[i] {
209 if let Some(val) = frame[i].pop() {
210 v[i] = val;
211 }
212 // Stack underflow: leave `v[i]` unchanged (PROJ silent pass-through)
213 }
214 }
215 }
216 });
217 Ok(Coord::new(v[0], v[1], v[2], v[3]))
218}
219
220// ---------------------------------------------------------------------------
221// Operation structs
222// ---------------------------------------------------------------------------
223
224/// `push` pipeline operation — saves coordinate components onto the thread-local stack.
225///
226/// Forward direction: push (save). Inverse direction: pop (restore).
227#[derive(Debug)]
228struct PushOp {
229 /// Which of the four coordinate components to push/pop.
230 which: [bool; 4],
231}
232
233/// `pop` pipeline operation — restores coordinate components from the thread-local stack.
234///
235/// Forward direction: pop (restore). Inverse direction: push (save).
236#[derive(Debug)]
237struct PopOp {
238 /// Which of the four coordinate components to push/pop.
239 which: [bool; 4],
240}
241
242// SAFETY: `PushOp`/`PopOp` contain only `[bool; 4]`, which is `Send + Sync`.
243// The thread-local `COORD_STACK_FRAMES` is never stored in the struct; it is
244// accessed only at call time via the `thread_local!` key. Therefore the
245// structs are safe to move and share across threads even though the backing
246// storage is thread-local.
247
248impl Operation for PushOp {
249 /// Forward: save selected components onto the stack, return `c` unchanged.
250 fn forward_4d(&self, c: Coord) -> ProjResult<Coord> {
251 do_push(c, self.which)
252 }
253
254 /// Inverse: restore selected components from the stack into `c`.
255 fn inverse_4d(&self, c: Coord) -> ProjResult<Coord> {
256 do_pop(c, self.which)
257 }
258
259 fn has_inverse(&self) -> bool {
260 true
261 }
262}
263
264impl Operation for PopOp {
265 /// Forward: restore selected components from the stack into `c`.
266 fn forward_4d(&self, c: Coord) -> ProjResult<Coord> {
267 do_pop(c, self.which)
268 }
269
270 /// Inverse: save selected components onto the stack, return `c` unchanged.
271 fn inverse_4d(&self, c: Coord) -> ProjResult<Coord> {
272 do_push(c, self.which)
273 }
274
275 fn has_inverse(&self) -> bool {
276 true
277 }
278}
279
280// ---------------------------------------------------------------------------
281// Parameter parsing
282// ---------------------------------------------------------------------------
283
284/// Parse `+v_1` … `+v_4` flags from the parameter block.
285///
286/// If **none** of the four flags is present, all four components are selected
287/// (PROJ default: push/pop everything when no component filter is given).
288fn parse_which(p: &TransParams) -> [bool; 4] {
289 let v1 = p.params.exists("v_1");
290 let v2 = p.params.exists("v_2");
291 let v3 = p.params.exists("v_3");
292 let v4 = p.params.exists("v_4");
293 if !v1 && !v2 && !v3 && !v4 {
294 // No filter → apply to all four components (PROJ default behaviour)
295 [true, true, true, true]
296 } else {
297 [v1, v2, v3, v4]
298 }
299}
300
301// ---------------------------------------------------------------------------
302// Public constructors
303// ---------------------------------------------------------------------------
304
305/// Construct the `push` coordinate-stack operation.
306///
307/// Reads `+v_1` … `+v_4` flags to determine which components are saved.
308/// If none are specified all four components are saved.
309pub fn new_push(p: &TransParams) -> ProjResult<TransBuild> {
310 let _ = ProjError::InvalidOp; // ensure error type is used (suppress lint)
311 Ok(TransBuild::new(
312 Box::new(PushOp {
313 which: parse_which(p),
314 }),
315 IoUnits::Whatever,
316 IoUnits::Whatever,
317 ))
318}
319
320/// Construct the `pop` coordinate-stack operation.
321///
322/// Reads `+v_1` … `+v_4` flags to determine which components are restored.
323/// If none are specified all four components are restored.
324pub fn new_pop(p: &TransParams) -> ProjResult<TransBuild> {
325 Ok(TransBuild::new(
326 Box::new(PopOp {
327 which: parse_which(p),
328 }),
329 IoUnits::Whatever,
330 IoUnits::Whatever,
331 ))
332}
333
334// ---------------------------------------------------------------------------
335// Tests
336// ---------------------------------------------------------------------------
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use oxiproj_core::{Coord, Ellipsoid};
342
343 // Minimal no-op parameter set for constructors that don't need params.
344 struct NoParams;
345 impl crate::TransParamLookup for NoParams {
346 fn get_dms(&self, _key: &str) -> Option<f64> {
347 None
348 }
349 fn get_f64(&self, _key: &str) -> Option<f64> {
350 None
351 }
352 fn get_int(&self, _key: &str) -> Option<i64> {
353 None
354 }
355 fn get_str(&self, _key: &str) -> Option<&str> {
356 None
357 }
358 fn get_bool(&self, _key: &str) -> bool {
359 false
360 }
361 fn exists(&self, _key: &str) -> bool {
362 false
363 }
364 }
365
366 // Parameter set that marks v_1 and v_2 as present.
367 struct V1V2Params;
368 impl crate::TransParamLookup for V1V2Params {
369 fn get_dms(&self, _key: &str) -> Option<f64> {
370 None
371 }
372 fn get_f64(&self, _key: &str) -> Option<f64> {
373 None
374 }
375 fn get_int(&self, _key: &str) -> Option<i64> {
376 None
377 }
378 fn get_str(&self, _key: &str) -> Option<&str> {
379 None
380 }
381 fn get_bool(&self, _key: &str) -> bool {
382 false
383 }
384 fn exists(&self, key: &str) -> bool {
385 key == "v_1" || key == "v_2"
386 }
387 }
388
389 fn wgs84() -> Ellipsoid {
390 Ellipsoid::named("WGS84").expect("WGS84 ellipsoid must be available")
391 }
392
393 /// Reset the thread-local stack frames to a single empty base frame
394 /// before each test, avoiding state leakage across tests that may share
395 /// a thread (e.g. under `cargo nextest`'s thread reuse).
396 fn clear_stacks() {
397 COORD_STACK_FRAMES.with(|cell| {
398 *cell.borrow_mut() = vec![empty_frame()];
399 });
400 }
401
402 /// Number of frames currently open on this thread (1 == just the base
403 /// frame, i.e. no [`enter_pipeline_scope`] guard is alive).
404 fn frame_count() -> usize {
405 COORD_STACK_FRAMES.with(|cell| cell.borrow().len())
406 }
407
408 #[test]
409 fn push_all_then_pop_all_round_trips() -> ProjResult<()> {
410 clear_stacks();
411 let push = PushOp {
412 which: [true, true, true, true],
413 };
414 let pop = PopOp {
415 which: [true, true, true, true],
416 };
417 let original = Coord::new(1.0, 2.0, 3.0, 4.0);
418 let after_push = push.forward_4d(original)?;
419 // push does not modify coordinates
420 assert_eq!(after_push.v(), [1.0, 2.0, 3.0, 4.0]);
421
422 // Overwrite the coordinate
423 let modified = Coord::new(9.0, 8.0, 7.0, 6.0);
424 let restored = pop.forward_4d(modified)?;
425 // pop should restore all four components
426 assert_eq!(restored.v(), [1.0, 2.0, 3.0, 4.0]);
427 Ok(())
428 }
429
430 #[test]
431 fn push_v1_v2_only_preserves_v3_v4() -> ProjResult<()> {
432 clear_stacks();
433 let push = PushOp {
434 which: [true, true, false, false],
435 };
436 let pop = PopOp {
437 which: [true, true, false, false],
438 };
439 let original = Coord::new(10.0, 20.0, 30.0, 40.0);
440 push.forward_4d(original)?;
441
442 // After modification only v3/v4 changed on the current coord
443 let modified = Coord::new(99.0, 88.0, 77.0, 66.0);
444 let restored = pop.forward_4d(modified)?;
445 // v_1 and v_2 restored; v_3 and v_4 come from `modified`
446 assert_eq!(restored.v()[0], 10.0);
447 assert_eq!(restored.v()[1], 20.0);
448 assert_eq!(restored.v()[2], 77.0);
449 assert_eq!(restored.v()[3], 66.0);
450 Ok(())
451 }
452
453 #[test]
454 fn push_inverse_acts_as_pop() -> ProjResult<()> {
455 clear_stacks();
456 let push = PushOp {
457 which: [true, true, true, true],
458 };
459 // Save via forward (push)
460 let saved = Coord::new(5.0, 6.0, 7.0, 8.0);
461 push.forward_4d(saved)?;
462
463 // Restore via inverse (pop)
464 let modified = Coord::new(0.0, 0.0, 0.0, 0.0);
465 let restored = push.inverse_4d(modified)?;
466 assert_eq!(restored.v(), [5.0, 6.0, 7.0, 8.0]);
467 Ok(())
468 }
469
470 #[test]
471 fn pop_inverse_acts_as_push() -> ProjResult<()> {
472 clear_stacks();
473 let pop = PopOp {
474 which: [true, true, true, true],
475 };
476 // Save via inverse (push)
477 let saved = Coord::new(11.0, 22.0, 33.0, 44.0);
478 pop.inverse_4d(saved)?;
479
480 // Restore via forward (pop)
481 let modified = Coord::new(0.0, 0.0, 0.0, 0.0);
482 let restored = pop.forward_4d(modified)?;
483 assert_eq!(restored.v(), [11.0, 22.0, 33.0, 44.0]);
484 Ok(())
485 }
486
487 #[test]
488 fn no_params_defaults_to_all_components() {
489 let which = parse_which(&TransParams {
490 ellipsoid: &wgs84(),
491 params: &NoParams,
492 registry: None,
493 });
494 assert_eq!(which, [true, true, true, true]);
495 }
496
497 #[test]
498 fn v1_v2_params_selects_first_two() {
499 let which = parse_which(&TransParams {
500 ellipsoid: &wgs84(),
501 params: &V1V2Params,
502 registry: None,
503 });
504 assert_eq!(which, [true, true, false, false]);
505 }
506
507 #[test]
508 fn new_push_builds_successfully() {
509 let ell = wgs84();
510 let p = TransParams {
511 ellipsoid: &ell,
512 params: &NoParams,
513 registry: None,
514 };
515 assert!(new_push(&p).is_ok());
516 }
517
518 #[test]
519 fn new_pop_builds_successfully() {
520 let ell = wgs84();
521 let p = TransParams {
522 ellipsoid: &ell,
523 params: &NoParams,
524 registry: None,
525 };
526 assert!(new_pop(&p).is_ok());
527 }
528
529 #[test]
530 fn stack_underflow_leaves_component_unchanged() -> ProjResult<()> {
531 clear_stacks();
532 let pop = PopOp {
533 which: [true, false, false, false],
534 };
535 // Stack is empty; component should remain as-is (no panic, no error)
536 let c = Coord::new(99.0, 1.0, 2.0, 3.0);
537 let result = pop.forward_4d(c)?;
538 assert_eq!(result.v()[0], 99.0); // unchanged since stack was empty
539 Ok(())
540 }
541
542 #[test]
543 fn push_and_pop_have_inverse() {
544 let push = PushOp { which: [true; 4] };
545 let pop = PopOp { which: [true; 4] };
546 assert!(push.has_inverse());
547 assert!(pop.has_inverse());
548 }
549
550 // ---- Pipeline-scoped stack isolation (cross-pipeline / error-path) ----
551
552 #[test]
553 fn enter_pipeline_scope_opens_and_closes_a_frame() {
554 clear_stacks();
555 assert_eq!(frame_count(), 1);
556 {
557 let _scope = enter_pipeline_scope();
558 assert_eq!(frame_count(), 2);
559 {
560 let _nested = enter_pipeline_scope();
561 assert_eq!(frame_count(), 3);
562 }
563 assert_eq!(frame_count(), 2);
564 }
565 assert_eq!(frame_count(), 1);
566 }
567
568 /// Regression test for the cross-pipeline contamination finding: two
569 /// "pipelines" (simulated by two `PushOp`/`PopOp` pairs, each guarded by
570 /// its own `enter_pipeline_scope`) used alternately on the same thread
571 /// must never observe each other's pushed values, even when the first
572 /// one aborts mid-pipeline (push with no matching pop, mirroring a step
573 /// between `push` and `pop` returning an error).
574 #[test]
575 fn cross_pipeline_scopes_do_not_contaminate() -> ProjResult<()> {
576 clear_stacks();
577 let push = PushOp { which: [true; 4] };
578 let pop = PopOp { which: [true; 4] };
579
580 // "Pipeline A" processes a point: push succeeds, then a later step
581 // fails and the pipeline aborts before reaching `pop`. The scope
582 // guard drops here (end of the `{ }` block) without a matching pop,
583 // exactly as `Pipeline::forward_4d` returning `Err` mid-way would
584 // leave the guard's block.
585 {
586 let _scope_a = enter_pipeline_scope();
587 let point_a = Coord::new(111.0, 222.0, 333.0, 444.0);
588 push.forward_4d(point_a)?;
589 // (simulated failing step here; `pop` is never reached)
590 }
591
592 // "Pipeline B" processes a completely unrelated point on the same
593 // thread. If the stacks were still a single shared thread-local
594 // (the pre-fix behaviour), B's `pop` would silently receive A's
595 // orphaned values instead of its own.
596 let restored_b = {
597 let _scope_b = enter_pipeline_scope();
598 let point_b = Coord::new(1.0, 2.0, 3.0, 4.0);
599 push.forward_4d(point_b)?;
600 pop.forward_4d(Coord::new(9.0, 9.0, 9.0, 9.0))?
601 };
602 assert_eq!(
603 restored_b.v(),
604 [1.0, 2.0, 3.0, 4.0],
605 "pipeline B must recover its own pushed point, not pipeline A's orphaned one"
606 );
607
608 // Back at the base (un-scoped) frame: pipeline A's orphaned push was
609 // discarded when its scope dropped, and pipeline B's scope is closed
610 // too, so a bare pop here (no scope open) must NOT see either of
611 // their leaked values — it hits the empty base frame (underflow) and
612 // leaves the component unchanged (PROJ silent pass-through).
613 clear_stacks();
614 let untouched = Coord::new(7.0, 8.0, 9.0, 10.0);
615 let after_pop = pop.forward_4d(untouched)?;
616 assert_eq!(after_pop.v(), untouched.v());
617 Ok(())
618 }
619
620 /// Nested-pipeline regression test: an inner pipeline's `push`/`pop`
621 /// (its own `enter_pipeline_scope`, opened while an outer scope is still
622 /// active — as happens when a nested `+proj=pipeline` step's
623 /// `forward_4d` runs inside an outer pipeline's step loop) must not
624 /// disturb the outer pipeline's own stacked value, and the outer scope's
625 /// value must still be there once the nested scope closes.
626 #[test]
627 fn nested_pipeline_scope_isolates_from_outer() -> ProjResult<()> {
628 clear_stacks();
629 let push = PushOp { which: [true; 4] };
630 let pop = PopOp { which: [true; 4] };
631
632 let _outer = enter_pipeline_scope();
633 let outer_point = Coord::new(1000.0, 2000.0, 3000.0, 4000.0);
634 push.forward_4d(outer_point)?;
635
636 // Nested pipeline: pushes and pops its own, different value, fully
637 // within its own nested scope.
638 {
639 let _inner = enter_pipeline_scope();
640 let inner_point = Coord::new(1.0, 1.0, 1.0, 1.0);
641 push.forward_4d(inner_point)?;
642 let inner_restored = pop.forward_4d(Coord::new(0.0, 0.0, 0.0, 0.0))?;
643 assert_eq!(
644 inner_restored.v(),
645 [1.0, 1.0, 1.0, 1.0],
646 "nested pipeline must recover its own value"
647 );
648 }
649
650 // Back in the outer scope: its pushed value must be intact, unmixed
651 // with anything the nested pipeline did.
652 let outer_restored = pop.forward_4d(Coord::new(0.0, 0.0, 0.0, 0.0))?;
653 assert_eq!(
654 outer_restored.v(),
655 [1000.0, 2000.0, 3000.0, 4000.0],
656 "outer pipeline's value must survive a nested pipeline's own push/pop"
657 );
658 Ok(())
659 }
660
661 /// Error-path cleanup regression test: if a pipeline aborts after `push`
662 /// but before its matching `pop` (simulated by dropping the scope guard
663 /// without popping), the orphaned value must not resurface for a later,
664 /// unrelated point processed in a fresh scope on the same thread.
665 #[test]
666 fn error_path_orphaned_push_is_discarded_on_scope_drop() -> ProjResult<()> {
667 clear_stacks();
668 let push = PushOp { which: [true; 4] };
669 let pop = PopOp { which: [true; 4] };
670
671 {
672 let _aborted_scope = enter_pipeline_scope();
673 push.forward_4d(Coord::new(42.0, 42.0, 42.0, 42.0))?;
674 // Pipeline aborts: matching `pop` never runs, scope drops here.
675 }
676
677 // A fresh point, fresh scope: must not see the orphaned 42.0 values.
678 let fresh_scope = enter_pipeline_scope();
679 let fresh_point = Coord::new(5.0, 6.0, 7.0, 8.0);
680 push.forward_4d(fresh_point)?;
681 let restored = pop.forward_4d(Coord::new(0.0, 0.0, 0.0, 0.0))?;
682 assert_eq!(restored.v(), [5.0, 6.0, 7.0, 8.0]);
683 drop(fresh_scope);
684 Ok(())
685 }
686}