diff --git a/CHANGELOG.md b/CHANGELOG.md index 8075b17..e3dc1a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`). ## [Unreleased] +- Settle camera/sample startup promises after Stop, replacement, failure or the + preparation deadline even while camera acquisition or video playback remains + pending. Preserve late-response cleanup and replacement ownership; native + browser work is not cancelled. Add exact-deadline and real-browser controls. + - Reuse the shared native GPU runtime in renderer tests while keeping renderer ownership and parallel execution. Add synchronized shared-runtime/independent renderer coverage and three Linux CI processes that require actual GPU diff --git a/benchmarks/camera-startup-completion-plan.json b/benchmarks/camera-startup-completion-plan.json new file mode 100644 index 0000000..5b0132d --- /dev/null +++ b/benchmarks/camera-startup-completion-plan.json @@ -0,0 +1,30 @@ +{ + "schema": "spatialrust.camera-startup-completion-plan.v1", + "slice": "146AQ", + "parent_merge": "fde55df103a72aaea0b557dccfe2557c83d11362", + "purpose": "Settle cancelled camera/sample start calls even when native acquisition or presenter startup remains pending, retaining late-resource cleanup and replacement-session ownership.", + "contract": { + "completion": "Each run owns a cancellation completion. Stop, failure and the unchanged startup deadline release the application start wait with false; successful startup returns true. Cancellation applies both before worker/camera readiness and while awaiting onReady.", + "ownership": "Native getUserMedia and video.play cannot be cancelled by settling the application wait. Their eventual completion/rejection remains observed; late acquired streams stop all tracks, and presenter epoch checks retain replacement ownership. No claim of native cancellation or physical memory bounds across restarts.", + "verification": "Reproduce parent start waits remaining unsettled after Stop with a ready worker and pending acquisition, and with pending video.play. Deterministic unit controls exercise Stop, exact startup timeout, failure, replacement, late success/rejection and synchronous failure. Browser controls hold real fake-camera acquisition or video playback, require application completion before release, and verify cleanup plus replacement sample progress. Retain all existing real WASM/model, geometry, capture/result/progress, CSP and privacy assertions.", + "preservation": "No Rust/core/GPU/dependency change or compilation. Preserve scientific JSON, frozen native outputs, prior camera and renderer evidence, resolved lockfile and release WASM. Reuse installed runtime/browser, matching bindgen and pinned vendor hardlinks." + }, + "resources": { + "build_root": "E:/SpatialRustBuild/camera-startup-completion-v1", + "cargo_cache": "E:/SpatialRustBuild/maturity-80-target", + "build_max_bytes": 100663296, + "positive_cache_growth_max_bytes": 8388608, + "combined_new_and_positive_cache_growth_max_bytes": 109051904, + "minimum_free_bytes": 21474836480, + "workers": 1, + "command_timeout_seconds": 300, + "sampling_seconds": 5, + "asset_download_max_bytes": 0, + "sensor_downloads": 0, + "new_sensor_payload_copies": 0, + "strategy": "Fresh E: site with hash-checked vendor hardlinks and reused release WASM. All local command inputs are source-bound; preserve previous outputs and failures.", + "public_verification_stream_max_bytes": 33554432, + "asset_download_scope": "No new local SDK/model acquisition. Bounded public HTTPS verification streams without retaining vendor payloads." + }, + "remaining_80_percent": "Canonical calibration/timing, independent real motion, full-room accuracy, qualifying physical Linux hardware and integrated operational acceptance remain open." +} diff --git a/crates/spatialrust-web/web/camera_controller.test.mjs b/crates/spatialrust-web/web/camera_controller.test.mjs index d1b7c35..9593a36 100644 --- a/crates/spatialrust-web/web/camera_controller.test.mjs +++ b/crates/spatialrust-web/web/camera_controller.test.mjs @@ -181,6 +181,59 @@ function harness(acquire = async () => ({ getTracks: () => [] }), options = {}) return { session, workers, states, errors, results, send }; } + +// Completion must not depend on releasing either native operation. +for (const phase of ["acquire", "presenter"]) for (const action of ["stop", "timeout", "failure", "replacement"]) { + test(`${action} settles startup while ${phase} stays pending`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const gate = deferred(), entered = deferred();let stopped = 0; + const stream = { getTracks: () => [{ stop() { stopped++; } }] }; + const h = harness(() => { + if (phase === "acquire") { entered.resolve();return gate.promise; } + return stream; + }, { onReady: (run) => { if (phase === "presenter" && run.mode === "camera") { entered.resolve();return gate.promise; } } }); + const pending = h.session.start("camera");h.send(h.workers[0], "ready");await entered.promise; + let settled = false, value;pending.then((result) => { settled = true;value = result; }); + t.mock.timers.tick(4999);await Promise.resolve();assert.equal(settled, false); + let replacement; + if (action === "stop") h.session.stop(); + else if (action === "timeout") t.mock.timers.tick(1); + else if (action === "failure") h.send(h.workers[0], "error", { message: "startup failed" }); + else { replacement = h.session.start("sample");h.send(h.workers[1], "ready");assert.equal(await replacement, true); } + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(settled, true, "cancelled start remained pending");assert.equal(value, false); + assert.equal(h.workers[0].terminated, true);assert.equal(h.workers[0].disposed, true); + assert.equal(h.errors.length, ["timeout", "failure"].includes(action) ? 1 : 0); + const current = h.session.run;gate.resolve(phase === "acquire" ? stream : undefined); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(stopped, 1);assert.equal(h.session.run, current);assert.equal(await pending, false); + if (action === "replacement") { assert.equal(current.started, true);assert.equal(h.workers[1].terminated, false); } + else assert.equal(h.session.run, null); + h.session.stop();t.mock.timers.tick(30000); + assert.equal(h.errors.length, ["timeout", "failure"].includes(action) ? 1 : 0); + }); +} + +for (const phase of ["acquire", "presenter"]) test(`late ${phase} rejection is consumed after cancelled startup settles`, async () => { + const gate = deferred(), entered = deferred(); + const h = harness(() => { + if (phase === "acquire") { entered.resolve();return gate.promise; } + return { getTracks: () => [] }; + }, { onReady: (run) => { if (phase === "presenter" && run.mode === "camera") { entered.resolve();return gate.promise; } } }); + const pending = h.session.start("camera");h.send(h.workers[0], "ready");await entered.promise;h.session.stop(); + let settled = false;pending.then(() => { settled = true; });await new Promise((resolve) => setImmediate(resolve));assert.equal(settled, true); + const next = h.session.start("sample");h.send(h.workers[1], "ready");assert.equal(await next, true); + gate.reject(new Error("late native rejection"));await new Promise((resolve) => setImmediate(resolve)); + assert.equal(h.errors.length, 0);assert.equal(h.session.run.started, true);assert.equal(h.workers[1].terminated, false);h.session.stop(); +}); + +test("synchronous worker creation failure settles startup and allows a fresh run", async () => { + const h = harness(), create = h.session.createWorker; + h.session.createWorker = () => { throw new Error("worker creation failed"); }; + assert.equal(await h.session.start("camera"), false);assert.equal(h.session.run, null);assert.equal(h.errors.length, 1); + h.session.createWorker = create;const next = h.session.start("sample");h.send(h.workers[0], "ready");assert.equal(await next, true);h.session.stop(); +}); + test("sample mode never requests a camera", async () => { let requested=0;const h=harness(async () => { requested++; throw new Error("camera"); }); const p=h.session.start("sample",false);h.send(h.workers[0],"ready",{backend:"OFF"});assert.equal(await p,true);assert.equal(requested,0);h.session.stop(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ce9f540..29cb9dc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,6 +46,11 @@ Result timeout reuses cleanup and offers explicit restart without acquisition. Each bitmap creation also owns a separate 5000ms wait deadline. Session Stop aborts that wait; completion, rejection, abort and timeout clear its timer and listener. A bitmap arriving after cancellation is closed without submission. +Start calls also observe a per-run cancellation completion through both worker/ +camera readiness and presenter startup. Stop, failure and the unchanged startup +deadline return false without requiring a native acquisition/play promise to +settle. Late continuations remain observed and retain their epoch/track cleanup; +this does not cancel native browser work or dismiss permission prompts. Native browser bitmap creation itself is not cancellable, so this policy does not claim cancellation or a physical memory bound for unfinished native work. This experimental browser boundary preserves core and vision algorithms; physical diff --git a/docs/MOBILE_CAMERA_DEMO.md b/docs/MOBILE_CAMERA_DEMO.md index dcf21c9..fa75784 100644 --- a/docs/MOBILE_CAMERA_DEMO.md +++ b/docs/MOBILE_CAMERA_DEMO.md @@ -46,6 +46,19 @@ after awaiting `video.play()`. A late completion after Stop or replacement canno restore the old canvas, label, size or backend. Stream/worker cleanup remains owned by the existing session controller. +Each start also owns an application cancellation completion. Stop, startup +failure, replacement and the existing 45-second preparation deadline settle +`CameraSession.start()` with `false`, even when the worker is already ready and +camera permission or `video.play()` remains pending. Successful startup retains +`true`. Both readiness and presenter waits observe the same run cancellation; +settling the start releases its cancellation callback. Late native rejection +stays observed, and a late acquired stream stops every track without changing +the replacement session. + +This settles the application promise; it does not cancel native `getUserMedia` +or `video.play()`, promise that the browser dismisses a permission prompt, or +bound physical memory for unfinished native work across repeated restarts. + During capture the page derives its processing size from current intrinsic video dimensions. It retains a 256-pixel long edge and admits rounded short sides of at least 64 pixels. Unsupported extreme ratios fail with an explanation instead of @@ -454,3 +467,25 @@ runner's temporary directory with the workflow run ID and attempt in its name. The fresh-output invariant remains enforced; previous cached evidence is kept. Application code and the successful local geometry controls are unchanged by this CI path correction. + +## Startup completion evidence (146AQ) + +The [startup receipt](receipts/2026-10-21_camera_startup_completion.json) binds +seven terminal local commands and both reproduced parent-browser failures. A +ready worker plus pending camera acquisition, and a pending native playback +completion, each left the old `start()` promise unsettled after Stop. The final +checks pass **77 JS**, **six asset** and **73 browser** controls. Eleven new unit +cases cover cancellation, the exact startup deadline, failure, replacement, +late success/rejection and synchronous creation failure. Browser checks hold +real fake-camera operations, require `false` before releasing them, and verify +late-track cleanup and replacement sample progress. All existing model, +geometry, capture/result/progress, timing and CSP checks remain. + +The fixed contract is +`C:/Users/rsasa/Workspace/SpatialRust/benchmarks/camera-startup-completion-plan.json`. +Reuse the existing release WASM and **41,087,603** hard-linked vendor bytes, +with no Rust compilation or new SDK/model/sensor acquisition. Preserve **613** +prior camera/renderer files, 493 scientific JSON, 42 frozen native artifacts +and the resolved lockfile. Physical phone/Safari behavior, native operation +cancellation and operational 80% qualification remain open. Exact-source CI, +merge and the matching public Pages checks are verified after the local receipt. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 36f314b..502fe17 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1064,6 +1064,7 @@ evidence, and fail-closed acceptance gates live in | 146AN | Complete implementation: bounded worker result ownership; physical qualification open | Give each camera/sample submission its own 15000ms result deadline, accept only matching session/frame results, clear resources on timeout and permit explicit restart | Both parent missing-result failures reproduced; 56 JS, six asset and 59 real WASM/model browser controls, including slow healthy input, duplicates, obsolete frames/sessions and restart. Reuse WASM/vendor assets and preserve all prior evidence; `docs/MOBILE_CAMERA_DEMO.md`. Physical phone/Safari, blocked-main-thread timing and operational 80% remain open | | 146AO | Complete implementation: bounded bitmap capture wait; native cancellation open | Give each camera/sample bitmap request a 5000ms deadline, abort its wait on session Stop/failure and close eventual late bitmaps without submitting or changing a replacement capture | Both parent capture stalls reproduced; 66 JS, six asset and 69 real WASM/model browser controls. Verify exact timers, listener cleanup, late resolution/rejection, restart and explicit Stop; preserve prior evidence and reuse WASM/vendor assets. Native operation cancellation/memory bounds, physical phone/Safari and operational 80% remain open; `docs/MOBILE_CAMERA_DEMO.md` | | 146AP | Complete implementation: shared native render test runtime; driver cause open | Reuse shared initialization while keeping renderer ownership and four-thread execution; require all GPU cases in three Linux processes without automatic retries | Windows parent five pass, candidate six pass in each of three processes, four GPU completion markers per process, related Clippy/base/format checks; preserve the transient inventory-stop record and all scientific/native/camera evidence. Verify exact-head Linux repeat logs before merge. Production constructors/transfers unchanged; prior SIGSEGV cause, physical Linux hardware and operational 80% remain open; `docs/RENDER_TEST_RUNTIME.md` | +| 146AQ | Complete implementation: cancelled startup completion; native cancellation open | Settle start calls with false on Stop, failure, replacement or preparation timeout through readiness and presenter waits; retain late native cleanup and replacement ownership | Both parent pending-start failures reproduced; 77 JS, six asset and 73 real WASM/model browser controls. Reuse WASM/vendor assets and preserve 613 prior camera/renderer files plus all frozen scientific/native evidence. Native operation cancellation, physical phone/Safari and operational 80% remain open; `docs/MOBILE_CAMERA_DEMO.md` | The operational maturity target is 80% on the explicitly requested real-data, multi-GPU/OS and public-release criteria. Historical engineering estimates in diff --git a/docs/receipts/2026-10-21_camera_startup_completion.json b/docs/receipts/2026-10-21_camera_startup_completion.json new file mode 100644 index 0000000..4bf350c --- /dev/null +++ b/docs/receipts/2026-10-21_camera_startup_completion.json @@ -0,0 +1,254 @@ +{ + "schema": "spatialrust.public-camera-startup-completion-receipt.v1", + "slice": "146AQ", + "date": "2026-10-21", + "recorded_at_utc": "2026-10-21T13:27:59.024842+00:00", + "protocol_commit": "9a3302bf329b409e8c6b84d5d3f24a843416c650", + "tested_source_commit": "401c9d2b5d0268e5fdad62719e22c197411e3c89", + "parent_merge": "fde55df103a72aaea0b557dccfe2557c83d11362", + "plan": { + "path": "C:\\Users\\rsasa\\Workspace\\SpatialRust\\benchmarks\\camera-startup-completion-plan.json", + "bytes": 2889, + "sha256": "de214a843ac7e6e198993f1d371ea877d2123bb0c2a064d49d97e5dd3cd07057" + }, + "preflight": { + "path": "C:\\Users\\rsasa\\Workspace\\SpatialRust\\target\\automation\\camera-startup-completion-preflight.json", + "bytes": 250788, + "sha256": "f2fc40a9f70fb949d7738d36bbb81b00f15aaeca1c63dd7e1c2c0a382157e16a" + }, + "owned_commands": 7, + "all_commands_terminal": true, + "commands": { + "assets": { + "command": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\assets.command.json", + "bytes": 3988, + "sha256": "d44a176ee6fe9f20a7e234fd8c93a3f08df53de26fc091be2b5bc5115d9811c1" + }, + "log": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\assets.log", + "bytes": 109, + "sha256": "59753171660350c6735ee4b12925f50e7f8606c102caf33752e5551608bad819" + }, + "source_head": "401c9d2b5d0268e5fdad62719e22c197411e3c89", + "child_exit_status": 0 + }, + "baseline-start-acquire": { + "command": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\baseline-start-acquire.command.json", + "bytes": 4471, + "sha256": "64c908f1b5ac9b13c407b4d207014969a114878e1901546b31e686a44b1799b3" + }, + "log": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\baseline-start-acquire.log", + "bytes": 560, + "sha256": "73fd3cdde9b82fd781ad957c0525636f2bac3f87f3832d6a5c1375c04811d5c1" + }, + "source_head": "9a3302bf329b409e8c6b84d5d3f24a843416c650", + "child_exit_status": 1 + }, + "baseline-start-play": { + "command": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\baseline-start-play.command.json", + "bytes": 4209, + "sha256": "79641cde95a7abe36f3f8f574a5ba521509054c3a34e88c3cef3bbf8ca36e12a" + }, + "log": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\baseline-start-play.log", + "bytes": 557, + "sha256": "e4fe7d8e892da38588b51aedddc5b6d3c08e01ed3656bfb3a043627acc7ff0f5" + }, + "source_head": "9a3302bf329b409e8c6b84d5d3f24a843416c650", + "child_exit_status": 1 + }, + "browser": { + "command": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser.command.json", + "bytes": 8391, + "sha256": "2c3287687277077e52db87350f57facac8e580dd441ec2cda64dc060abe54f54" + }, + "log": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser.log", + "bytes": 5187, + "sha256": "69fd8ea873fecce2dd8e7b1cb006e5a99e49401cdd877a583b7a39c6929c1528" + }, + "source_head": "401c9d2b5d0268e5fdad62719e22c197411e3c89", + "child_exit_status": 0 + }, + "build-v2": { + "command": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\build-v2.command.json", + "bytes": 4230, + "sha256": "cd18dcb8c40626d3c73da20e5c3001823e5eaaab000dc9b9e75197734099e053" + }, + "log": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\build-v2.log", + "bytes": 142, + "sha256": "430abce230c46f2b9135a6ad8f364150957443c9346500940b5c35240f29fda0" + }, + "source_head": "401c9d2b5d0268e5fdad62719e22c197411e3c89", + "child_exit_status": 0 + }, + "build": { + "command": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\build.command.json", + "bytes": 4227, + "sha256": "079a85a8eb303fc63deeb8fca6a5f1844cc3f55652228a22a4b37d6c47cb3a7f" + }, + "log": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\build.log", + "bytes": 142, + "sha256": "cc41fd8faea15b2528c5322ceb15c9c03ef4c48d5b1fbb0741c405cfe49bacdc" + }, + "source_head": "9a3302bf329b409e8c6b84d5d3f24a843416c650", + "child_exit_status": 0 + }, + "unit": { + "command": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\unit.command.json", + "bytes": 3952, + "sha256": "c368ffa1a4a0355716c5c0ca00ced44446f39aef43bb2a8af20da271f42d1e09" + }, + "log": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\unit.log", + "bytes": 6045, + "sha256": "a73fa6c20bf650b5e8d8cde8bcea4906e8a0d935a618d7faa0605f8934319e40" + }, + "source_head": "401c9d2b5d0268e5fdad62719e22c197411e3c89", + "child_exit_status": 0 + } + }, + "unit_tests": 77, + "asset_controls": 6, + "browser_controls": 73, + "software_graphics_delegate": true, + "browser_report": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\report.json", + "bytes": 11689, + "sha256": "b9c3aa6d0b8d36d753e9811500362f7ce94a18f527a27264b619bdaf8fd432bb" + }, + "screenshots": [ + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\mobile-idle.png", + "bytes": 218678, + "sha256": "95637b49b81d23999c2b29f148f317a79a4c021b31648a9f1e0617df49aeb38c" + }, + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\mobile-tracking.png", + "bytes": 283258, + "sha256": "4345410b398f4073a3c59f0737eb34b6be3f10b968c016d8a827b9abb29435c1" + }, + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\mobile-ai.png", + "bytes": 284516, + "sha256": "fafc3e2607ddd834e08419b4f28b40e5c21bdbc0ad1d205211986f75368eb6ac" + }, + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\desktop-idle.png", + "bytes": 437103, + "sha256": "bb4e7619a7b023d0f18c9324a7d3db7d42ae7e51274401ddc37d4bd9a99b9ecb" + }, + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\mobile-portrait-camera-256.png", + "bytes": 186777, + "sha256": "c0fb8baecb68590ad30a743e83461e6e635e6a49ef9c999fc5e0ea726c9d9ae7" + }, + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\mobile-light-sample.png", + "bytes": 272890, + "sha256": "0fab2a4bc80be96774b5994eceeed1293bb9f0c1ff6110c2efc644284cdbe98f" + }, + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\mobile-portrait-camera-192.png", + "bytes": 187704, + "sha256": "0990d8149276502c559b35d571ca587a546eaeeb37f4bf0c167933b4ab7efa87" + }, + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\mobile-capture-rate.png", + "bytes": 263380, + "sha256": "b0797beb73dc2802c46fcf25ac17981e25bb6bf0555558da31c9b501eaf6a494" + }, + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\mobile-source-recovery.png", + "bytes": 272906, + "sha256": "514819730092d224182c1c54d6e165307aee91d31ac1e6389810f4faba928c69" + }, + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\mobile-progress-timeout.png", + "bytes": 223512, + "sha256": "407f9fb64a39dead84be64b1b9a775af2abb7673109358c3f38c00e5de3cb0d2" + }, + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\mobile-progress-restart.png", + "bytes": 174280, + "sha256": "b1733679d4f1047614770d4d07cacf19bb68daed42e609aeff08869e8bda858f" + }, + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\mobile-result-timeout.png", + "bytes": 223214, + "sha256": "981db1fb413285c2533c220e7085bebff51c52f0c22087672feb191ea07027ff" + }, + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\mobile-result-restart.png", + "bytes": 175325, + "sha256": "acfb704b742576c2e11c53089524dd91d1573bd7def131ff9a9598cd4dea7918" + }, + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\mobile-capture-timeout.png", + "bytes": 223848, + "sha256": "5273760ba0ae3cba6823fd84cdb5bf0fb91b79a7809b95e5aee417482670cf14" + }, + { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\browser-artifacts\\mobile-capture-restart.png", + "bytes": 175333, + "sha256": "407fa803a62bb6e5c3b5f989c8a431a481d737e46078d2de326b488e908a344c" + } + ], + "site_build": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\site\\camera\\build.json", + "bytes": 4191, + "sha256": "6b94076adc77c5bab2bc590e7201106e713ec8b947f057aec356f1182126dafa" + }, + "unchanged_wasm": { + "path": "E:\\SpatialRustBuild\\maturity-80-target\\wasm32-unknown-unknown\\release\\spatialrust_web.wasm", + "bytes": 1030227, + "sha256": "660461f1760aab0a96b8bd72baa265196107ae4ece8cf3e17dfa929a30c1dc6e" + }, + "unchanged_lock": { + "path": "C:\\Users\\rsasa\\Workspace\\SpatialRust\\Cargo.lock", + "bytes": 118086, + "sha256": "e9d0a244c69b4ea193c7ebcd61e404de8071a53b71ff269c2c8dbe0f506484cc" + }, + "vendor_hardlink_bytes": 41087603, + "sdk_model_acquisition_bytes": 0, + "storage": { + "owned_metadata_bytes": 45781968, + "cache_bytes": 13386648799, + "positive_cache_growth_bytes": 0, + "combined_bytes": 45781968, + "free_bytes": { + "C": 69897601024, + "E": 876002787328 + } + }, + "non_shared_owned_file_bytes": 4694365, + "preserved_scientific_json": 493, + "preserved_frozen_native": 42, + "preserved_previous_camera_files": 613, + "forbidden_adapter_matches": 0, + "physical_phone_tested": false, + "ios_safari_tested": false, + "new_sensor_payload_downloads": 0, + "real_user_camera_reads": 0, + "operational_80_percent_proven": false, + "release_verification": "Exact-head CI, merge and live Pages checks are recorded after this source-bound receipt.", + "parent_regressions_reproduced": [ + "pending acquisition retained the cancelled start promise", + "pending video.play retained the cancelled start promise" + ], + "baseline_site_build": { + "path": "E:\\SpatialRustBuild\\camera-startup-completion-v1\\baseline-build.json", + "bytes": 4191, + "sha256": "ffe9c253dced56a46af5369246804745cffaf140ed47ace0ae4d2ddbc61f74c2" + } +} diff --git a/docs/site/camera/session.mjs b/docs/site/camera/session.mjs index 0a8bca3..65664a7 100644 --- a/docs/site/camera/session.mjs +++ b/docs/site/camera/session.mjs @@ -14,6 +14,8 @@ export class CameraSession { this.stop(); const run = { token: ++this.epoch, mode, ai, worker: null, stream: null, started: false, inFlight: null, frameId: 0, captureAbort: new AbortController() }; this.run = run; + // Release the application wait even if native camera/play work never settles. + const cancelled = new Promise((resolve) => { run.cancelStart = resolve; }); this.onState("loading"); try { run.worker = this.createWorker(); @@ -52,9 +54,9 @@ export class CameraSession { if (!this.checkCamera(run)) return null; return stream; }) : Promise.resolve(null); - await Promise.all([ready, camera]); + await Promise.race([Promise.all([ready, camera]), cancelled]); if (this.run !== run) return false; - await this.onReady(run); + await Promise.race([this.onReady(run), cancelled]); if (this.run !== run) return false; if (mode === "camera" && !this.checkCamera(run)) return false; clearTimeout(run.timer); @@ -64,7 +66,7 @@ export class CameraSession { } catch (error) { if (this.run === run) this.fail(run, error); return false; - } + } finally { run.cancelStart = null; } } checkCamera(run) { @@ -111,6 +113,7 @@ export class CameraSession { this.run = null; ++this.epoch; if (run) { + run.cancelStart?.(); clearTimeout(run.timer); clearTimeout(run.inFlight?.timer); run.captureAbort.abort(); diff --git a/scripts/test_camera_demo.cjs b/scripts/test_camera_demo.cjs index fdf2a88..f5ad26e 100644 --- a/scripts/test_camera_demo.cjs +++ b/scripts/test_camera_demo.cjs @@ -8,7 +8,7 @@ const { chromium } = require("playwright"); function argument(name) { const i = process.argv.indexOf(name); if (i < 0 || !process.argv[i + 1]) throw new Error(`missing ${name}`); return path.resolve(process.argv[i + 1]); } const site = argument("--site"), output = argument("--output"); const caseIndex = process.argv.indexOf("--regression-case"), regressionCase = caseIndex < 0 ? null : process.argv[caseIndex + 1]; -if (regressionCase && !["startup", "rotation", "history", "cadence", "rate", "source-ended", "source-inactive", "source-silent", "progress-idle", "progress-busy", "result-camera", "result-sample", "capture-camera", "capture-sample"].includes(regressionCase)) throw new Error("unknown regression case"); +if (regressionCase && !["startup", "rotation", "history", "cadence", "rate", "source-ended", "source-inactive", "source-silent", "progress-idle", "progress-busy", "result-camera", "result-sample", "capture-camera", "capture-sample", "start-acquire", "start-play"].includes(regressionCase)) throw new Error("unknown regression case"); if (fs.existsSync(output)) throw new Error("fresh browser evidence directory required"); fs.mkdirSync(output, { recursive: true }); const report = { schema: "spatialrust.camera-browser.v1", syntheticCaptureOnly: true, physicalPhoneTested: false, checks: [], screenshots: [], externalResponses: [], nonGetRequests: [], consoleErrors: [], failedRequests: [], httpErrors: [], workerErrors: [], cspViolations: [] }; @@ -46,6 +46,7 @@ async function instrument(context) { await context.addInitScript(() => { window.__workerErrors = []; window.__workerCount = 0; + window.__workerReadyCount = 0; window.__terminatedWorkers = 0; window.__cameraReports = []; window.__holdNextResult = false; @@ -58,6 +59,7 @@ async function instrument(context) { window.Worker = class extends OriginalWorker { constructor(...args) { super(...args); window.__workerCount++; this.addEventListener("message", (event) => { + if (event.data.kind === "ready") window.__workerReadyCount++; if (event.data.kind !== "result") return; const r = event.data.report; window.__cameraReports.push({ width: r.width, height: r.height, status: r.status, motion: r.background_flow_px, maskBytes: event.data.mask.byteLength, timestampMs: r.timestamp_ms }); @@ -87,6 +89,52 @@ async function instrument(context) { }); } +async function cancelledStartup(context, origin, phase) { + const page = await context.newPage(); activePage = page; + await page.goto(origin + "/camera/");await page.locator("#ai").uncheck(); + await page.evaluate(async (phase) => { + const { CameraSession } = await import("./session.mjs"); + const start = CameraSession.prototype.start; + window.__startCompletions = []; + CameraSession.prototype.start = function (...args) { + const result = { settled: false };window.__startCompletions.push(result); + const pending = start.apply(this, args); + pending.then((value) => { result.settled = true;result.value = value; }, (error) => { result.settled = true;result.error = String(error); }); + return pending; + }; + const hold = async () => { await new Promise((resolve) => { window.__releaseStartup = resolve; });window.__startupReleased = true; }; + if (phase === "acquire") { + const acquire = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); + navigator.mediaDevices.getUserMedia = async (constraints) => { const stream = await acquire(constraints);await hold();return stream; }; + } else { + const play = HTMLMediaElement.prototype.play; + HTMLMediaElement.prototype.play = async function () { await play.call(this);await hold(); }; + } + }, phase); + await page.locator("#start").click(); + await page.waitForFunction(() => typeof window.__releaseStartup === "function" && window.__workerReadyCount === 1); + assert.equal(await page.evaluate(() => window.__startCompletions[0].settled), false); + await page.locator("#stop").click(); + await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 0))); + const completion = await page.evaluate(() => window.__startCompletions[0]); + assert.equal(completion.settled, true, "cancelled start remained pending");assert.equal(completion.value, false);assert.equal(completion.error, undefined); + assert.equal(await page.evaluate(() => document.documentElement.dataset.sessionState), "idle"); + assert.equal(await page.evaluate(() => document.querySelector("video").srcObject), null); + assert.equal(await page.evaluate(() => window.__terminatedWorkers), 1); + check(`cancelled-start-settles-before-native-${phase}-release`); + const before = await frameCount(page);await page.locator("#sample").click();await waitFrames(page, before + 1); + assert.deepEqual(await page.evaluate(() => window.__startCompletions[1]), { settled: true, value: true }); + const replacementFrames = await frameCount(page); + await page.evaluate(() => window.__releaseStartup()); + await page.waitForFunction(() => window.__startupReleased && window.__cameraStreams.every((stream) => stream.getTracks().every((track) => track.readyState === "ended"))); + await waitFrames(page, replacementFrames + 1); + assert.ok((await page.locator("#mode-label").textContent()).startsWith("SAMPLE")); + assert.equal(await page.evaluate(() => window.__cameraCalls.length), 1);assert.equal(await page.evaluate(() => window.__workerCount), 2); + assert.equal(await page.evaluate(() => window.__terminatedWorkers), 1); + check(`late-native-${phase}-completion-preserves-replacement-and-releases-tracks`); + await page.locator("#stop").click();await stopped(page);await page.close(); +} + async function staleStartup(context, origin) { const page = await context.newPage(); activePage = page; await page.addInitScript(() => { @@ -493,7 +541,8 @@ async function intrinsicRotation(context, origin, longEdge = 256, historyLimit = const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1, isMobile: true, hasTouch: true }); await instrument(context); if (regressionCase) { - if (regressionCase === "startup") await staleStartup(context, origin); + if (regressionCase.startsWith("start-")) await cancelledStartup(context, origin, regressionCase.slice(6)); + else if (regressionCase === "startup") await staleStartup(context, origin); else if (regressionCase === "rotation") await intrinsicRotation(context, origin); else if (regressionCase === "history") await intrinsicRotation(context, origin, 192, 8); else if (regressionCase === "cadence") await captureCadence(context, origin); @@ -558,6 +607,7 @@ async function intrinsicRotation(context, origin, longEdge = 256, historyLimit = assert.ok((await denied.locator("#status").textContent()).includes("許可されていません"));check("camera-denial-shows-recovery"); await denied.locator("#sample").click();await waitFrames(denied);check("sample-works-after-denial");await denied.locator("#stop").click(); await deniedContext.close(); + for (const phase of ["acquire", "play"]) await cancelledStartup(context, origin, phase); await staleStartup(context, origin); await intrinsicRotation(context, origin); await lightPreset(context, origin);