Devices and sensor lifetime

CNA snapshot 009d40f5  ·  Development › Module internals  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Test names were located by reading and none was executed; sanitizer results quoted are CNA's own records. Live Android and iOS hardware, the real NDK worker, native Windows, X11 and Wayland sensor and haptic behaviour, and line-by-line Gyroscope and Motion parity remain unverified.

modules/devices/ owns the XNA and Windows Phone shaped Microsoft::Devices surface: Accelerometer, Gyroscope, Compass, Motion, VibrateController and Environment::DeviceType. Its job is to translate a small public API into services of the selected platform while native callbacks, concurrent Stop and Dispose, and even process-exit destruction are all possible, so the module is best read as a lifetime boundary around platform sensors. The separate devices-ext module owns the CNA-specific camera, clipboard, dialog and system services and is not a dependency of this base layer. The user guide is Sensors; this page is for maintainers who change callback, disposal or shutdown code.

Module and backend boundary

modules/devices/CMakeLists.txt builds cna_devices (alias CNA::Devices) from every source under src/ and links cna_runtime, cna_graphics_core, cna_core and cna_math publicly, with Sharp Runtime Core.Base. On Android it additionally links the NDK android and log libraries publicly, because the compass and motion backends call the NDK sensor API directly (no JNI). The base code consults GetCurrentPlatform() and two of its services, IPlatformSensors and IPlatformHaptics; the selected platform implements them. The Android compass and motion path is an additional direct NDK path behind ICompassBackend and IMotionBackend, not a claim that every host has those sensors.

ℹ

The CNA_DEVICES option does not gate this module. At this snapshot modules/CMakeLists.txt adds devices unconditionally, cna_devices is one of the runtime parts of the CNA umbrella target, and no file under modules/devices/ tests the CNA_DEVICES definition. The definition guards only the CNA::Devices headers and sources in devices-ext (see that page). The presets named devices-asan, devices-tsan and devices-ubsan set CNA_DEVICES=ON for that reason, and their own descriptions say what compiles out without it: the CNA::Devices surface, not Microsoft::Devices.

Which service the selected platform returns decides what these classes can do. Read from the platform sources at 009d40f5, the sensor and haptic services are returned like this (a null service is the honest unsupported answer: the platform conformance suite requires each service to be null exactly when its capability is false, and a missing sensor is never a fake stream):

PlatformGetSensors()GetHaptics()
SDL3the SDL3 sensor servicethe SDL3 haptic service
X11, WAYLANDnullptran evdev-based service when the backend was built with evdev and controllers exist, otherwise nullptr
WIN32, SDL2, HEADLESS, TERMINALnullptrnullptr

So Accelerometer and Gyroscope can only be supported through the SDL3 platform (or Android), and the base layer follows that through getIsSupportedProperty: for Accelerometer and Gyroscope it first applies target policy (Android, iOS and Desktop are eligible; Web is refused by policy) and then probes the actual platform with one balanced subsystem lifetime (acquire the Sensor subsystem, ask IsAvailable, release; a missing service or a PlatformException answers false). Compass and Motion report false everywhere except Android, where Compass needs both the rotation-vector and magnetic-field sensors and Motion needs an attitude source (rotation vector, else game rotation vector) plus gravity, linear acceleration and gyroscope. VibrateController reports support only when its backend finds a default vibration device that supports rumble. Each of the four sensor classes allows at most ten live instances per application (MaxSensorCount); the eleventh constructor throws SensorFailedException. Environment::getDeviceTypeProperty answers Device on mobile targets and Emulator elsewhere.

Accelerometer: one sample from native service to user event

Accelerometer::Start
  -> IPlatform::AcquireSubsystem(Sensor)         [per-instance lease, kept until Dispose]
  -> PlatformSensorSubsystem::EnsureSessionLocked
  -> IPlatformSensors::OpenSensor(kind, callback) [one shared session per sensor class]
platform-owned thread: sample -> callback
  -> snapshot started registrations under the class mutex
  -> DispatchToInstances: revalidate owner, publish thread id in the instance token
  -> Accelerometer::ProcessSensorUpdateEvent      [started? throttle?]
  -> DispatchSensorReading -> SensorBase current value + IsDataValid
  -> CurrentValueChanged, then ReadingChanged     [class mutex NOT held]
Stop    -> null the registration's owner, erase it, close the session if it was the last
Dispose -> wait for other threads' in-flight dispatch, then release the platform lease

Construction, Start and Stop

Accelerometer.cpp keeps a function-static PlatformSensorSubsystem<Accelerometer> returned by Accelerometer::GetSubsystem, defined in PlatformSensorSubsystem.hpp; Gyroscope has the corresponding per-class subsystem. The subsystem holds the shared platform session, the list of started registrations, the instance count and the one mutex for all of it. The constructor increments the count under that mutex (throwing at the limit), then probes support outside the mutex, because probing calls into the platform and must never run under it, and begins in Initializing or NotSupported; a throw after the increment rolls the count back.

Start runs entirely under the class mutex. It rejects a second start with AccelerometerFailedException. It refuses to proceed if the instance still holds a platform lease from a different current platform, and otherwise acquires a Sensor subsystem reference on first use (a failure becomes NotSupported plus an exception carrying the platform error). It then opens the shared session if none exists; a platform that has no sensor service, refuses the session, throws or returns null leaves the state NotSupported, rolls back a lease acquired by this very call and throws with the recorded reason. On success it sets Ready, resets the update throttle so a fresh Start always delivers an immediate first sample, and registers the instance. The lease is per instance and survives Stop; it is released only in Dispose. These are the reasons a platform replacement during live sensor use is an ownership problem and not merely a factory switch.

Stop unregisters under the mutex (setting the registration's owner to null before erasing it), sets started_ false and the state to Disabled, and, if no started instances remain, takes the shared session out of the subsystem. That session is destroyed after the mutex is released: the platform contract makes destroying a session a callback barrier (after the destructor returns no callback on another thread is still running and none will start), so an in-flight callback that is itself waiting for the class mutex must not be held up by the destroying thread.

From the platform callback to user events

The session callback copies the vector of shared DispatchRegistration records under the mutex and then, in DispatchToInstances, rechecks each record's owner under the mutex immediately before use. Because Stop nulls the owner before erasing the record, a snapshot copied earlier cannot call an instance destroyed since, even if a new instance reuses the same address. For each live owner the dispatcher appends the current thread id to the instance's shared dispatchToken_ for the duration of the call; a scope guard removes it and notifies waiters even when a handler throws, and swallows anything the cleanup itself throws. User code runs with the subsystem mutex released. A handler exception is caught at the callback boundary, counted, remembered for test hooks and sent to NativeDiagnosticSink; the synthetic-injection test path, by contrast, lets the exception reach its caller after cleanup.

ProcessSensorUpdateEvent drops the sample when the instance is disposed or not started, and drops it when ShouldAcceptUpdateAt(steady_clock::now()) says it arrived within TimeBetweenUpdates of the last accepted one. The default interval is two milliseconds, and the throttle is software-side because the platform service exposes no rate control. Accepted samples reach DispatchSensorReading, which divides the platform's metres per second squared by 9.80665 to give the XNA-style G units, stamps the reading with wall-clock DateTimeOffset::getUtcNowProperty() (the project-wide cross-sensor policy) and, on Android only, remaps portrait-frame axes to the XNA landscape convention by default (a CNA convenience that real Windows Phone 7 does not do, with an opt-out, SetAndroidLandscapeRemapEnabled). Accelerometer then takes a local copy of the ReadingChanged handler list before raising anything, publishes the reading and IsDataValid together, raises CurrentValueChanged and finally raises ReadingChanged from that copy. The copy is what lets a CurrentValueChanged handler destroy the sender without the second event dereferencing a dead this; the order (current value first) is a CNA policy pinned by CurrentValueChangedFiresBeforeReadingChanged.

Dispose waits only for other threads

Dispose first stops the instance, then, under the mutex, waits until no other thread's id remains in this instance's dispatch token, so a handler may dispose its own sender without waiting on itself. It then decrements the instance count (an assertion guards against underflow), clears the started list when the count reaches zero, releases the platform lease and finally publishes the disposed state through the base class. The session destructor runs outside the mutex, as above.

Gyroscope is the same code with a different reading

Gyroscope.cpp was compared with Accelerometer.cpp after removing comments and normalising the class name; the constructor, Start, Stop, Dispose and dispatch code is identical. The differences are the exception type (SensorFailedException rather than AccelerometerFailedException), a rotation-rate reading passed through without the gravity division, no ReadingChanged event, and the Android landscape remap of the rate vector. Parity of the two classes' behaviour on real hardware is not established by this reading.

Shared state, event ordering and failure

SensorBase.hpp (SensorBase<TReading>) guards the current reading, data validity, support flag, disposed flag and update interval with its own mutex, and never holds it across a handler. Throttle decisions use steady_clock, while public reading timestamps stay wall-clock DateTimeOffset; swapping one for the other changes behaviour under clock adjustments. The throttle compares in 100-nanosecond ticks so that TimeSpan::MaxValue cannot overflow a signed multiplication. getCurrentValueProperty returns a copy and throws InvalidOperationException when the sensor is unsupported; after a supported sensor stops, the last reading and its validity are retained. TimeBetweenUpdatesChanged is a CNA protected extension that lets Compass and Motion forward a live interval change to the native queue. Do not infer that sensor callbacks run on the game thread.

Disposal is a two-stage terminal state. ClaimDisposalOnce selects one cleanup winner; every other concurrent caller waits in WaitForDisposalToComplete until disposed_ is published. The winning derived class installs a DisposalTerminalStateGuard immediately, so a cleanup that throws still publishes the state and wakes waiters; a losing caller simply returns and never sees the winner's exception. The winner then stops and unregisters, waits for callback drain, balances the instance count and releases the lease before the base Dispose runs.

CNA documents the supported concurrency contract in docs/devices-thread-safety.md and the event contract in docs/devices-event-contract.md. Read together with the source, they say:

Call patternDocumented status
Property getters and setters of every sensor, concurrent with Start, Stop and DisposeSupported; guarded by the base mutex
Accelerometer and Gyroscope Start, Stop, Dispose across instancesSupported; guarded by the per-class subsystem mutex
Compass and Motion Start, Stop, state read, backend replacementSupported on one instance; guarded by that instance's mutex
Concurrent Dispose against Dispose or StopSupported
Concurrent Dispose against Start on the same instanceNot supported: Dispose reads started_ under the lock, releases it, then calls Stop (which takes the same non-recursive lock), and a Start in that gap can leave a disposed instance whose backend is still logically started
Subscribing or unsubscribing a handler from another thread during a raiseOnly "does not crash"; a handler changing the list from inside the same raise affects the next raise, not the current one

The event contract adds: the dispatch thread is whatever the backend uses and is never the thread that called Start; handlers run in subscription order over a snapshot; CurrentValueChanged and Calibrate have no defined relative order; a handler may dispose or destroy its own sender; and an exception escaping a handler must not cross a C callback or thread entry point. The implementation of that last rule is uneven in time: the contract text says the Android bridge still swallows silently, but at this snapshot AndroidSensorBridge::Run routes such exceptions through the same NativeDiagnosticSink as the platform path. The sink writes a log line only in builds without NDEBUG and otherwise only counts and stores the record for test hooks, so in a release build a swallowed handler exception is not visible outside the tests. The thread-safety text also attributes the instance counter to a static instanceCountMutex_; that is true for Compass and Motion, while Accelerometer and Gyroscope keep the count in their subsystem under the subsystem mutex.

Compass/Motion and Android worker ownership

Compass.cpp and Motion.cpp use per-instance ICompassBackend and IMotionBackend objects instead of a shared session; comparing the two files side by side after normalising names shows the same lifecycle code, differing only in exception messages and in Motion's extra getIsAttitudeNorthReferencedProperty. Off Android there is no backend, getIsSupportedProperty returns false without probing, and Start throws SensorFailedException. Tests reach the logic through a fake backend, and replacing a backend for testing is refused while acquisition is started or transitioning.

The control block and generation

A reading or calibration callback captures a shared_ptr<SensorOwnerControlBlock> and the start generation, never a raw owner pointer and never this (SensorOwnerControlBlock.hpp). Before touching the owner it locks the block's mutex, checks that the generation still matches and the owner is non-null, copies the pointer, and releases the mutex before any code that can raise user events. Stop increments the generation, so callbacks from an older Start become no-ops; Dispose nulls the owner before it calls Stop, so a callback that has not yet passed its check no-ops once disposal has begun. Start and Stop call into the backend outside the control mutex, because a backend may call back synchronously and reenter public methods; a reservation flag (transitioning_) marks the interval.

Two more pieces close specific races. backendCallsInFlight_ and a quiescence condition make a fresh Start wait for an earlier, now orphaned start attempt's own cleanup call into the same backend, so two calls never overlap on one backend object. A start whose backend call finishes after a Stop superseded it does not commit Ready; if the backend did start, the orphaned attempt stops it itself (the superseding Stop did not know about it) and Start returns without throwing. Stop deliberately does not wait for in-flight calls, so it stays non-blocking against a stalled Start; the next Start waits instead. A second Stop while one is claimed waits for the first rather than calling the backend again. The header states what this does not prove: another thread destroying the owner while a callback that already passed its owner check is executing inside it remains unsupported. Destroying the owner from within its own callback on the same thread is safe.

AndroidSensorBridge and its worker

The Android backends sit on AndroidSensorBridge.cpp, one bridge per NDK sensor type: Compass needs the rotation vector and the magnetic field (the heading comes from the OS-fused rotation vector, and the magnetic-field accuracy status drives Calibrate); Motion constructs six bridge objects and starts at most five of them (the rotation vector, or the game rotation vector when the plain one is unavailable, plus gravity, linear acceleration, gyroscope and optionally the magnetic field). Once the attitude source, gravity, linear acceleration and gyroscope are all running it publishes a fused reading only when the four latest samples are within a 500 millisecond window of each other (dropped frames are counted for tests), with the magnetic-field bridge used only for calibration and never required. Each bridge owns an NDK sensor queue and one polling worker thread: the worker prepares its own ALooper, polls with a 100 millisecond timeout, drains at most 64 events per pass, applies a live event-rate change when one is requested, and stops itself after five consecutive failures of ASensorEventQueue_getEvents. Start blocks for a startup handshake bounded at five seconds.

The worker's lifecycle is governed by runState_ (NotRunning, Running, Stopping) and an exit condition variable, not by std::thread::joinable(), because a reentrant self-stop from the bridge's own callback detaches the thread object while the worker is still running. The first Stop to claim the thread (reclaimClaimed_) joins it, or detaches it when it is the worker itself, and every other concurrent Stop waits for the state to reach NotRunning instead of touching the thread object. If the worker does not finish within the five second bound, presumed stuck inside a native call, Stop detaches it and marks the bridge permanently abandoned: it can never Start again, which is a deliberate fatal backend-health state, not a bug. Impl is held by a shared_ptr copy captured in the worker's lambda, so the worker's own state stays alive even if the wrapper is destroyed first. A simple lock around all of Start and Stop would deadlock on reentrancy; if you modify callbacks or teardown, read these state machines and their tests first.

The NDK implementation of the bridge and its worker, and the compass and motion backends, exist only under __ANDROID__: the two backend headers and sources are entirely gated, while AndroidSensorBridge.cpp still compiles on every platform as an inert shim (IsAvailable() returns false, Start() returns false and Stop() does nothing). On a desktop host the tests reach the pure helpers (event-rate conversion, per-sensor value counts, landscape orientation, compass and motion math) and the bridge's non-Android answers (unavailable, start returns false), and reach Compass and Motion only through fake backends. Nothing in a host build exercises the worker, the abandon path or the real NDK calls, and CNA's own event-contract text calls the real Android teardown-from-callback behaviour unverified beyond the fake-backend seam.

VibrateController and process-exit order

VibrateController.cpp exposes a function-static singleton through getDefaultProperty, which also registers, once, the process-exit fallback. Its public Start, Stop, support and device-name methods hold backendMutex_ while they call the backend. Start(duration), Stop and VibrateController::getDefaultProperty are XNA API; intensity, left/right motors, support and device name are CNA extensions. Duration is validated first (zero to five seconds, otherwise ArgumentOutOfRangeException), and intensity and motor magnitudes are clamped to [0, 1] with NaN treated as zero, before the lock is taken and before any backend call.

The default backend, PlatformVibrateBackend.cpp, borrows IPlatformHaptics from the current platform, acquires and releases a Haptic subsystem lease and remembers a device id. Its release path calls through the borrowed platform only when a platform is still installed and is the same one that was borrowed: a flag alone is not enough because it is process-global and a test can reset it, and calling through a freed vtable at process exit was a reproduced crash that this guard fixes. If the selected platform changes, the backend drops its cached service and lease bookkeeping without calling the old platform (the identity check fails, so the old platform's Haptic reference is not released by this path) and then acquires from the new one; whether the old platform even still exists is exactly what the guard cannot know.

DevicesShutdownCoordinator.cpp adds two teardown paths. DevicesShutdownCoordinator::Shutdown resets the controller's backend while the platform and its native services are still alive, then sets a flag that makes later backend destruction skip the release, and is idempotent; afterwards the controller is inert but safe. RegisterProcessExitFallback installs an atexit handler that only sets the flag and touches no platform, because a lazily created default platform may already be destroyed; since it is registered after the singleton is constructed, it runs before the singleton's destructor. The shutdown mutex and flag are intentionally leaked for the same static-destruction reason. Production code at this snapshot never calls Shutdown: within the repository its callers are the tests and the shutdown-ordering harness (shutdown_ordering_harness.cpp). The pinned code relies on the fallback and on the identity guard, so a maintainer must not assume an application executes the explicit path. Test both an explicit-shutdown process and an omitted-shutdown process, and see the uncertainty register. The same reasoning applies to any singleton that borrows a platform service: destruction order at process exit is not something this codebase controls.

Change and validation route

Test names below are as registered in source at 009d40f5; none were executed for these pages. For a sensor event bug, reproduce first with an injected session or a synthetic update in AccelerometerTests or GyroscopeTests, and include ordering, reentrant Stop and Dispose, and a handler that throws.

InvariantTests
Nothing dispatches after Stop or DisposeAccelerometerTests.NoDispatchAfterStop, NoDispatchAfterDispose, StopPreventsSubsequentSyntheticEventFromDispatching
A handler may dispose or destroy its own senderDisposeFromWithinOwnCallbackDoesNotDeadlock (Accelerometer, Gyroscope, Compass and Motion), DestroyingOwnerFromCurrentValueChangedStillFiresReadingChangedSafely, SelfDestroyingFromOwnReadingChangedCallbackDuringInjectSyntheticSensorUpdateDoesNotUseAfterFree, PlatformSensorMigrationTests.HandlerMayDisposeItsOwnSensorWithoutDeadlock
Registration identity survives address reuse and same-batch disposalDisposingDifferentInstanceDuringSameBatchDispatchDoesNotUseAfterFree and DispatchDoesNotDeliverStaleEventToUnrelatedInstanceReusingSameAddress (Accelerometer and Gyroscope)
Concurrent disposal and the instance limitConcurrentDisposeFromMultipleThreadsNeverCorruptsInstanceCount, ConcurrentDisposeLoserWaitsForWinnerCleanupToFinishBeforeStateAppearsDisposed, EleventhSimultaneousInstanceThrows, SensorBaseTests.WinningCleanupExceptionStillUnblocksConcurrentLosingDispose
Handler exceptionsThrowingCallbackDuringSyntheticUpdateStillCleansUpAndDoesNotHangDispose, ThrowingHandlerInBatchDispatchDoesNotPreventNextInstanceFromReceivingItsEvent, ThrowingHandlerDuringDispatchIsAlsoRecordedByTheSharedNativeDiagnosticSink
Compass and Motion lifetime and reentrancySynchronousReadingCallbackDuringStartIsHandledSafely, ConcurrentStopDuringStartDoesNotDeadlock, ConcurrentStartStopFromMultipleThreadsDoesNotCrash, ConcurrentSetTimeBetweenUpdatesAndSetBackendForTestingDoesNotCrash, SetBackendForTestingAfterStartThrowsAndDoesNotReplaceBackend, DestroyingOwnerFromCurrentValueChangedThenFiringCalibrateDoesNotCrash
Shutdown orderDevicesShutdownCoordinatorTest.* (four cases, in the file DevicesShutdownCoordinatorTests), PlatformVibrateMigrationTests.ShutdownReleasesPlatformBeforeMakingControllerInert and DestroyingABackendDoesNotCallAPlatformThatIsNoLongerInstalled, and DevicesShutdownOrderingTest.HarnessExitsCleanlyAfterShutdownCoordinatorThenRealSdlQuit and HarnessExitsCleanlyViaFallbackWhenExplicitShutdownIsOmitted
Vibration argument policythe VibrateControllerTests group (63 cases, including the range, NaN and infinity forwarding cases and ConcurrentCallsFromMultipleThreadsDoNotCrashOrDeadlock)

One in-process unit test cannot exercise static destruction after SDL_Quit, so the two ordering tests spawn a separate process (the harness target is built only where SDL3 is configured; elsewhere the suite reports a skip). The harness returns 0 whenever it reaches the end of main, and the tests assert only that exit code under a ten second watchdog, so outside a sanitizer build they show that the process neither crashes nor hangs; the finding a sanitizer build adds is an abort or a report on stderr. Their own comments state a further scope limit: a successfully opened haptic device is not available in the environment they were written in, so the native haptic-close guard specifically is reasoned from source, not reproduced.

For races, the presets devices-asan, devices-tsan and devices-ubsan each build CnaTests with CNA_DEVICES=ON and one sanitizer. The devices-tsan description records a known, unrelated report in a Sharp Runtime TimeSpan copy-count debug counter (the report concerns TimeSpan::copy_count); that text describes an older sharp-runtime, since the counters became atomic in sharp-runtime 9c2cb0ae (2026-07-07), so a current checkout should not reproduce it; still preserve and classify reports rather than suppressing them. CNA's documents report that the Devices suite under ThreadSanitizer showed no race in the devices code beyond that one; none of these sanitizer configurations was run for these pages. The dedicated workflow (devices-tests.yml) builds only the devices-ubsan preset and runs two filters. By comparison of its exact-name filter with the TEST macros, seven of the 28 suite names in modules/devices/tests are not in that filter (DevicesShutdownCoordinatorTest, DevicesShutdownOrderingTest, EnvironmentTests, IndependentReferenceCrossCheckTests, NativeDiagnosticSinkTest, PlatformSensorMigrationTests, PlatformVibrateMigrationTests), so a green run of that workflow does not cover the shutdown-order tests above; the unfiltered general workflow may. It is desktop and headless evidence: hardware-dependent tests such as the supported-sensor GetCurrentValuePropertyDoesNotThrowWhenSupported cases self-skip when no sensor exists.

Before approving a patch, answer these:

  • Who owns the platform lease and the session?
  • Can Stop invoke, or wait for, a callback?
  • Is a registration invalidated before its owner dies?
  • Is a lock held across user code?
  • Does a foreign native worker outlive a detached std::thread object?
  • Does the selected platform change while a borrowed service pointer remains?
  • Which target policy and which real hardware host establish the behaviour?

These questions are worth more than a broad claim that sensors are thread safe.

⚠

What remains unverified. Line-by-line parity of Gyroscope with Accelerometer and of Motion with Compass beyond the structural comparison above, live Android and iOS hardware, the real NDK worker, and native Windows, X11 and Wayland sensor and haptic behaviour were not exercised. This page reports the source and test files at 009d40f5.

Source reading order

  1. SensorBase.hpp and devices-thread-safety.md: establish the shared state and the exact supported concurrency contract, including the documented gap.
  2. Accelerometer.cpp and PlatformSensorSubsystem.hpp: follow one public Start, the shared registration, the callback snapshot, Stop and the disposal barrier.
  3. Compass.cpp, SensorOwnerControlBlock.hpp and AndroidSensorBridge.cpp: compare per-instance generation and callback ownership with the shared-session model.
  4. VibrateController.cpp, PlatformVibrateBackend.cpp and DevicesShutdownCoordinator.cpp: understand the singleton, backend and platform destruction order before altering haptics.
  5. AccelerometerTests.cpp, CompassTests.cpp and DevicesShutdownOrderingTests.cpp: learn which interleavings and process-exit orders are asserted, then inspect the platform implementation being changed.

Cross-cutting rules are collected in the ownership and lifetime master map and the thread and callback map; the module's place among its siblings is in the module index, and the debugging procedure for teardown problems is I need to debug shutdown and lifetime behavior. The companion pages are Phone compatibility internals (the other module with a process-exit hazard) and Platform backends.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.