also looking at this
test: retain owned browser startup process evidence
#100
21 files
+1516
-155
| @@ -30,6 +30,22 @@ jobs: | ||
| 30 | 30 | - run: bun run parity:check |
| 31 | 31 | - run: python3 -m py_compile resources/functions/drone/camera/imx500_worker.py |
| 32 | 32 | - run: python3 -m unittest discover -s tests/hardware |
| 33 | - name: Qualify startup observations with actual exited and live native processes | |
| 34 | env: | |
| 35 | BROWSER_LAUNCH_EVIDENCE: ${{ runner.temp }}/browser-launch-exited | |
| 36 | BROWSER_LAUNCH_LIVE_EVIDENCE: ${{ runner.temp }}/browser-launch-live | |
| 37 | run: bun test tests/unit/public-safety-browser-launch.test.ts tests/unit/public-safety-browser-launch-evidence.test.ts tests/unit/public-safety-browser-startup-process.test.ts | |
| 38 | - uses: actions/upload-artifact@v4 | |
| 39 | if: always() | |
| 40 | with: | |
| 41 | name: browser-launch-native-evidence | |
| 42 | if-no-files-found: ignore | |
| 43 | retention-days: 7 | |
| 44 | path: | | |
| 45 | ${{ runner.temp }}/browser-launch-exited/browser-launch.json | |
| 46 | ${{ runner.temp }}/browser-launch-exited/chrome-startup-stderr.log | |
| 47 | ${{ runner.temp }}/browser-launch-live/browser-launch.json | |
| 48 | ${{ runner.temp }}/browser-launch-live/chrome-startup-stderr.log | |
| 33 | 49 | - name: Qualify cold build setup across the former hook deadline |
| 34 | 50 | run: bun scripts/qualify-runtime-cold-build.ts "$RUNNER_TEMP/runtime-cold-build" |
| 35 | 51 | - uses: actions/upload-artifact@v4 |
| @@ -51,6 +51,7 @@ const config: BunPressOptions = { | ||
| 51 | 51 | { text: 'Raspberry Pi setup', link: '/raspberry-pi' }, |
| 52 | 52 | { text: 'Library API', link: '/api' }, |
| 53 | 53 | { text: 'Validation evidence', link: '/validation' }, |
| 54 | { text: 'Browser qualification evidence', link: '/browser-qualification' }, | |
| 54 | 55 | { text: 'Flight-development gates', link: '/flight-development' }, |
| 55 | 56 | ], |
| 56 | 57 | }, |
| @@ -0,0 +1,53 @@ | ||
| 1 | # ADR 0036: One point-load intake for a receiving vehicle | |
| 2 | ||
| 3 | Status: accepted for software simulation. | |
| 4 | ||
| 5 | ## Context | |
| 6 | ||
| 7 | Cargo fleet handover needs a physical parcel to leave one carrier, undergo finite modeled | |
| 8 | station handling and enter another carrier's actual dynamics. A custody-label change or | |
| 9 | an initially loaded second vehicle would bypass that mass transfer. The rigid body already | |
| 10 | supports an attached point load and one zero-ejection separation, but had no empty joint. | |
| 11 | ||
| 12 | ## Decision | |
| 13 | ||
| 14 | Add `createReceivingRigidBody` over the same integrator, with a captured `expectedPointLoad`. | |
| 15 | Its lifecycle is `awaiting -> attached -> separated`. The initial body has base mass/inertia; | |
| 16 | the planned load is not an actual parcel. A successful intake consumes this one opportunity. | |
| 17 | The old `createRigidBody` constructor still starts attached and exposes no intake method or | |
| 18 | awaiting phase. Its default/no-load numerical paths and result projections remain unchanged. | |
| 19 | ||
| 20 | The receiving joint reports its position and velocity. The incoming parcel record contains | |
| 21 | its own id, mass, position and velocity. Match id/mass exactly and require exact coincident | |
| 22 | binary64 position components at invocation. This is an ideal programmatic contact condition, | |
| 23 | not a sensor tolerance or claim of physical positioning precision. Captured records and | |
| 24 | finite tuple elements reject accessors. Valid refusal or malformed input does not mutate | |
| 25 | the body or consume its intake opportunity. | |
| 26 | ||
| 27 | Let base mass be `m`, point mass `p`, body-forward joint vector `r`, and combined mass `M=m+p`. | |
| 28 | The new body-frame CG is `c=p*r/M`. Combined center velocity is `(m*vBody+p*vParcel)/M`. | |
| 29 | About that center, body-frame angular momentum is base `I*omega` plus | |
| 30 | `m*p/M * cross(r, inverseRotate(vParcel-vBody))`. Dividing by the combined principal inertia | |
| 31 | gives the new angular rate. Reconstruct datum velocity from center velocity and `omega cross c`. | |
| 32 | The joint is on the body x axis, so the existing diagonal parallel-axis inertia is sufficient. | |
| 33 | ||
| 34 | The base and incoming point jointly determine conserved linear/angular momentum. Structural | |
| 35 | position, quaternion and actual motor state remain unchanged. Kinetic energy may decrease; | |
| 36 | retain before/after energy and raw dissipation. Reject non-finite candidates or a gain beyond | |
| 37 | `64 * Number.EPSILON * max(1, kineticBeforeJ, kineticAfterJ)` before committing state. Keep the | |
| 38 | raw represented loss, including tolerated tiny negatives. NED potential is `-mass*g*down`. | |
| 39 | The event reports both potential values separately. | |
| 40 | ||
| 41 | ## Consequences | |
| 42 | ||
| 43 | The primitive models an instantaneous inelastic impulse, not contact duration, force peaks, | |
| 44 | parcel spin/deformation, latch actuation or restitution. The retained acceleration is the last | |
| 45 | continuous integration sample, not an invented impact acceleration. A later physics step uses | |
| 46 | the new mass/CG/inertia without resetting the controller, estimator, pose or motor lag. | |
| 47 | ||
| 48 | World/policy code must own and consume the single parcel, admit the physical operation, model | |
| 49 | finite transport to the joint and capture mechanism/vehicle acknowledgement with current | |
| 50 | authority and source epochs. This primitive does not globally prevent a caller from supplying | |
| 51 | the same record to two independent worlds. A joint pose is not a source parcel or proof of custody. | |
| 52 | Station handling and the actual two-vehicle protocol remain unimplemented at this checkpoint. | |
| 53 | No physical output, authenticated identity, battery endurance or fleet completion is claimed. | |
| @@ -57,6 +57,11 @@ The [cargo guide](cargo-delivery.md) defines the flat synthetic origin, finite l | ||
| 57 | 57 | one-release and receipt semantics, phase-bound faults, native CLI exit codes and remaining |
| 58 | 58 | fleet/physical/BVLOS work. Budgets are declared power ceilings, not electrical measurements. |
| 59 | 59 | |
| 60 | `createReceivingRigidBody({ expectedPointLoad, ...bodyOptions })` in `controls/physics.ts` | |
| 61 | provides the separate [point-load intake prerequisite](cargo-delivery.md#point-load-intake-prerequisite). | |
| 62 | It integrates real modeled mass/CG changes after one coincident inelastic point capture. | |
| 63 | The caller still owns the parcel, finite station handling and captured fleet custody protocol. | |
| 64 | ||
| 60 | 65 | ## Target sources |
| 61 | 66 | |
| 62 | 67 | `createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes |
| @@ -0,0 +1,80 @@ | ||
| 1 | # Browser qualification observations | |
| 2 | ||
| 3 | The rendered workspace and execution checks launch a fresh native Chrome process in the | |
| 4 | existing fixture-owned process group. They retain the original launch arguments and the | |
| 5 | 400-read, 50 ms DevToolsActivePort discovery guard. No retry or browser flag bypass is added. | |
| 6 | The full page/route/Worker scenarios must still pass. A successful native launcher fixture | |
| 7 | does not qualify those scenarios or explain an intermittent Chrome startup failure. | |
| 8 | ||
| 9 | ## Startup evidence | |
| 10 | ||
| 11 | `browser-launch.json` retains its exit/port counters and adds a `startup` observation. The | |
| 12 | observer requests samples near launch, 1, 5, 10 and 15 seconds, then at discovery end. Samples | |
| 13 | run asynchronously during discovery, with at most one actual task in flight and a 500 ms | |
| 14 | cooperative cancellation budget. Cancellation stops further process/resource reads after | |
| 15 | outstanding native calls settle. It never releases ownership of an unfinished task. A timeout | |
| 16 | disables later Linux samples and hashing, closes captured descriptors after settlement and | |
| 17 | emits a bounded pending-task notice. Finalization waits for that task; it cannot allow a page | |
| 18 | visit behind it. An uninterruptible filesystem call can hold finalization until the existing | |
| 19 | outer fixture guard fails and terminates its group. This is not a hard OS syscall deadline. | |
| 20 | Actual start/completion epochs are retained; labels are requested milestones, not | |
| 21 | promises that an event occurred at that exact time. A pre-page cleanup sample follows the | |
| 22 | existing root termination/2 second wait. No process or resource sample is requested after a | |
| 23 | page visit. Post-page stderr remains private. | |
| 24 | ||
| 25 | Each sample includes a native root PID existence probe. Linux adds selected `/proc` state: | |
| 26 | parent/group/session, start/user/system ticks, thread count, RSS and kernel wait channel. | |
| 27 | Names, argv, environment, stacks and memory contents are not exported. Main-task child | |
| 28 | traversal and previously observed descendants are capped at 64 processes per sample; root | |
| 29 | thread wait details are capped at 32. Start ticks flag observed PID reuse. Fork/exit/reparenting, | |
| 30 | permissions and truncation can make this incomplete. It is not proof of whole-group cleanup. | |
| 31 | The outer fixture owner separately terminates its isolated group before disposable removal. | |
| 32 | ||
| 33 | Expected discovery-file metadata, observer cgroup-v2 counters and system CPU/memory/IO pressure | |
| 34 | help distinguish missing files, a live process and resource contention. Cgroup counters may | |
| 35 | cover other runner work; system pressure covers other host workloads. Neither proves Chrome | |
| 36 | causality. Unsupported platforms, missing files, permission errors and observation timeouts | |
| 37 | are explicit outcomes. Numeric kernel fields are selected and bounded; raw status, cgroup | |
| 38 | membership, thread names and arbitrary text are not retained. | |
| 39 | ||
| 40 | Startup samples pin at most four distinct actual Linux `/proc/PID/exe` file descriptors | |
| 41 | without reading executable bytes. Only after discovery ends does it hash them under one | |
| 42 | 2 second cooperative hash budget, with a 512 MiB per-executable limit. Device/inode identity uses exact | |
| 43 | integer strings. Byte count and file timestamps are checked before retaining a digest; all | |
| 44 | pinned descriptors are then closed, with close errors reported. This identifies an observed | |
| 45 | backing executable rather than assuming the configured launcher is Chrome. It neither warms | |
| 46 | the binary before the attempt nor authenticates the code or its entire loaded graph. | |
| 47 | ||
| 48 | The browser version is retained only from a successful CDP version response and a closed | |
| 49 | Chrome version format. If discovery fails, it stays null. Do not infer the failed binary's | |
| 50 | version from another run without matching actual identity evidence. Opening descriptors, | |
| 51 | reading process files and hashing are observation work and can affect scheduling; the | |
| 52 | observed times and unavailable/timeout outcomes must accompany comparisons. | |
| 53 | ||
| 54 | ## Native qualification | |
| 55 | ||
| 56 | ```sh | |
| 57 | bun test tests/unit/public-safety-browser-launch.test.ts tests/unit/public-safety-browser-launch-evidence.test.ts tests/unit/public-safety-browser-startup-process.test.ts | |
| 58 | ``` | |
| 59 | ||
| 60 | One actual native fixture exits 23 before discovery. Another owns a live child until the | |
| 61 | original cleanup request. The checks preserve all 400 failed probes, inspect actual PIDs, | |
| 62 | verify parent/child shutdown and ensure argv/environment sentinels do not enter the report. | |
| 63 | On Linux the live fixture also checks actual descendant/start/RSS data and the retained | |
| 64 | executable hash against the independently read runtime binary. Optional `BROWSER_LAUNCH_EVIDENCE` | |
| 65 | and `BROWSER_LAUNCH_LIVE_EVIDENCE` retain reports in new directories. CI publishes only the | |
| 66 | allowlisted launch JSON and startup stderr, not scripts, credentials or private fixture files. | |
| 67 | A held OS-adapter promise separately verifies that cancellation cannot overlap another sample, | |
| 68 | finalize early or begin hashing while an operation remains unfinished. It does not qualify | |
| 69 | Linux field acquisition; the actual native Linux fixture provides that evidence. | |
| 70 | A Linux cancellation case also opens real executable descriptors and checks that they are | |
| 71 | closed without a digest once the held native sample settles. | |
| 72 | ||
| 73 | Linux-only observations require Linux evidence; a macOS pass does not establish them. | |
| 74 | [Issue #72](https://github.com/stacksjs/drone/issues/72) retains the original failed attempts and | |
| 75 | passing controls. Its root cause and correction remain open until reproduced and verified. | |
| 76 | These observations change evidence, not aircraft control, physical outputs or flight readiness. | |
| 77 | ||
| 78 | Field semantics follow the kernel's [proc reference](https://docs.kernel.org/filesystems/proc.html), | |
| 79 | [cgroup-v2 reference](https://docs.kernel.org/admin-guide/cgroup-v2.html) and | |
| 80 | [pressure reference](https://docs.kernel.org/accounting/psi.html). | |
| @@ -149,8 +149,39 @@ temperature limit, voltage-sag prediction or Pi performance result. Changing mas | ||
| 149 | 149 | the actual modeled dynamics; it does not identify an electrical load law. A successful |
| 150 | 150 | budget is conditional on that ceiling and is not measured endurance qualification. |
| 151 | 151 | |
| 152 | ## Point-load intake prerequisite | |
| 153 | ||
| 154 | `createReceivingRigidBody` in `controls/physics.ts` adds an empty declared joint for a | |
| 155 | future parcel. Supply `expectedPointLoad: { id, massKg, forwardM }`; the mass and offset | |
| 156 | bounds match the carried-load model. Its `awaiting` phase has the base body's actual | |
| 157 | mass/inertia. `intakePoint()` returns only joint position and velocity, not a parcel or | |
| 158 | custody evidence. | |
| 159 | ||
| 160 | `attachParcel({ id, massKg, positionM, velocityMps })` accepts an existing modeled point | |
| 161 | parcel only at the current joint position, equal component by component. Identity and | |
| 162 | mass must match the declaration. The caller must move its one physical-world parcel | |
| 163 | there; this primitive does not transport, copy or globally own it. Malformed own-data | |
| 164 | records throw without changing the body. Valid foreign or noncoincident parcels are | |
| 165 | refused without consuming the joint. | |
| 166 | ||
| 167 | Capture conserves total linear/angular momentum and applies a perfectly inelastic point | |
| 168 | joint. Structural position, orientation and actual motors remain continuous; a non-co-moving | |
| 169 | parcel can impart a genuine velocity/angular-rate impulse. Captured mass changes the actual | |
| 170 | subsequent dynamics, CG and inertia. The event retains detached before/after state, incoming | |
| 171 | parcel and raw kinetic/potential energy accounting. Dissipation is mechanical energy, | |
| 172 | not battery Wh or an identified mechanism efficiency. Tiny binary64 negative loss within | |
| 173 | the reported numerical tolerance is retained rather than clamped. | |
| 174 | ||
| 175 | The joint accepts once, can later separate through the existing zero-ejection model and | |
| 176 | cannot refill after separation. The legacy `createRigidBody` carried-load interface keeps | |
| 177 | its original phase/method contract. [ADR 0036](adr/0036-point-load-intake.md) gives the model | |
| 178 | and limits. This primitive supplies no finite-duration contact forces or impact IMU sample, | |
| 179 | release/intake acknowledgement, station handling, authorization or fleet custody handover. | |
| 180 | Those integrations remain open under issue #25. | |
| 181 | ||
| 152 | 182 | ## BVLOS operations evidence |
| 153 | 183 | |
| 184 | ||
| 154 | 185 | Country and intended operation must be established separately from library support. |
| 155 | 186 | Sources below were checked October 24, 2026; inspect their current requirements for the |
| 156 | 187 | actual operation. This software issues no authorization. |
| @@ -23,3 +23,6 @@ The owned runtime follows the dependency-linked [roadmap](roadmap/README.md). Th | ||
| 23 | 23 | support comparison and development while native board controls are implemented and validated. |
| 24 | 24 | |
| 25 | 25 | Until those milestones are complete, the code here should be used for simulation and bench development. |
| 26 | ||
| 27 | Rendered application checks have a separate [browser qualification contract](browser-qualification.md). | |
| 28 | A browser-startup failure does not establish a vehicle, estimator or physical flight failure. | |
| @@ -33,6 +33,67 @@ arming/engagement. Independent board leases expire to each declared neutral, inc | ||
| 33 | 33 | and air spring/wheel brake channels. Neutral does not imply a vehicle has physically stopped. |
| 34 | 34 | Host loss leaves a stale host mission snapshot; the independently ticking board still expires output. |
| 35 | 35 | |
| 36 | ## Shared-clock modeled sessions | |
| 37 | ||
| 38 | `createVehicleSimulationSession(profile, options)` in `vehicles/runtime.ts` retains the same | |
| 39 | owned loop behind `step(atMs)`, `result()` and `close()`. Start at zero and advance exactly | |
| 40 | 4 ms per call. Two sessions can therefore be advanced at each shared epoch before the | |
| 41 | coordinator evaluates their observations. Rejected duplicate, skipped or invalid epochs | |
| 42 | do not advance the session; a caller can supply the expected epoch afterward. | |
| 43 | ||
| 44 | An optional `scope: { bootId, sessionId }` captures two nonzero unsigned 64-bit bigints | |
| 45 | before session resources open. The boot identity is encoded in the actual synthetic IMU/GNSS | |
| 46 | packets, sensor clock anchor and estimator reference. Both identifiers also scope the | |
| 47 | authority supervisor and independent board. They are correlations, not authentication. | |
| 48 | Omitting scope preserves the original boot 55/session 123 reports. | |
| 49 | ||
| 50 | ```ts | |
| 51 | const a = createVehicleSimulationSession(profileA, { | |
| 52 | seconds: 120, scope: { bootId: 101n, sessionId: 201n }, | |
| 53 | }) | |
| 54 | const b = createVehicleSimulationSession(profileB, { | |
| 55 | seconds: 120, scope: { bootId: 102n, sessionId: 202n }, | |
| 56 | }) | |
| 57 | try { | |
| 58 | // Advance each live session once at every epoch; stop stepping one after terminal. | |
| 59 | for (let atMs = 0, doneA = false, doneB = false; !doneA || !doneB; atMs += 4) { | |
| 60 | if (!doneA) doneA = a.step(atMs).terminal | |
| 61 | if (!doneB) doneB = b.step(atMs).terminal | |
| 62 | } | |
| 63 | console.log(a.result().status, b.result().status) | |
| 64 | } | |
| 65 | finally { a.close(); b.close() } | |
| 66 | ``` | |
| 67 | ||
| 68 | A tick returns detached mission, authority and board snapshots, the estimator reference, | |
| 69 | its actual epoch, phase, terminal flag and last completed host policy epoch. Policy still | |
| 70 | runs every 20 ms after alignment. During host loss or an opt-in independent settling tail, | |
| 71 | `policyAtMs` does not advance. Tail epochs run only board/plant work. Host authority and | |
| 72 | mission snapshots can remain stale; the board snapshot reflects its actual independent lease. | |
| 73 | ||
| 74 | `result()` refuses an active session and returns a detached terminal report. Explicit | |
| 75 | `close()` is idempotent: it closes owned payload state once, immediately revokes the modeled | |
| 76 | board and reports a blocked `simulation-session-closed` outcome. It does not invent mission | |
| 77 | completion, execute a recovery or settle the physical plant. A step failure also closes | |
| 78 | ownership and revokes output before throwing; a simultaneous cleanup failure is retained | |
| 79 | in an aggregate error. Naturally terminated sessions keep their existing terminal result. | |
| 80 | Closing from an adapter callback stops the in-flight step before another adapter or output | |
| 81 | acceptance runs. Recursive stepping is refused. An interrupted policy epoch is not reported | |
| 82 | as completed, and terminal sessions report `hostAvailable: false`. | |
| 83 | All adapter data results, including faults, plant state, navigation, measurements, payload | |
| 84 | acknowledgements and controller output, are captured inside that ownership check before use. | |
| 85 | A link callback that closes the session and then fails cannot hide the failure as a disconnected | |
| 86 | observation. Void observation/request callbacks keep their ignored-return contract. | |
| 87 | The initial plant snapshot and adapter method references are captured before the session | |
| 88 | is exposed. Replacing a method property afterward does not replace the retained adapter; | |
| 89 | modeled world changes belong in the adapter's own captured state. | |
| 90 | `runVehicleSimulation` now drains this same interface to natural termination. | |
| 91 | ||
| 92 | This enables concurrent modeled vehicle execution. It does not yet implement fleet custody | |
| 93 | transfer, collision avoidance, a network scheduler, distributed clocks or physical multi-vehicle | |
| 94 | control. Callers must avoid reusing identities. [Cargo issue #25](cargo-delivery.md#remaining-issue-scope) | |
| 95 | remains open for the actual handover policy and evidence. | |
| 96 | ||
| 36 | 97 | ## Payload and survey evidence |
| 37 | 98 | |
| 38 | 99 | Payload requests bind event, task, operation, container, reference, position and an expiry window. |
| @@ -8,53 +8,79 @@ export { rotateBodyToNed, rotateNedToBody } from '../estimation/frames' | ||
| 8 | 8 | interface RigidBodyInput { positionM?: SensorVector, motorsN?: Motors, groundDownM?: number, groundGradient?: [number, number] } |
| 9 | 9 | interface CarriedPointLoad { id: string, massKg: number, forwardM: number } |
| 10 | 10 | interface BodySnapshot { positionM: SensorVector, velocityMps: SensorVector, angularRateRadS: SensorVector, accelerationMps2: SensorVector, quaternionWxyz: Quaternion, motorsN: Motors, groundContact: boolean } |
| 11 | interface LoadProperties { id: string, phase: 'attached' | 'separated', pointMassKg: number, forwardM: number, totalMassKg: number, cgBodyM: SensorVector, inertiaDiagonalKgM2: SensorVector } | |
| 12 | interface LoadSnapshot { properties: LoadProperties, centerPositionM: SensorVector, centerVelocityMps: SensorVector } | |
| 13 | interface SeparationSide { properties: LoadProperties, bodyPositionM: SensorVector, bodyVelocityMps: SensorVector, quaternionWxyz: Quaternion, angularRateRadS: SensorVector, actualMotorsN: Motors, centerPositionM: SensorVector, centerVelocityMps: SensorVector } | |
| 11 | type PointLoadPhase = 'awaiting' | 'attached' | 'separated' | |
| 12 | interface LoadProperties<P extends PointLoadPhase = 'attached' | 'separated'> { id: string, phase: P, pointMassKg: number, forwardM: number, totalMassKg: number, cgBodyM: SensorVector, inertiaDiagonalKgM2: SensorVector } | |
| 13 | interface LoadSnapshot<P extends PointLoadPhase = 'attached' | 'separated'> { properties: LoadProperties<P>, centerPositionM: SensorVector, centerVelocityMps: SensorVector } | |
| 14 | interface SeparationSide<P extends PointLoadPhase = 'attached' | 'separated'> { properties: LoadProperties<P>, bodyPositionM: SensorVector, bodyVelocityMps: SensorVector, quaternionWxyz: Quaternion, angularRateRadS: SensorVector, actualMotorsN: Motors, centerPositionM: SensorVector, centerVelocityMps: SensorVector } | |
| 14 | 15 | type LoadSeparation = { outcome: 'separated', reason: 'prescribed-zero-ejection', before: SeparationSide, after: SeparationSide, parcel: { massKg: number, positionM: SensorVector, velocityMps: SensorVector } } | { outcome: 'refused', reason: 'already-separated' } |
| 15 | 16 | interface RigidBody { snapshot(): BodySnapshot, step(command: Motors, dtS: number, disturbance?: { windMps?: SensorVector, forceN?: SensorVector, torqueNm?: SensorVector }): BodySnapshot } |
| 16 | 17 | interface LoadedRigidBody extends RigidBody { loadSnapshot(): LoadSnapshot, separateCarriedLoad(): LoadSeparation } |
| 18 | export interface IntakeParcel { id: string, massKg: number, positionM: SensorVector, velocityMps: SensorVector } | |
| 19 | export type PointLoadAttachment = { | |
| 20 | outcome: 'attached' | |
| 21 | reason: 'coincident-inelastic-point-capture' | |
| 22 | before: SeparationSide<'awaiting'> | |
| 23 | after: SeparationSide<'attached'> | |
| 24 | parcel: IntakeParcel | |
| 25 | energy: { kineticBeforeJ: number, kineticAfterJ: number, dissipatedJ: number, numericalToleranceJ: number, potentialBeforeJ: number, potentialAfterJ: number } | |
| 26 | } | { outcome: 'refused', reason: 'intake-consumed' | 'unexpected-parcel' | 'mount-not-coincident' } | |
| 27 | export interface ReceivingRigidBody extends RigidBody { | |
| 28 | loadSnapshot(): LoadSnapshot<PointLoadPhase> | |
| 29 | intakePoint(): { positionM: SensorVector, velocityMps: SensorVector } | |
| 30 | attachParcel(input: unknown): PointLoadAttachment | |
| 31 | separateCarriedLoad(): LoadSeparation | { outcome: 'refused', reason: 'awaiting-load' } | |
| 32 | } | |
| 17 | 33 | function cargoCross(a: SensorVector, b: SensorVector): SensorVector { |
| 18 | 34 | return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] |
| 19 | 35 | } |
| 36 | function captureOwnRecord(value: unknown, allowed: string[], required: string[]) { | |
| 37 | if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) throw new TypeError() | |
| 38 | const descriptors = Object.getOwnPropertyDescriptors(value), names = Reflect.ownKeys(value) | |
| 39 | if (names.some(name => typeof name !== 'string' || !allowed.includes(name)) || required.some(name => !Object.hasOwn(descriptors, name))) throw new TypeError() | |
| 40 | const captured: Record<string, unknown> = {} | |
| 41 | for (const name of names as string[]) { | |
| 42 | const descriptor = descriptors[name]! | |
| 43 | if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value') || descriptor.value === undefined) throw new TypeError() | |
| 44 | captured[name] = descriptor.value | |
| 45 | } | |
| 46 | return captured | |
| 47 | } | |
| 48 | function captureFiniteTuple(value: unknown, length: number, min: number, max: number) { | |
| 49 | if (!Array.isArray(value) || value.length !== length || Reflect.ownKeys(value).length !== length + 1) throw new TypeError() | |
| 50 | const captured: number[] = [] | |
| 51 | for (let i = 0; i < length; i++) { | |
| 52 | const descriptor = Object.getOwnPropertyDescriptor(value, String(i)) | |
| 53 | if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || typeof descriptor.value !== 'number' || !Number.isFinite(descriptor.value) || descriptor.value < min || descriptor.value > max) throw new TypeError() | |
| 54 | captured.push(descriptor.value) | |
| 55 | } | |
| 56 | return captured | |
| 57 | } | |
| 58 | function captureIntakeParcel(input: unknown): IntakeParcel { | |
| 59 | try { | |
| 60 | const values = captureOwnRecord(input, ['id', 'massKg', 'positionM', 'velocityMps'], ['id', 'massKg', 'positionM', 'velocityMps']) | |
| 61 | values.positionM = captureFiniteTuple(values.positionM, 3, -1e6, 1e6) | |
| 62 | values.velocityMps = captureFiniteTuple(values.velocityMps, 3, -1e6, 1e6) | |
| 63 | if (typeof values.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(values.id) || typeof values.massKg !== 'number' || !Number.isFinite(values.massKg) || values.massKg <= 0 || values.massKg > .25) throw new TypeError() | |
| 64 | return values as unknown as IntakeParcel | |
| 65 | } | |
| 66 | catch { throw new TypeError('cargo-intake-parcel') } | |
| 67 | } | |
| 20 | 68 | /** New opt-in records are captured as exact own data. The absent-own-member path is deliberately legacy. */ |
| 21 | function captureCarriedPointLoad(input: unknown): { input: RigidBodyInput, load: CarriedPointLoad } | null { | |
| 69 | function captureCarriedPointLoad(input: unknown, member: 'carriedPointLoad' | 'expectedPointLoad' = 'carriedPointLoad'): { input: RigidBodyInput, load: CarriedPointLoad } | null { | |
| 22 | 70 | try { |
| 23 | if (!input || (typeof input !== 'object' && typeof input !== 'function') || !Object.getOwnPropertyDescriptor(input, 'carriedPointLoad')) return null | |
| 24 | function record(value: unknown, allowed: string[], required: string[]) { | |
| 25 | if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) throw new TypeError() | |
| 26 | const descriptors = Object.getOwnPropertyDescriptors(value), names = Reflect.ownKeys(value) | |
| 27 | if (names.some(name => typeof name !== 'string' || !allowed.includes(name)) || required.some(name => !Object.hasOwn(descriptors, name))) throw new TypeError() | |
| 28 | const captured: Record<string, unknown> = {} | |
| 29 | for (const name of names as string[]) { | |
| 30 | const descriptor = descriptors[name]! | |
| 31 | if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value') || descriptor.value === undefined) throw new TypeError() | |
| 32 | captured[name] = descriptor.value | |
| 33 | } | |
| 34 | return captured | |
| 35 | } | |
| 36 | function tuple(value: unknown, length: number, min: number, max: number) { | |
| 37 | if (!Array.isArray(value) || value.length !== length || Reflect.ownKeys(value).length !== length + 1) throw new TypeError() | |
| 38 | const captured: number[] = [] | |
| 39 | for (let i = 0; i < length; i++) { | |
| 40 | const descriptor = Object.getOwnPropertyDescriptor(value, String(i)) | |
| 41 | if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || typeof descriptor.value !== 'number' || !Number.isFinite(descriptor.value) || descriptor.value < min || descriptor.value > max) throw new TypeError() | |
| 42 | captured.push(descriptor.value) | |
| 43 | } | |
| 44 | return captured | |
| 45 | } | |
| 46 | const captured = record(input, ['positionM', 'motorsN', 'groundDownM', 'groundGradient', 'carriedPointLoad'], ['carriedPointLoad']) | |
| 47 | const load = record(captured.carriedPointLoad, ['id', 'massKg', 'forwardM'], ['id', 'massKg', 'forwardM']) | |
| 71 | if (!input || (typeof input !== 'object' && typeof input !== 'function') || !Object.getOwnPropertyDescriptor(input, member)) return null | |
| 72 | const captured = captureOwnRecord(input, ['positionM', 'motorsN', 'groundDownM', 'groundGradient', member], [member]) | |
| 73 | const load = captureOwnRecord(captured[member], ['id', 'massKg', 'forwardM'], ['id', 'massKg', 'forwardM']) | |
| 48 | 74 | if (typeof load.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(load.id) || typeof load.massKg !== 'number' || !Number.isFinite(load.massKg) || load.massKg <= 0 || load.massKg > 0.25 || typeof load.forwardM !== 'number' || !Number.isFinite(load.forwardM) || load.forwardM < -0.1 || load.forwardM > 0.1) throw new TypeError() |
| 49 | 75 | const body: RigidBodyInput = {} |
| 50 | if (Object.hasOwn(captured, 'positionM')) body.positionM = tuple(captured.positionM, 3, -1e6, 1e6) as SensorVector | |
| 51 | if (Object.hasOwn(captured, 'motorsN')) body.motorsN = tuple(captured.motorsN, 4, 0, model.maxMotorThrustN) as Motors | |
| 76 | if (Object.hasOwn(captured, 'positionM')) body.positionM = captureFiniteTuple(captured.positionM, 3, -1e6, 1e6) as SensorVector | |
| 77 | if (Object.hasOwn(captured, 'motorsN')) body.motorsN = captureFiniteTuple(captured.motorsN, 4, 0, model.maxMotorThrustN) as Motors | |
| 52 | 78 | if (Object.hasOwn(captured, 'groundDownM')) { |
| 53 | 79 | if (typeof captured.groundDownM !== 'number' || !Number.isFinite(captured.groundDownM) || captured.groundDownM < -1 || captured.groundDownM > 0) throw new TypeError() |
| 54 | 80 | body.groundDownM = captured.groundDownM |
| 55 | 81 | } |
| 56 | 82 | if (Object.hasOwn(captured, 'groundGradient')) { |
| 57 | body.groundGradient = tuple(captured.groundGradient, 2, -0.1, 0.1) as [number, number] | |
| 83 | body.groundGradient = captureFiniteTuple(captured.groundGradient, 2, -0.1, 0.1) as [number, number] | |
| 58 | 84 | if (Math.hypot(...body.groundGradient) > 0.1) throw new TypeError() |
| 59 | 85 | } |
| 60 | 86 | return { input: body, load: { id: load.id, massKg: load.massKg, forwardM: load.forwardM } } |
| @@ -66,6 +92,18 @@ export function createRigidBody(input?: RigidBodyInput): RigidBody | ||
| 66 | 92 | export function createRigidBody(input: RigidBodyInput & { carriedPointLoad?: CarriedPointLoad } = {}): RigidBody | LoadedRigidBody { |
| 67 | 93 | const configured = captureCarriedPointLoad(input) |
| 68 | 94 | if (configured) input = configured.input |
| 95 | // This factory never exposes the awaiting phase or the intake operation. | |
| 96 | return createBody(input, configured, false) as RigidBody | LoadedRigidBody | |
| 97 | } | |
| 98 | ||
| 99 | /** An empty declared point joint. Physical world/policy ownership of the parcel belongs to the caller. */ | |
| 100 | export function createReceivingRigidBody(input: RigidBodyInput & { expectedPointLoad: CarriedPointLoad }): ReceivingRigidBody { | |
| 101 | const configured = captureCarriedPointLoad(input, 'expectedPointLoad') | |
| 102 | if (!configured) throw new TypeError('cargo-receiver-constructor') | |
| 103 | return createBody(configured.input, configured, true) as ReceivingRigidBody | |
| 104 | } | |
| 105 | ||
| 106 | function createBody(input: RigidBodyInput, configured: ReturnType<typeof captureCarriedPointLoad>, receiving: boolean): RigidBody | LoadedRigidBody | ReceivingRigidBody { | |
| 69 | 107 | |
| 70 | 108 | const groundDownM = input.groundDownM ?? 0 |
| 71 | 109 | const groundGradient = [...(input.groundGradient ?? [0, 0])] |
| @@ -78,11 +116,12 @@ export function createRigidBody(input: RigidBodyInput & { carriedPointLoad?: Car | ||
| 78 | 116 | let velocityMps: SensorVector = [0, 0, 0], angularRateRadS: SensorVector = [0, 0, 0], accelerationMps2: SensorVector = [0, 0, 0] |
| 79 | 117 | let quaternionWxyz: Quaternion = [1, 0, 0, 0], groundContact = false |
| 80 | 118 | function snapshot() { return structuredClone({ positionM, velocityMps, angularRateRadS, accelerationMps2, quaternionWxyz, motorsN, groundContact }) } |
| 81 | let attached = true | |
| 82 | function properties(): LoadProperties { | |
| 119 | let phase: PointLoadPhase = receiving ? 'awaiting' : 'attached' | |
| 120 | function properties(selectedPhase: PointLoadPhase = phase): LoadProperties<PointLoadPhase> { | |
| 121 | const attached = selectedPhase === 'attached' | |
| 83 | 122 | const load = configured!.load, totalMassKg = attached ? model.massKg + load.massKg : model.massKg |
| 84 | 123 | const shift = attached ? model.massKg * load.massKg / totalMassKg * load.forwardM ** 2 : 0 |
| 85 | return { id: load.id, phase: attached ? 'attached' : 'separated', pointMassKg: load.massKg, forwardM: load.forwardM, totalMassKg, cgBodyM: [attached ? load.massKg * load.forwardM / totalMassKg : 0, 0, 0], inertiaDiagonalKgM2: [model.inertiaKgM2[0]!, model.inertiaKgM2[1]! + shift, model.inertiaKgM2[2]! + shift] } | |
| 124 | return { id: load.id, phase: selectedPhase, pointMassKg: load.massKg, forwardM: load.forwardM, totalMassKg, cgBodyM: [attached ? load.massKg * load.forwardM / totalMassKg : 0, 0, 0], inertiaDiagonalKgM2: [model.inertiaKgM2[0]!, model.inertiaKgM2[1]! + shift, model.inertiaKgM2[2]! + shift] } | |
| 86 | 125 | } |
| 87 | 126 | let centerPositionM: SensorVector | null = configured ? positionM.map((n, i) => n + properties().cgBodyM[i]!) as SensorVector : null |
| 88 | 127 | let centerVelocityMps: SensorVector | null = configured ? [...velocityMps] : null |
| @@ -140,19 +179,50 @@ export function createRigidBody(input: RigidBodyInput & { carriedPointLoad?: Car | ||
| 140 | 179 | }, |
| 141 | 180 | } |
| 142 | 181 | if (!configured) return body |
| 143 | function loadSnapshot(): LoadSnapshot { return { properties: properties(), centerPositionM: [...centerPositionM!], centerVelocityMps: [...centerVelocityMps!] } } | |
| 144 | function side(): SeparationSide { | |
| 182 | function loadSnapshot(): LoadSnapshot<PointLoadPhase> { return { properties: properties(), centerPositionM: [...centerPositionM!], centerVelocityMps: [...centerVelocityMps!] } } | |
| 183 | function side(): SeparationSide<PointLoadPhase> { | |
| 145 | 184 | return { properties: properties(), bodyPositionM: [...positionM], bodyVelocityMps: [...velocityMps], quaternionWxyz: [...quaternionWxyz], angularRateRadS: [...angularRateRadS], actualMotorsN: [...motorsN], centerPositionM: [...centerPositionM!], centerVelocityMps: [...centerVelocityMps!] } |
| 146 | 185 | } |
| 147 | function separateCarriedLoad(...args: unknown[]): LoadSeparation { | |
| 186 | function separateCarriedLoad(...args: unknown[]): LoadSeparation | { outcome: 'refused', reason: 'awaiting-load' } { | |
| 148 | 187 | if (args.length !== 0) throw new TypeError('cargo-load-separation-arguments') |
| 149 | if (!attached) return Object.freeze({ outcome: 'refused', reason: 'already-separated' }) | |
| 188 | if (phase === 'awaiting') return Object.freeze({ outcome: 'refused', reason: 'awaiting-load' }) | |
| 189 | if (phase === 'separated') return Object.freeze({ outcome: 'refused', reason: 'already-separated' }) | |
| 150 | 190 | const before = side(), r: SensorVector = [configured!.load.forwardM, 0, 0] |
| 151 | 191 | const positionLever = rotateBodyToNed(quaternionWxyz, r), velocityLever = rotateBodyToNed(quaternionWxyz, cargoCross(angularRateRadS, r)) |
| 152 | 192 | const parcel = { massKg: configured!.load.massKg, positionM: positionM.map((n, i) => n + positionLever[i]!) as SensorVector, velocityMps: velocityMps.map((n, i) => n + velocityLever[i]!) as SensorVector } |
| 153 | attached = false; centerPositionM = [...positionM]; centerVelocityMps = [...velocityMps] | |
| 154 | return { outcome: 'separated', reason: 'prescribed-zero-ejection', before, after: side(), parcel } | |
| 193 | phase = 'separated'; centerPositionM = [...positionM]; centerVelocityMps = [...velocityMps] | |
| 194 | return { outcome: 'separated', reason: 'prescribed-zero-ejection', before: before as SeparationSide, after: side() as SeparationSide, parcel } | |
| 195 | } | |
| 196 | if (!receiving) return { ...body, loadSnapshot, separateCarriedLoad } as LoadedRigidBody | |
| 197 | function intakePoint(): { positionM: SensorVector, velocityMps: SensorVector } { | |
| 198 | const r: SensorVector = [configured!.load.forwardM, 0, 0], lever = rotateBodyToNed(quaternionWxyz, r), rate = rotateBodyToNed(quaternionWxyz, cargoCross(angularRateRadS, r)) | |
| 199 | return { positionM: positionM.map((n, i) => n + lever[i]!) as SensorVector, velocityMps: velocityMps.map((n, i) => n + rate[i]!) as SensorVector } | |
| 200 | } | |
| 201 | function attachParcel(input: unknown, ...extra: unknown[]): PointLoadAttachment { | |
| 202 | if (extra.length) throw new TypeError('cargo-intake-parcel') | |
| 203 | const parcel = captureIntakeParcel(input) | |
| 204 | if (phase !== 'awaiting') return { outcome: 'refused', reason: 'intake-consumed' } | |
| 205 | const mount = intakePoint() | |
| 206 | if (parcel.id !== configured!.load.id || parcel.massKg !== configured!.load.massKg) return { outcome: 'refused', reason: 'unexpected-parcel' } | |
| 207 | if (!parcel.positionM.every((n, i) => n === mount.positionM[i])) return { outcome: 'refused', reason: 'mount-not-coincident' } | |
| 208 | const before = side() as SeparationSide<'awaiting'>, afterProperties = properties('attached'), total = afterProperties.totalMassKg | |
| 209 | const c = afterProperties.cgBodyM, inertia = afterProperties.inertiaDiagonalKgM2, reduced = model.massKg * parcel.massKg / total | |
| 210 | const relative = rotateNedToBody(quaternionWxyz, parcel.velocityMps.map((n, i) => n - velocityMps[i]!) as SensorVector) | |
| 211 | const transfer = cargoCross([configured!.load.forwardM, 0, 0], relative) | |
| 212 | const nextRate = angularRateRadS.map((n, i) => (model.inertiaKgM2[i]! * n + reduced * transfer[i]!) / inertia[i]!) as SensorVector | |
| 213 | const nextCenterVelocity = velocityMps.map((n, i) => (model.massKg * n + parcel.massKg * parcel.velocityMps[i]!) / total) as SensorVector | |
| 214 | const lever = rotateBodyToNed(quaternionWxyz, c), rateLever = rotateBodyToNed(quaternionWxyz, cargoCross(nextRate, c)) | |
| 215 | const nextCenterPosition = positionM.map((n, i) => n + lever[i]!) as SensorVector | |
| 216 | const nextVelocity = nextCenterVelocity.map((n, i) => n - rateLever[i]!) as SensorVector | |
| 217 | const square = (v: SensorVector) => v.reduce((sum, n) => sum + n * n, 0) | |
| 218 | const kineticBeforeJ = .5 * model.massKg * square(velocityMps) + .5 * angularRateRadS.reduce((sum, n, i) => sum + model.inertiaKgM2[i]! * n * n, 0) + .5 * parcel.massKg * square(parcel.velocityMps) | |
| 219 | const kineticAfterJ = .5 * total * square(nextCenterVelocity) + .5 * nextRate.reduce((sum, n, i) => sum + inertia[i]! * n * n, 0) | |
| 220 | const dissipatedJ = kineticBeforeJ - kineticAfterJ, numericalToleranceJ = 64 * Number.EPSILON * Math.max(1, kineticBeforeJ, kineticAfterJ) | |
| 221 | const potentialBeforeJ = -model.gravityMps2 * (model.massKg * positionM[2] + parcel.massKg * parcel.positionM[2]), potentialAfterJ = -model.gravityMps2 * total * nextCenterPosition[2] | |
| 222 | if (![...nextRate, ...nextCenterVelocity, ...nextCenterPosition, ...nextVelocity, kineticBeforeJ, kineticAfterJ, dissipatedJ, potentialBeforeJ, potentialAfterJ].every(Number.isFinite) || dissipatedJ < -numericalToleranceJ) throw new RangeError('cargo-intake-numerical-state') | |
| 223 | phase = 'attached'; angularRateRadS = nextRate; velocityMps = nextVelocity; centerPositionM = nextCenterPosition; centerVelocityMps = nextCenterVelocity | |
| 224 | return { outcome: 'attached', reason: 'coincident-inelastic-point-capture', before, after: side() as SeparationSide<'attached'>, parcel, energy: { kineticBeforeJ, kineticAfterJ, dissipatedJ, numericalToleranceJ, potentialBeforeJ, potentialAfterJ } } | |
| 155 | 225 | } |
| 156 | return { ...body, loadSnapshot, separateCarriedLoad } | |
| 226 | return { ...body, loadSnapshot, separateCarriedLoad, intakePoint, attachParcel } | |
| 157 | 227 | } |
| 158 | 228 | export type RigidBodyState = ReturnType<ReturnType<typeof createRigidBody>['snapshot']> |
| @@ -3,9 +3,10 @@ import { rotateNedToBody } from './physics' | ||
| 3 | 3 | import { createSensorPipeline } from '../sensors/pipeline' |
| 4 | 4 | import { runtimeCrc32 } from '../runtime/protocol' |
| 5 | 5 | import { multirotorModel } from './model' |
| 6 | const bootId = 55n, boardOrigin = 1000000n | |
| 6 | const boardOrigin = 1000000n | |
| 7 | 7 | /** Modeled raw IMU/GNSS only. Measurement epoch is known because this is a synthetic source. */ |
| 8 | export function createPhysicsSensorFeed(additionalDelayMs: number) { | |
| 8 | export function createPhysicsSensorFeed(additionalDelayMs: number, bootId = 55n) { | |
| 9 | if (typeof bootId !== 'bigint' || bootId < 1n || bootId > 0xffffffffffffffffn) throw new TypeError('Invalid synthetic sensor boot scope') | |
| 9 | 10 | if (!Number.isFinite(additionalDelayMs) || additionalDelayMs < 0 || additionalDelayMs > 200) throw new RangeError('Sensor delay must be 0..200 ms') |
| 10 | 11 | const pipeline = createSensorPipeline({ anchor: { bootId, boardUs: boardOrigin, localMs: 0, uncertaintyUs: 0, driftPpm: 0, source: 'fixture' }, calibrations: { imu: { |
| 11 | 12 | id: 'physics-frd-synthetic', provenance: 'fixture', axes: [1, 2, 3], referenceC: 25, minC: -20, maxC: 60, accelBias: [0, 0, 0], gyroBias: [0, 0, 0], accelScale: [1, 1, 1], gyroScale: [1, 1, 1], accelTempSlope: [0, 0, 0], gyroTempSlope: [0, 0, 0], accelNoiseStd: [0.005, 0.005, 0.005], gyroNoiseStd: [0.001, 0.001, 0.001], |
| @@ -31,8 +31,24 @@ function captureLinkObservation(input: unknown, nowMs: number): { capturedAtMs: | ||
| 31 | 31 | if (!time?.enumerable || !Object.hasOwn(time, 'value') || !Number.isSafeInteger(time.value) || time.value < 0 || time.value > nowMs || !connected?.enumerable || !Object.hasOwn(connected, 'value') || typeof connected.value !== 'boolean') throw new TypeError('Unqualified vehicle link observation') |
| 32 | 32 | return { capturedAtMs: time.value, connected: connected.value } |
| 33 | 33 | } |
| 34 | /** Owned raw sensor -> estimator -> mission -> vehicle policy -> authority -> independent board loop. */ | |
| 35 | export function runVehicleSimulation(profileInput: VehicleProfile, options: { scenario?: string, seconds?: number, neutralTailMs?: number, missionActions?: readonly ScheduledMissionAction[] } = {}) { | |
| 34 | export interface VehicleSimulationOptions { | |
| 35 | scenario?: string | |
| 36 | seconds?: number | |
| 37 | neutralTailMs?: number | |
| 38 | missionActions?: readonly ScheduledMissionAction[] | |
| 39 | scope?: { bootId: bigint, sessionId: bigint } | |
| 40 | } | |
| 41 | function captureSimulationScope(input: unknown) { | |
| 42 | if (input === undefined) return { bootId: 55n, sessionId: 123n } | |
| 43 | if (!input || typeof input !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Reflect.ownKeys(input).length !== 2) throw new TypeError('Invalid vehicle simulation scope') | |
| 44 | const boot = Object.getOwnPropertyDescriptor(input, 'bootId'), session = Object.getOwnPropertyDescriptor(input, 'sessionId') | |
| 45 | if (!boot?.enumerable || !Object.hasOwn(boot, 'value') || !session?.enumerable || !Object.hasOwn(session, 'value') || [boot.value, session.value].some(value => typeof value !== 'bigint' || value < 1n || value > 0xffffffffffffffffn)) throw new TypeError('Invalid vehicle simulation scope') | |
| 46 | return { bootId: boot.value as bigint, sessionId: session.value as bigint } | |
| 47 | } | |
| 48 | /** Retained owned loop, advanced at exactly 4 ms epochs from zero by its caller. */ | |
| 49 | export function createVehicleSimulationSession(profileInput: VehicleProfile, options: VehicleSimulationOptions = {}) { | |
| 50 | const scope = captureSimulationScope(options.scope), vehicle = profileInput.vehicle | |
| 51 | const runtimeFaults = profileInput.faults.bind(profileInput) | |
| 36 | 52 | const scenario = options.scenario ?? profileInput.scenario, seconds = options.seconds ?? 120 |
| 37 | 53 | const neutralTailMs = options.neutralTailMs ?? 0 |
| 38 | 54 | const positionPrediction = profileInput.positionPrediction |
| @@ -43,21 +59,23 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 43 | 59 | const missionActions = captureMissionActions(options.missionActions, seconds * 1000) |
| 44 | 60 | const { plan, worstCaseMs, requiredWh } = validateVehicleMission(profileInput.mission) |
| 45 | 61 | const batteryModel = captureVehicleBatteryModel(profileInput.batteryModel, plan.power) |
| 46 | if (profileInput.vehicle !== plan.vehicle || plan.origin.latitudeDeg !== 0 || plan.origin.longitudeDeg !== 0 || plan.origin.ellipsoidHeightM !== 100) throw new TypeError('Vehicle fixture requires its declared 0,0,100 WGS84 origin') | |
| 62 | if (vehicle !== plan.vehicle || plan.origin.latitudeDeg !== 0 || plan.origin.longitudeDeg !== 0 || plan.origin.ellipsoidHeightM !== 100) throw new TypeError('Vehicle fixture requires its declared 0,0,100 WGS84 origin') | |
| 47 | 63 | const hasTransitions = plan.tasks.some(task => task.kind === 'vtol-mode') |
| 48 | 64 | const captureMotion = plan.tasks.some(task => task.kind === 'vtol-mode' && task.approach === 'bounded-approach-v1') |
| 49 | 65 | let transitionObservation: VehicleVtolTransitionObservation | null = null |
| 50 | 66 | const actuators = structuredClone(profileInput.actuators), fence = structuredClone(profileInput.authorityFence) |
| 51 | 67 | const session = profileInput.createSession(), navigation = session.createNavigation?.() |
| 52 | const linkProvider = session.linkObservation | |
| 53 | if (profileInput.vehicle === 'subsea' && !navigation) throw new TypeError('Subsea profile requires owned pressure/DVL navigation') | |
| 68 | const linkProvider = session.linkObservation, renderMeasurement = session.renderMeasurement.bind(session) | |
| 69 | const observeIndependent = session.observeIndependent?.bind(session), updateNavigation = navigation?.update.bind(navigation) | |
| 70 | if (vehicle === 'subsea' && !navigation) throw new TypeError('Subsea profile requires owned pressure/DVL navigation') | |
| 54 | 71 | const engine = createVehicleMissionEngine(plan), plant = session.createPlant(), controller = session.createController() |
| 55 | const supervisor = createVehicleAuthoritySupervisor({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators, fence }) | |
| 56 | const board = createVehicleOutputWatchdog({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators }) | |
| 57 | const feed = createPhysicsSensorFeed(0), estimator = createEstimator({ origin: plan.origin, gravityCorrection: 'initialization-only', positionPrediction }) | |
| 58 | const qualifier = createVehicleMeasurementQualifier(profileInput.vehicle, batteryModel ?? undefined) | |
| 72 | const plantSnapshot = plant.snapshot.bind(plant), stepPlant = plant.step.bind(plant), stepController = controller.step.bind(controller) | |
| 73 | const supervisor = createVehicleAuthoritySupervisor({ ...scope, vehicle: vehicle, actuators, fence }) | |
| 74 | const board = createVehicleOutputWatchdog({ ...scope, vehicle: vehicle, actuators }) | |
| 75 | const feed = createPhysicsSensorFeed(0, scope.bootId), estimator = createEstimator({ origin: plan.origin, gravityCorrection: 'initialization-only', positionPrediction }) | |
| 76 | const qualifier = createVehicleMeasurementQualifier(vehicle, batteryModel ?? undefined) | |
| 59 | 77 | const observedSensors = { imu: 0, gnss: 0 }, sensorSequences = { imu: 0, gnss: 0 } |
| 60 | let truth = plant.snapshot(), mission = engine.snapshot() | |
| 78 | let truth = structuredClone(plantSnapshot()), mission = engine.snapshot() | |
| 61 | 79 | let estimate: EstimatorSnapshot = estimator.snapshot(0) |
| 62 | 80 | let measurement: VehicleMeasurement | null = null, measurementPose: VehicleMeasurementPose | null = null |
| 63 | 81 | let batteryObservation: VehicleBatteryObservation | null = null |
| @@ -68,105 +86,165 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 68 | 86 | const transitions: { atMs: number, state: string, taskId: string, reason: string }[] = [] |
| 69 | 87 | let previousState = '', previousTask = '' |
| 70 | 88 | const payload = session.createPayload?.() |
| 71 | let finalAtMs = 0 | |
| 72 | try { | |
| 73 | for (let nowMs = 0; nowMs <= seconds * 1000; nowMs += 4) { | |
| 89 | const observePayload = payload?.observe?.bind(payload), pollPayload = payload?.poll.bind(payload), requestPayload = payload?.request.bind(payload), closePayload = payload?.close.bind(payload) | |
| 90 | let finalAtMs = 0, nextAtMs = 0, policyAtMs: number | null = null, hostEndAtMs: number | null = null | |
| 91 | let terminal = false, stepping = false | |
| 92 | const interrupted = Symbol('vehicle-session-closed') | |
| 93 | function owned<T>(work: () => T): T { | |
| 94 | if (terminal) throw interrupted | |
| 95 | const value = work() | |
| 96 | if (terminal) throw interrupted | |
| 97 | return value | |
| 98 | } | |
| 99 | function captured<T>(work: () => T): T { return owned(() => structuredClone(work())) } | |
| 100 | function stepHost(nowMs: number) { | |
| 101 | const faults = captured(() => runtimeFaults(scenario, nowMs, 'host')), boardState = board.tick(nowMs) | |
| 102 | if (nowMs > 600) truth = captured(() => stepPlant(boardState.actuators, 0.004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] })) | |
| 103 | if (faults.hostLost) hostLostAt ??= nowMs | |
| 104 | if (nowMs >= 600) owned(() => observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], hostLostAt === null)) | |
| 105 | if (hostLostAt !== null) { | |
| 106 | if (nowMs - hostLostAt >= 100) { status = 'host-lost'; fault = board.snapshot().fault ?? 'host-lost'; return false } | |
| 107 | return true | |
| 108 | } | |
| 109 | feed.capture(truth, nowMs, faults.dropImu, vehicle === 'subsea' || faults.dropPosition) | |
| 110 | const sensors = feed.advanceTo(nowMs) | |
| 111 | for (const kind of ['imu', 'gnss'] as const) { | |
| 112 | const sequence = sensors.sensors.find(sensor => sensor.kind === kind)?.observation?.sequence ?? 0 | |
| 113 | if (sequence > sensorSequences[kind]) { observedSensors[kind]++; sensorSequences[kind] = sequence } | |
| 114 | } | |
| 115 | const baseEstimate = estimator.update(sensors) | |
| 116 | estimate = updateNavigation ? captured(() => updateNavigation(baseEstimate, measurement, nowMs)) : baseEstimate | |
| 117 | if (nowMs % 20 !== 0) return true | |
| 118 | const reference = vehicleReference(estimate) | |
| 119 | const imuObservation = sensors.sensors.find(s => s.kind === 'imu')?.observation | |
| 120 | const capturePose = predictVehicleMeasurementPose(estimate, imuObservation, nowMs, { includeMotion: captureMotion }) | |
| 121 | if ((capturePose || vehicle === 'subsea') && !faults.dropVehicleSensor) { | |
| 122 | if (queue.length >= 8) throw new RangeError('Vehicle sensor queue exhausted') | |
| 123 | const packet = captured(() => renderMeasurement(truth, nowMs, reference)) | |
| 124 | if (packet.capturedAtMs !== nowMs || !Number.isSafeInteger(packet.receivedAtMs) || packet.receivedAtMs < nowMs + 20 || packet.receivedAtMs > nowMs + 100) throw new TypeError('Vehicle renderer must declare capture and bounded receipt epochs') | |
| 125 | queue.push({ measurement: packet, pose: capturePose }) | |
| 126 | queue.sort((a, b) => a.measurement.receivedAtMs - b.measurement.receivedAtMs || a.measurement.sequence - b.measurement.sequence) | |
| 127 | } | |
| 128 | while (queue[0] && queue[0].measurement.receivedAtMs <= nowMs) { const entry = queue.shift()!; measurement = entry.measurement; measurementPose = entry.pose } | |
| 129 | let qualified: VehicleMeasurement | null = null | |
| 130 | try { qualified = qualifier.qualify(measurement, nowMs, reference) } | |
| 131 | catch (error) { fault ??= error instanceof Error ? error.message : 'vehicle-sensor-envelope'; measurementPose = null } | |
| 132 | measurement = qualified | |
| 133 | if (batteryModel && qualified) { | |
| 134 | batteryObservation = observeVehicleBattery(qualified, batteryModel, nowMs) | |
| 135 | if (batteryObservation.cutoff) fault ??= 'modeled-battery-supply-cutoff' | |
| 136 | } | |
| 137 | if (updateNavigation) estimate = captured(() => updateNavigation(baseEstimate, qualified, nowMs)) | |
| 138 | let link = linkProvider === undefined ? { capturedAtMs: nowMs, connected: !faults.linkLost } : { capturedAtMs: 0, connected: false } | |
| 139 | if (typeof linkProvider === 'function') { | |
| 140 | try { link = captured(() => captureLinkObservation(linkProvider.call(session, nowMs), nowMs)) } | |
| 141 | catch (error) { if (error === interrupted || terminal) throw error; /* An installed adapter failure is disconnected, never a replacement fixture. */ } | |
| 142 | } | |
| 143 | if (faults.linkLost) link.connected = false | |
| 144 | if (nowMs < 600) return true | |
| 145 | const b = board.snapshot() | |
| 146 | const batteryPercent = batteryModel ? qualified && batteryObservation && !batteryObservation.cutoff ? batteryObservation.remainingPercentLowerBound : 0 : 100 - plan.power.maxW * Math.max(0, nowMs - 600) / 3600000 / plan.power.availableWh * 100 | |
| 147 | const observation = { kind: 'simulation' as const, nowMs, estimate, | |
| 148 | runtime: { bootId: b.bootId, capturedAtMs: nowMs, watchdogHealthy: b.fault === null }, | |
| 149 | rc: { capturedAtMs: nowMs, connected: true, armSwitch: true, override: faults.rcOverride ?? false }, | |
| 150 | interlocks: { capturedAtMs: qualified?.capturedAtMs ?? nowMs - 1000, killActive: faults.kill ?? false, onGround: qualified?.values.onSurface === true, motorsStopped: qualified?.values.stopped === true }, | |
| 151 | battery: { capturedAtMs: batteryModel ? qualified?.capturedAtMs ?? nowMs - 1000 : nowMs, remainingPercent: batteryPercent }, link } | |
| 152 | const send = (action: 'arm' | 'engage' | 'renew' | 'disarm', targetM = mission.targetM) => { | |
| 153 | const s = supervisor.snapshot() | |
| 154 | return supervisor.update(observation, { version: 1, sessionId: s.sessionId, bootId: s.bootId, epoch: s.epoch, sequence: s.sequence + 1, issuedAtMs: nowMs, action, mode: 'position', leaseMs: 100, target: { positionM: targetM ?? [0, 0, 0], yawRad: 0 } }) | |
| 155 | } | |
| 156 | supervisor.update(observation) | |
| 157 | if (nowMs === 600) { send('arm'); send('engage', [0, 0, 0]) } | |
| 158 | const context: VehicleControllerContext = { nowMs, enabled: supervisor.snapshot().state === 'autonomous', estimate, measurement, measurementPose, mission, | |
| 159 | ...(vehicle === 'vtol' || vehicle === 'quadcopter' ? { controlRate: capturePose ? { evidence: 'modeled-qualified-imu' as const, reference: capturePose.reference, capturedAtMs: capturePose.gyroCapturedAtMs, angularRateRadS: [...(imuObservation!.value as ImuMeasurement).angularRateRadS] as SensorVector } : null } : {}), | |
| 160 | } | |
| 161 | owned(() => observePayload?.(structuredClone(context))) | |
| 162 | const ack = captured(() => pollPayload?.(nowMs)) ?? null | |
| 163 | const missionObservation = { ...observeMission({ estimate, authority: supervisor.snapshot().state, batteryPercent, onGround: qualified?.values.onSurface === true }, vehicle === 'subsea' ? 'LOCAL_WATER_SURFACE' : 'WGS84_ELLIPSOID'), ...(!estimate.localPosition ? { origin: structuredClone(plan.origin), positionM: null, velocityMps: null } : {}), payloadAck: ack, ...(hasTransitions ? { transition: transitionObservation } : {}) } | |
| 164 | transitionObservation = null | |
| 165 | let action: VehicleMissionAction | undefined = nowMs === 600 ? 'start' : undefined | |
| 166 | const scheduled = missionActions[missionActionIndex] | |
| 167 | if (scheduled?.atMs === nowMs) { action = scheduled.action; missionActionIndex++ } | |
| 168 | mission = engine.step(missionObservation, action) | |
| 169 | if (mission.payloadFailure?.triggeredAtMs === nowMs) owned(() => observePayload?.(structuredClone({ ...context, mission }))) | |
| 170 | if (mission.payload && !requested.has(mission.payload.eventId)) { | |
| 171 | if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') } | |
| 172 | else { owned(() => observePayload?.(structuredClone({ ...context, mission }))); owned(() => requestPayload!(structuredClone(mission.payload!))); requested.add(mission.payload.eventId) } | |
| 173 | } | |
| 174 | const active = mission.state === 'running' || mission.state === 'paused' | |
| 175 | if (active && mission.targetM && !fault) send('renew') | |
| 176 | else { send('disarm'); board.revoke() } | |
| 177 | const output: VehicleControlOutput = captured(() => stepController({ ...context, enabled: supervisor.snapshot().state === 'autonomous', mission })) | |
| 178 | if (hasTransitions && active && !output.inhibited && supervisor.snapshot().state === 'autonomous') transitionObservation = output.transition ? structuredClone(output.transition) : null | |
| 179 | if (active && output.inhibited && supervisor.snapshot().state === 'autonomous') { fault ??= output.reason; send('disarm'); board.revoke() } | |
| 180 | const frame = supervisor.gate(output, nowMs) | |
| 181 | if (frame && !terminal) board.accept(frame, nowMs) | |
| 182 | else board.revoke() | |
| 183 | policyAtMs = nowMs | |
| 184 | path.push({ atMs: nowMs, positionM: estimate.localPosition ? [...estimate.localPosition.positionM] as SensorVector : null, truthPositionM: [...truth.positionM], mission: structuredClone(mission), measurement: measurement ? structuredClone(measurement) : null, measurementPose: measurementPose ? structuredClone(measurementPose) : null, actuators: [...board.snapshot().actuators], ...(hasTransitions ? { transitionObservation: transitionObservation ? structuredClone(transitionObservation) : null } : {}), ...(output.backCapture ? { backCapture: structuredClone(output.backCapture) } : {}) }) | |
| 185 | if (mission.state !== previousState || mission.taskId !== previousTask) { transitions.push({ atMs: nowMs, state: mission.state, taskId: mission.taskId, reason: mission.reason }); previousState = mission.state; previousTask = mission.taskId } | |
| 186 | if (!['ready', 'running', 'paused'].includes(mission.state)) { status = mission.state === 'completed' ? 'completed' : 'blocked'; fault ??= mission.state === 'completed' ? null : mission.reason; return false } | |
| 187 | return true | |
| 188 | } | |
| 189 | function finishHost() { | |
| 190 | if (hostEndAtMs !== null) return | |
| 191 | hostEndAtMs = finalAtMs | |
| 192 | try { closePayload?.() } | |
| 193 | finally { if (status !== 'host-lost') board.revoke() } | |
| 194 | } | |
| 195 | function close() { | |
| 196 | if (terminal) return | |
| 197 | terminal = true | |
| 198 | status = 'blocked'; fault ??= 'simulation-session-closed' | |
| 199 | try { finishHost() } | |
| 200 | finally { board.revoke() } | |
| 201 | } | |
| 202 | function progress(nowMs: number, phase: 'host' | 'independent-tail') { | |
| 203 | return { atMs: nowMs, phase, hostAvailable: phase === 'host' && hostLostAt === null && !terminal, terminal, policyAtMs, reference: vehicleReference(estimate), mission: structuredClone(mission), authority: supervisor.snapshot(), board: board.snapshot() } | |
| 204 | } | |
| 205 | function step(nowMs: number) { | |
| 206 | if (terminal) throw new Error('Vehicle simulation session is closed') | |
| 207 | if (stepping) throw new Error('Vehicle simulation session is already stepping') | |
| 208 | if (!Number.isSafeInteger(nowMs) || nowMs !== nextAtMs) throw new RangeError('Vehicle simulation requires its next 4 ms epoch') | |
| 209 | const phase = hostEndAtMs === null ? 'host' as const : 'independent-tail' as const | |
| 210 | stepping = true | |
| 211 | try { | |
| 74 | 212 | finalAtMs = nowMs |
| 75 | const faults = profileInput.faults(scenario, nowMs, 'host'), boardState = board.tick(nowMs) | |
| 76 | if (nowMs > 600) truth = plant.step(boardState.actuators, 0.004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] }) | |
| 77 | if (faults.hostLost) hostLostAt ??= nowMs | |
| 78 | if (nowMs >= 600) session.observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], hostLostAt === null) | |
| 79 | if (hostLostAt !== null) { | |
| 80 | if (nowMs - hostLostAt >= 100) { status = 'host-lost'; fault = board.snapshot().fault ?? 'host-lost'; break } | |
| 81 | continue | |
| 82 | } | |
| 83 | feed.capture(truth, nowMs, faults.dropImu, profileInput.vehicle === 'subsea' || faults.dropPosition) | |
| 84 | const sensors = feed.advanceTo(nowMs) | |
| 85 | for (const kind of ['imu', 'gnss'] as const) { | |
| 86 | const sequence = sensors.sensors.find(sensor => sensor.kind === kind)?.observation?.sequence ?? 0 | |
| 87 | if (sequence > sensorSequences[kind]) { observedSensors[kind]++; sensorSequences[kind] = sequence } | |
| 88 | } | |
| 89 | const baseEstimate = estimator.update(sensors) | |
| 90 | estimate = navigation ? navigation.update(baseEstimate, measurement, nowMs) : baseEstimate | |
| 91 | if (nowMs % 20 !== 0) continue | |
| 92 | const reference = vehicleReference(estimate) | |
| 93 | const imuObservation = sensors.sensors.find(s => s.kind === 'imu')?.observation | |
| 94 | const capturePose = predictVehicleMeasurementPose(estimate, imuObservation, nowMs, { includeMotion: captureMotion }) | |
| 95 | if ((capturePose || profileInput.vehicle === 'subsea') && !faults.dropVehicleSensor) { | |
| 96 | if (queue.length >= 8) throw new RangeError('Vehicle sensor queue exhausted') | |
| 97 | const packet = structuredClone(session.renderMeasurement(truth, nowMs, reference)) | |
| 98 | if (packet.capturedAtMs !== nowMs || !Number.isSafeInteger(packet.receivedAtMs) || packet.receivedAtMs < nowMs + 20 || packet.receivedAtMs > nowMs + 100) throw new TypeError('Vehicle renderer must declare capture and bounded receipt epochs') | |
| 99 | queue.push({ measurement: packet, pose: capturePose }) | |
| 100 | queue.sort((a, b) => a.measurement.receivedAtMs - b.measurement.receivedAtMs || a.measurement.sequence - b.measurement.sequence) | |
| 101 | } | |
| 102 | while (queue[0] && queue[0].measurement.receivedAtMs <= nowMs) { const entry = queue.shift()!; measurement = entry.measurement; measurementPose = entry.pose } | |
| 103 | let qualified: VehicleMeasurement | null = null | |
| 104 | try { qualified = qualifier.qualify(measurement, nowMs, reference) } | |
| 105 | catch (error) { fault ??= error instanceof Error ? error.message : 'vehicle-sensor-envelope'; measurementPose = null } | |
| 106 | measurement = qualified | |
| 107 | if (batteryModel && qualified) { | |
| 108 | batteryObservation = observeVehicleBattery(qualified, batteryModel, nowMs) | |
| 109 | if (batteryObservation.cutoff) fault ??= 'modeled-battery-supply-cutoff' | |
| 110 | } | |
| 111 | if (navigation) estimate = navigation.update(baseEstimate, qualified, nowMs) | |
| 112 | let link = linkProvider === undefined ? { capturedAtMs: nowMs, connected: !faults.linkLost } : { capturedAtMs: 0, connected: false } | |
| 113 | if (typeof linkProvider === 'function') { | |
| 114 | try { link = captureLinkObservation(linkProvider.call(session, nowMs), nowMs) } | |
| 115 | catch { /* An installed adapter failure is disconnected, never a replacement fixture. */ } | |
| 213 | if (phase === 'host') { | |
| 214 | if (!stepHost(nowMs) || nowMs === seconds * 1000) finishHost() | |
| 215 | if (hostEndAtMs !== null && neutralTailMs === 0) terminal = true | |
| 116 | 216 | } |
| 117 | if (faults.linkLost) link.connected = false | |
| 118 | if (nowMs < 600) continue | |
| 119 | const b = board.snapshot() | |
| 120 | const batteryPercent = batteryModel ? qualified && batteryObservation && !batteryObservation.cutoff ? batteryObservation.remainingPercentLowerBound : 0 : 100 - plan.power.maxW * Math.max(0, nowMs - 600) / 3600000 / plan.power.availableWh * 100 | |
| 121 | const observation = { kind: 'simulation' as const, nowMs, estimate, | |
| 122 | runtime: { bootId: b.bootId, capturedAtMs: nowMs, watchdogHealthy: b.fault === null }, | |
| 123 | rc: { capturedAtMs: nowMs, connected: true, armSwitch: true, override: faults.rcOverride ?? false }, | |
| 124 | interlocks: { capturedAtMs: qualified?.capturedAtMs ?? nowMs - 1000, killActive: faults.kill ?? false, onGround: qualified?.values.onSurface === true, motorsStopped: qualified?.values.stopped === true }, | |
| 125 | battery: { capturedAtMs: batteryModel ? qualified?.capturedAtMs ?? nowMs - 1000 : nowMs, remainingPercent: batteryPercent }, link } | |
| 126 | const send = (action: 'arm' | 'engage' | 'renew' | 'disarm', targetM = mission.targetM) => { | |
| 127 | const s = supervisor.snapshot() | |
| 128 | return supervisor.update(observation, { version: 1, sessionId: s.sessionId, bootId: s.bootId, epoch: s.epoch, sequence: s.sequence + 1, issuedAtMs: nowMs, action, mode: 'position', leaseMs: 100, target: { positionM: targetM ?? [0, 0, 0], yawRad: 0 } }) | |
| 217 | else { | |
| 218 | // No host sensor, estimation, mission or control work runs in this phase. | |
| 219 | const faults = captured(() => runtimeFaults(scenario, nowMs, 'independent-tail')), boardState = board.tick(nowMs) | |
| 220 | truth = captured(() => stepPlant(boardState.actuators, .004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] })) | |
| 221 | owned(() => observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], false)) | |
| 222 | if (nowMs === hostEndAtMs! + neutralTailMs) terminal = true | |
| 129 | 223 | } |
| 130 | supervisor.update(observation) | |
| 131 | if (nowMs === 600) { send('arm'); send('engage', [0, 0, 0]) } | |
| 132 | const context: VehicleControllerContext = { nowMs, enabled: supervisor.snapshot().state === 'autonomous', estimate, measurement, measurementPose, mission, | |
| 133 | ...(profileInput.vehicle === 'vtol' || profileInput.vehicle === 'quadcopter' ? { controlRate: capturePose ? { evidence: 'modeled-qualified-imu' as const, reference: capturePose.reference, capturedAtMs: capturePose.gyroCapturedAtMs, angularRateRadS: [...(imuObservation!.value as ImuMeasurement).angularRateRadS] as SensorVector } : null } : {}), | |
| 134 | } | |
| 135 | payload?.observe?.(structuredClone(context)) | |
| 136 | const ack = payload?.poll(nowMs) ?? null | |
| 137 | const missionObservation = { ...observeMission({ estimate, authority: supervisor.snapshot().state, batteryPercent, onGround: qualified?.values.onSurface === true }, profileInput.vehicle === 'subsea' ? 'LOCAL_WATER_SURFACE' : 'WGS84_ELLIPSOID'), ...(!estimate.localPosition ? { origin: structuredClone(plan.origin), positionM: null, velocityMps: null } : {}), payloadAck: ack, ...(hasTransitions ? { transition: transitionObservation } : {}) } | |
| 138 | transitionObservation = null | |
| 139 | let action: VehicleMissionAction | undefined = nowMs === 600 ? 'start' : undefined | |
| 140 | const scheduled = missionActions[missionActionIndex] | |
| 141 | if (scheduled?.atMs === nowMs) { action = scheduled.action; missionActionIndex++ } | |
| 142 | mission = engine.step(missionObservation, action) | |
| 143 | if (mission.payloadFailure?.triggeredAtMs === nowMs) payload?.observe?.(structuredClone({ ...context, mission })) | |
| 144 | if (mission.payload && !requested.has(mission.payload.eventId)) { | |
| 145 | if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') } | |
| 146 | else { payload.observe?.(structuredClone({ ...context, mission })); payload.request(structuredClone(mission.payload)); requested.add(mission.payload.eventId) } | |
| 147 | } | |
| 148 | const active = mission.state === 'running' || mission.state === 'paused' | |
| 149 | if (active && mission.targetM && !fault) send('renew') | |
| 150 | else { send('disarm'); board.revoke() } | |
| 151 | const output: VehicleControlOutput = controller.step({ ...context, enabled: supervisor.snapshot().state === 'autonomous', mission }) | |
| 152 | if (hasTransitions && active && !output.inhibited && supervisor.snapshot().state === 'autonomous') transitionObservation = output.transition ? structuredClone(output.transition) : null | |
| 153 | if (active && output.inhibited && supervisor.snapshot().state === 'autonomous') { fault ??= output.reason; send('disarm'); board.revoke() } | |
| 154 | const frame = supervisor.gate(output, nowMs) | |
| 155 | if (frame) board.accept(frame, nowMs) | |
| 156 | else board.revoke() | |
| 157 | path.push({ atMs: nowMs, positionM: estimate.localPosition ? [...estimate.localPosition.positionM] as SensorVector : null, truthPositionM: [...truth.positionM], mission: structuredClone(mission), measurement: measurement ? structuredClone(measurement) : null, measurementPose: measurementPose ? structuredClone(measurementPose) : null, actuators: [...board.snapshot().actuators], ...(hasTransitions ? { transitionObservation: transitionObservation ? structuredClone(transitionObservation) : null } : {}), ...(output.backCapture ? { backCapture: structuredClone(output.backCapture) } : {}) }) | |
| 158 | if (mission.state !== previousState || mission.taskId !== previousTask) { transitions.push({ atMs: nowMs, state: mission.state, taskId: mission.taskId, reason: mission.reason }); previousState = mission.state; previousTask = mission.taskId } | |
| 159 | if (!['ready', 'running', 'paused'].includes(mission.state)) { status = mission.state === 'completed' ? 'completed' : 'blocked'; fault ??= mission.state === 'completed' ? null : mission.reason; break } | |
| 224 | nextAtMs += 4 | |
| 225 | return progress(nowMs, phase) | |
| 226 | } | |
| 227 | catch (error) { | |
| 228 | if (error === interrupted) return progress(nowMs, phase) | |
| 229 | terminal = true; status = 'blocked'; fault ??= 'simulation-step-failed' | |
| 230 | try { finishHost() } | |
| 231 | catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Vehicle simulation step and cleanup failed') } | |
| 232 | finally { board.revoke() } | |
| 233 | throw error | |
| 160 | 234 | } |
| 235 | finally { stepping = false } | |
| 161 | 236 | } |
| 162 | finally { payload?.close(); if (status !== 'host-lost') board.revoke() } | |
| 163 | // Opt-in material/actuator settling has no host sensor, estimation, mission or control work. | |
| 164 | const hostEndAtMs = finalAtMs | |
| 165 | for (let elapsed = 4; elapsed <= neutralTailMs; elapsed += 4) { | |
| 166 | const nowMs = hostEndAtMs + elapsed, faults = profileInput.faults(scenario, nowMs, 'independent-tail'), boardState = board.tick(nowMs) | |
| 167 | truth = plant.step(boardState.actuators, .004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] }) | |
| 168 | session.observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], false) | |
| 169 | finalAtMs = nowMs | |
| 237 | function result() { | |
| 238 | if (!terminal) throw new Error('Vehicle simulation session is still active') | |
| 239 | return structuredClone({ version: 1, evidence: 'owned-vehicle-physics-simulation' as const, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, vehicle: vehicle, scenario, status, fault, navigationEvidence: { ...(positionPrediction ? { positionPrediction: { ...fixtureInertialModel } } : {}), observedSensors, datum: estimate.localPosition?.altitudeDatum ?? null, quality: [...estimate.quality], localPositionHealthy: estimate.health.localPosition }, mission, trace: engine.trace(), path, transitions, budget: { worstCaseMs, requiredWh }, authority: supervisor.snapshot(), board: board.snapshot(), finalTruthPositionM: [...truth.positionM] as SensorVector, ...(batteryModel ? { energy: { evidence: 'modeled-vehicle-battery-v1' as const, model: batteryModel, lastObservation: batteryObservation } } : {}) }) | |
| 240 | } | |
| 241 | return { step, result, close } | |
| 242 | } | |
| 243 | /** Run the same retained session synchronously to its natural terminal result. */ | |
| 244 | export function runVehicleSimulation(profileInput: VehicleProfile, options: VehicleSimulationOptions = {}) { | |
| 245 | const session = createVehicleSimulationSession(profileInput, options) | |
| 246 | try { | |
| 247 | for (let nowMs = 0; ; nowMs += 4) { if (session.step(nowMs).terminal) return session.result() } | |
| 170 | 248 | } |
| 171 | return { version: 1, evidence: 'owned-vehicle-physics-simulation' as const, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, vehicle: profileInput.vehicle, scenario, status, fault, navigationEvidence: { ...(positionPrediction ? { positionPrediction: { ...fixtureInertialModel } } : {}), observedSensors, datum: estimate.localPosition?.altitudeDatum ?? null, quality: [...estimate.quality], localPositionHealthy: estimate.health.localPosition }, mission, trace: engine.trace(), path, transitions, budget: { worstCaseMs, requiredWh }, authority: supervisor.snapshot(), board: board.snapshot(), finalTruthPositionM: [...truth.positionM] as SensorVector, ...(batteryModel ? { energy: { evidence: 'modeled-vehicle-battery-v1' as const, model: batteryModel, lastObservation: batteryObservation } } : {}) } | |
| 249 | finally { session.close() } | |
| 172 | 250 | } |
| @@ -2,12 +2,14 @@ import { Buffer } from 'node:buffer' | ||
| 2 | 2 | import { writeSync } from 'node:fs' |
| 3 | 3 | import { mkdir, open, writeFile } from 'node:fs/promises' |
| 4 | 4 | import { join } from 'node:path' |
| 5 | import type { BrowserStartupEvidence } from './browser-startup-process' | |
| 5 | 6 | |
| 6 | 7 | export interface BrowserLaunchObservation { |
| 7 | 8 | phase: 'startup-unavailable' | 'discovery-failed' | 'closed' |
| 8 | 9 | elapsedMs: number |
| 9 | 10 | launch: { pid: number, exitObserved: boolean, settledExitCode: number | null, signalCode: string | number | null, exitObservedBeforeCleanup: boolean, cleanupRequested: boolean, exitElapsedMs: number | null } |
| 10 | 11 | portProbe: { attempts: number, successfulReads: number, failedReads: number, lastReadError: string | null, discovered: boolean } |
| 12 | startup?: BrowserStartupEvidence | |
| 11 | 13 | } |
| 12 | 14 | export function browserFileErrorCode(error: unknown): string { |
| 13 | 15 | const code = (error as NodeJS.ErrnoException | null)?.code |
| @@ -0,0 +1,167 @@ | ||
| 1 | import { createHash } from 'node:crypto' | |
| 2 | import { lstat, open, readFile, readlink, readdir } from 'node:fs/promises' | |
| 3 | import type { FileHandle } from 'node:fs/promises' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { browserFileErrorCode } from './browser-launch-evidence' | |
| 6 | ||
| 7 | const processLimit = 64, threadLimit = 32, executableLimit = 4, executableByteLimit = 512 * 1024 * 1024 | |
| 8 | async function text(path: string, signal: AbortSignal) { | |
| 9 | if (signal.aborted) return { value: null, code: 'ABORT_ERR' } | |
| 10 | try { | |
| 11 | const value = await readFile(path, { encoding: 'utf8', signal }) | |
| 12 | return value.length <= 32768 ? { value, code: null } : { value: null, code: 'BYTE_LIMIT' } | |
| 13 | } | |
| 14 | catch (error) { return { value: null, code: browserFileErrorCode(error) } } | |
| 15 | } | |
| 16 | const decimal = (value: string | undefined) => value && /^\d{1,20}$/.test(value) ? value : null | |
| 17 | const integer = (value: string | undefined) => { const n = decimal(value); return n !== null && Number.isSafeInteger(Number(n)) ? Number(n) : null } | |
| 18 | async function processState(pid: number, signal: AbortSignal) { | |
| 19 | const [raw, status, wait, children] = await Promise.all([text(`/proc/${pid}/stat`, signal), text(`/proc/${pid}/status`, signal), text(`/proc/${pid}/wchan`, signal), text(`/proc/${pid}/task/${pid}/children`, signal)]) | |
| 20 | signal.throwIfAborted() | |
| 21 | const end = raw.value?.lastIndexOf(') ') ?? -1 | |
| 22 | // tcomm may contain spaces/parentheses and is deliberately discarded. | |
| 23 | const fields = end >= 0 ? raw.value!.slice(end + 2).trim().split(/\s+/) : [] | |
| 24 | const valid = fields.length >= 22 && /^[RSDZTtXxKWPI]$/.test(fields[0]!) && decimal(fields[19]) !== null | |
| 25 | const rss = status.value?.match(/^VmRSS:\s+(\d+)\s+kB$/m)?.[1] | |
| 26 | const ids = children.value?.trim().split(/\s+/).map(integer).filter((n): n is number => n !== null && n > 0) ?? [] | |
| 27 | return { | |
| 28 | record: { pid, code: valid ? null : raw.code ?? 'FORMAT', state: valid ? fields[0]! : null, | |
| 29 | parentPid: valid ? integer(fields[1]) : null, group: valid ? integer(fields[2]) : null, session: valid ? integer(fields[3]) : null, | |
| 30 | startTicks: valid ? decimal(fields[19]) : null, userTicks: valid ? decimal(fields[11]) : null, systemTicks: valid ? decimal(fields[12]) : null, | |
| 31 | threads: valid ? integer(fields[17]) : null, rssKiB: integer(rss), statusCode: status.code, | |
| 32 | wait: wait.value !== null && /^[A-Za-z0-9_.:]{1,80}$/.test(wait.value.trim()) ? wait.value.trim() : null, waitCode: wait.code, childrenCode: children.code }, | |
| 33 | children: ids.slice(0, processLimit), childrenCapped: ids.length > processLimit, | |
| 34 | } | |
| 35 | } | |
| 36 | async function counters(path: string, allowed: string[], signal: AbortSignal) { | |
| 37 | const result = await text(path, signal), values: Record<string, string> = {} | |
| 38 | signal.throwIfAborted() | |
| 39 | for (const line of result.value?.split('\n') ?? []) { | |
| 40 | const [key, value] = line.trim().split(/\s+/) | |
| 41 | if (key && allowed.includes(key) && decimal(value) !== null) values[key] = value! | |
| 42 | } | |
| 43 | return { values, code: result.code } | |
| 44 | } | |
| 45 | async function scalar(path: string, signal: AbortSignal) { | |
| 46 | const result = await text(path, signal), value = result.value?.trim() | |
| 47 | signal.throwIfAborted() | |
| 48 | return { value: value === 'max' || decimal(value) !== null ? value! : null, code: result.code ?? (value === 'max' || decimal(value) !== null ? null : 'FORMAT') } | |
| 49 | } | |
| 50 | async function pressure(resource: string, signal: AbortSignal) { | |
| 51 | const result = await text(`/proc/pressure/${resource}`, signal), values: Record<string, { avg10: number, avg60: number, avg300: number, totalMicroseconds: string }> = {} | |
| 52 | for (const line of result.value?.split('\n') ?? []) { | |
| 53 | const match = /^(some|full) avg10=(\d{1,3}(?:\.\d{1,6})?) avg60=(\d{1,3}(?:\.\d{1,6})?) avg300=(\d{1,3}(?:\.\d{1,6})?) total=(\d{1,20})$/.exec(line) | |
| 54 | if (match) values[match[1]!] = { avg10: Number(match[2]), avg60: Number(match[3]), avg300: Number(match[4]), totalMicroseconds: match[5]! } | |
| 55 | } | |
| 56 | return { values, code: result.code } | |
| 57 | } | |
| 58 | async function resources(signal: AbortSignal) { | |
| 59 | signal.throwIfAborted() | |
| 60 | const membership = await text('/proc/self/cgroup', signal) | |
| 61 | signal.throwIfAborted() | |
| 62 | const path = membership.value?.split('\n').find(line => line.startsWith('0::'))?.slice(3) | |
| 63 | const base = path?.startsWith('/') && !path.split('/').includes('..') ? join('/sys/fs/cgroup', path) : null | |
| 64 | const [cpuPressure, memoryPressure, ioPressure] = await Promise.all(['cpu', 'memory', 'io'].map(name => pressure(name, signal))) | |
| 65 | signal.throwIfAborted() | |
| 66 | const cgroup = base ? { | |
| 67 | memoryCurrent: await scalar(join(base, 'memory.current'), signal), memoryMax: await scalar(join(base, 'memory.max'), signal), | |
| 68 | memoryEvents: await counters(join(base, 'memory.events'), ['low', 'high', 'max', 'oom', 'oom_kill', 'oom_group_kill', 'sock_throttled'], signal), | |
| 69 | cpu: await counters(join(base, 'cpu.stat'), ['usage_usec', 'user_usec', 'system_usec', 'nr_periods', 'nr_throttled', 'throttled_usec', 'nr_bursts', 'burst_usec'], signal), | |
| 70 | pidsCurrent: await scalar(join(base, 'pids.current'), signal), pidsMax: await scalar(join(base, 'pids.max'), signal), | |
| 71 | } : null | |
| 72 | return { scope: 'observer cgroup, potentially shared with other runner processes', pressureScope: 'system pressure, shared with other host workloads', membershipCode: membership.code ?? (base ? null : 'NOT_V2'), cgroup, cpuPressure, memoryPressure, ioPressure } | |
| 73 | } | |
| 74 | ||
| 75 | type Executable = { path: string, device: string, inode: string, byteLength: number, observedPids: number[], openedAtMs: number, sha256: string | null, hashedAfterDiscovery: boolean, code: string | null, closeCode: string | null } | |
| 76 | /** No argv, environment, comm, stack or memory dump is exported. */ | |
| 77 | export function createLinuxStartupObservation(pid: number, profile: string, began: number) { | |
| 78 | const tracked = new Map<number, string>(), pinned = new Map<string, { handle: FileHandle, mtimeNs: bigint, ctimeNs: bigint, evidence: Executable }>() | |
| 79 | const identityErrors: Array<{ pid: number, code: string }> = [] | |
| 80 | let handlesSealed = false, executableLimitReached = false | |
| 81 | async function executable(id: number, signal: AbortSignal) { | |
| 82 | if (handlesSealed || signal.aborted) return | |
| 83 | let handle: FileHandle | undefined | |
| 84 | try { | |
| 85 | handle = await open(`/proc/${id}/exe`, 'r') | |
| 86 | if (handlesSealed || signal.aborted) return | |
| 87 | const stat = await handle.stat({ bigint: true }), key = `${stat.dev}:${stat.ino}` | |
| 88 | if (handlesSealed || signal.aborted) return | |
| 89 | const prior = pinned.get(key) | |
| 90 | if (prior) { if (!prior.evidence.observedPids.includes(id)) prior.evidence.observedPids.push(id); return } | |
| 91 | if (pinned.size >= executableLimit) { executableLimitReached = true; return } | |
| 92 | const path = await readlink(`/proc/self/fd/${handle.fd}`) | |
| 93 | if (handlesSealed || signal.aborted) return | |
| 94 | if (!stat.isFile() || stat.size <= 0n || stat.size > BigInt(executableByteLimit) || path.length > 4096) { if (identityErrors.length < 8) identityErrors.push({ pid: id, code: 'IDENTITY_LIMIT' }); return } | |
| 95 | pinned.set(key, { handle, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs, evidence: { path, device: String(stat.dev), inode: String(stat.ino), byteLength: Number(stat.size), observedPids: [id], openedAtMs: performance.now() - began, sha256: null, hashedAfterDiscovery: false, code: null, closeCode: null } }) | |
| 96 | handle = undefined | |
| 97 | } | |
| 98 | catch (error) { if (identityErrors.length < 8) identityErrors.push({ pid: id, code: browserFileErrorCode(error) }) } | |
| 99 | finally { | |
| 100 | if (handle) { | |
| 101 | try { await handle.close() } | |
| 102 | catch (closeError) { if (identityErrors.length < 8) identityErrors.push({ pid: id, code: browserFileErrorCode(closeError) }) } | |
| 103 | } | |
| 104 | } | |
| 105 | } | |
| 106 | return { | |
| 107 | async snapshot(signal: AbortSignal) { | |
| 108 | signal.throwIfAborted() | |
| 109 | const root = await processState(pid, signal), descendants: Array<typeof root.record & { reused: boolean }> = [] | |
| 110 | const check = (id: number, start: string | null) => { | |
| 111 | if (!start) return false | |
| 112 | const prior = tracked.get(id) | |
| 113 | if (prior && prior !== start) return false | |
| 114 | if (tracked.size < processLimit) tracked.set(id, start) | |
| 115 | return true | |
| 116 | } | |
| 117 | const rootMatches = check(pid, root.record.startTicks) | |
| 118 | if (rootMatches) await executable(pid, signal) | |
| 119 | const queue = [...new Set([...(rootMatches ? root.children : []), ...tracked.keys()].filter(id => id !== pid))] | |
| 120 | const seen = new Set<number>([pid]); let processLimitReached = root.childrenCapped | |
| 121 | while (queue.length && seen.size < processLimit && !signal.aborted) { | |
| 122 | const id = queue.shift()! | |
| 123 | if (seen.has(id)) continue | |
| 124 | seen.add(id) | |
| 125 | const state = await processState(id, signal), matches = check(id, state.record.startTicks) | |
| 126 | descendants.push({ ...state.record, reused: state.record.startTicks !== null && !matches }) | |
| 127 | if (matches) { await executable(id, signal); queue.push(...state.children.filter(n => !seen.has(n))); processLimitReached ||= state.childrenCapped } | |
| 128 | } | |
| 129 | processLimitReached ||= queue.length > 0 || tracked.size === processLimit | |
| 130 | signal.throwIfAborted() | |
| 131 | let threadCode: string | null = null | |
| 132 | const allThreads = await readdir(`/proc/${pid}/task`).catch(error => { threadCode = browserFileErrorCode(error); return [] }) | |
| 133 | signal.throwIfAborted() | |
| 134 | const threads = await Promise.all(allThreads.filter(id => /^\d+$/.test(id)).slice(0, threadLimit).map(async id => { | |
| 135 | const wait = await text(`/proc/${pid}/task/${id}/wchan`, signal) | |
| 136 | return { tid: Number(id), wait: wait.value !== null && /^[A-Za-z0-9_.:]{1,80}$/.test(wait.value.trim()) ? wait.value.trim() : null, code: wait.code } | |
| 137 | })) | |
| 138 | signal.throwIfAborted() | |
| 139 | const discoveryFile = await lstat(join(profile, 'DevToolsActivePort')).then(value => ({ present: true, regular: value.isFile(), bytes: value.size, code: null })).catch(error => ({ present: false, regular: null, bytes: null, code: browserFileErrorCode(error) })) | |
| 140 | signal.throwIfAborted() | |
| 141 | return { status: 'observed' as const, root: { ...root.record, reused: root.record.startTicks !== null && !rootMatches }, descendants, processLimitReached, | |
| 142 | childScope: 'main-task children and previously observed descendants; incomplete during fork/exit/reparenting', threads, threadCode, threadDetailsCapped: allThreads.length > threadLimit, discoveryFile, resources: await resources(signal) } | |
| 143 | }, | |
| 144 | async settleExecutables(shouldHash: boolean) { | |
| 145 | handlesSealed = true | |
| 146 | const abort = new AbortController(), timer = setTimeout(() => abort.abort(), 2000) | |
| 147 | try { | |
| 148 | for (const { handle, evidence, mtimeNs, ctimeNs } of pinned.values()) { | |
| 149 | try { | |
| 150 | if (!shouldHash) { evidence.code = 'OBSERVATION_CANCELED'; continue } | |
| 151 | if (abort.signal.aborted) { evidence.code = 'HASH_BUDGET'; continue } | |
| 152 | const hash = createHash('sha256'); let bytes = 0 | |
| 153 | for await (const chunk of handle.createReadStream({ autoClose: false, start: 0, end: evidence.byteLength - 1, signal: abort.signal })) { hash.update(chunk); bytes += chunk.length } | |
| 154 | const after = await handle.stat({ bigint: true }) | |
| 155 | if (abort.signal.aborted) { evidence.code = 'HASH_BUDGET'; continue } | |
| 156 | if (bytes !== evidence.byteLength || after.size !== BigInt(bytes) || after.mtimeNs !== mtimeNs || after.ctimeNs !== ctimeNs) { evidence.code = 'EXECUTABLE_CHANGED'; continue } | |
| 157 | evidence.sha256 = hash.digest('hex'); evidence.hashedAfterDiscovery = true | |
| 158 | } | |
| 159 | catch (error) { evidence.code = browserFileErrorCode(error) } | |
| 160 | finally { await handle.close().catch(error => { evidence.closeCode = browserFileErrorCode(error) }) } | |
| 161 | } | |
| 162 | } | |
| 163 | finally { clearTimeout(timer) } | |
| 164 | }, | |
| 165 | evidence() { return { executables: [...pinned.values()].map(value => structuredClone(value.evidence)), identityErrors: structuredClone(identityErrors), executableLimitReached } }, | |
| 166 | } | |
| 167 | } | |
| @@ -0,0 +1,61 @@ | ||
| 1 | import { browserFileErrorCode } from './browser-launch-evidence' | |
| 2 | import type { createLinuxStartupObservation } from './browser-startup-linux' | |
| 3 | ||
| 4 | type NativeSnapshot = Awaited<ReturnType<ReturnType<typeof createLinuxStartupObservation>['snapshot']>> | |
| 5 | export interface StartupProcessAdapter { | |
| 6 | snapshot: (signal: AbortSignal) => Promise<NativeSnapshot | { status: 'unavailable', code: string | null }> | |
| 7 | settleExecutables: (hash: boolean) => Promise<void> | |
| 8 | evidence: ReturnType<typeof createLinuxStartupObservation>['evidence'] | |
| 9 | } | |
| 10 | ||
| 11 | type Probe = 'present' | 'missing' | 'denied' | 'unavailable' | |
| 12 | function rootProbe(pid: number): Probe { | |
| 13 | try { process.kill(pid, 0); return 'present' } | |
| 14 | catch (error) { | |
| 15 | const code = browserFileErrorCode(error) | |
| 16 | return code === 'ESRCH' ? 'missing' : code === 'EPERM' ? 'denied' : 'unavailable' | |
| 17 | } | |
| 18 | } | |
| 19 | ||
| 20 | /** Startup-only facts. A PID probe does not establish complete process-group ownership. */ | |
| 21 | export function createBrowserStartupObserver(pid: number, began: number, linux: StartupProcessAdapter | null) { | |
| 22 | type Linux = NativeSnapshot | { status: 'unsupported' | 'timeout' | 'unavailable', code: string | null } | |
| 23 | const snapshots: Array<{ label: string, elapsedMs: number, completedAtMs: number, root: { pid: number, probe: Probe }, linux: Linux }> = [] | |
| 24 | const milestones = [0, 1000, 5000, 10000, 15000] | |
| 25 | let next = 0, sealed = false, inFlight = false, canceled = false, browserVersion: string | null = null | |
| 26 | const sample = async (label: string) => { | |
| 27 | const elapsedMs = performance.now() - began, root = { pid, probe: rootProbe(pid) } | |
| 28 | const abort = new AbortController() | |
| 29 | let timer: ReturnType<typeof setTimeout> | undefined, expired = false | |
| 30 | try { | |
| 31 | let observation: Linux = { status: 'unsupported', code: null } | |
| 32 | if (linux && canceled) observation = { status: 'unavailable', code: 'OBSERVATION_CANCELED' } | |
| 33 | else if (linux) { | |
| 34 | timer = setTimeout(() => { | |
| 35 | expired = true; canceled = true; abort.abort() | |
| 36 | console.error(JSON.stringify({ oracle: 'browser-startup-observation-pending', pid, elapsedMs: performance.now() - began })) | |
| 37 | }, 500) | |
| 38 | // Cancellation requests are not settlement. Own this actual task until it returns. | |
| 39 | observation = await linux.snapshot(abort.signal).catch(error => ({ status: 'unavailable' as const, code: browserFileErrorCode(error) })) | |
| 40 | if (expired) observation = { status: 'timeout', code: 'OBSERVATION_BUDGET' } | |
| 41 | } | |
| 42 | snapshots.push({ label, elapsedMs, completedAtMs: performance.now() - began, root, linux: observation }) | |
| 43 | } | |
| 44 | finally { if (timer) clearTimeout(timer) } | |
| 45 | } | |
| 46 | let pending: Promise<void> = Promise.resolve() | |
| 47 | return { | |
| 48 | tick() { | |
| 49 | if (!sealed && !canceled && !inFlight && next < milestones.length && performance.now() - began >= milestones[next]!) { | |
| 50 | const at = milestones[next++]! | |
| 51 | inFlight = true | |
| 52 | pending = sample(at === 0 ? 'launch' : `elapsed-${at}ms`).finally(() => { inFlight = false }) | |
| 53 | } | |
| 54 | }, | |
| 55 | async finishDiscovery() { sealed = true; await pending; await sample('discovery-ended'); await linux?.settleExecutables(!canceled) }, | |
| 56 | async cleanupEnded() { await sample('cleanup-ended') }, | |
| 57 | recordVersion(value: unknown) { if (typeof value === 'string' && /^(?:HeadlessChrome|Chrome)\/\d{1,6}(?:\.\d{1,8}){3}$/.test(value)) browserVersion = value }, | |
| 58 | evidence() { return { method: 'startup-only-process-observation-v1' as const, platform: process.platform, browserVersion, snapshots: structuredClone(snapshots), ...linux?.evidence() ?? { executables: [], identityErrors: [], executableLimitReached: false } } }, | |
| 59 | } | |
| 60 | } | |
| 61 | export type BrowserStartupEvidence = ReturnType<ReturnType<typeof createBrowserStartupObserver>['evidence']> | |
| @@ -4,6 +4,8 @@ import { join } from 'node:path' | ||
| 4 | 4 | import { observeBrowserOperation } from './browser-operation' |
| 5 | 5 | import { browserFileErrorCode, retainBrowserLaunchEvidence } from './browser-launch-evidence' |
| 6 | 6 | import type { BrowserLaunchObservation } from './browser-launch-evidence' |
| 7 | import { createBrowserStartupObserver } from './browser-startup-process' | |
| 8 | import { createLinuxStartupObservation } from './browser-startup-linux' | |
| 7 | 9 | |
| 8 | 10 | /** Dependency-free CDP, with a fresh profile. Credentials never enter command arguments. */ |
| 9 | 11 | async function openSocket(url: string): Promise<WebSocket> { |
| @@ -22,31 +24,41 @@ export async function startWorkspaceBrowser(root: string) { | ||
| 22 | 24 | const child = Bun.spawn([process.env.BROWSE_BROWSER ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '--headless=new', '--disable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--disable-features=CalculateNativeWinOcclusion', '--disable-dev-shm-usage', '--disable-extensions', '--disable-component-update', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdout: 'ignore', stderr: Bun.file(join(root, 'chrome.log')) }) |
| 23 | 25 | const started = Date.now() |
| 24 | 26 | const began = performance.now() |
| 27 | const startup = createBrowserStartupObserver(child.pid, began, process.platform === 'linux' ? createLinuxStartupObservation(child.pid, profile, began) : null) | |
| 25 | 28 | let closing = false |
| 26 | 29 | const launch: BrowserLaunchObservation['launch'] = { pid: child.pid, exitObserved: false, settledExitCode: null, signalCode: null, exitObservedBeforeCleanup: false, cleanupRequested: false, exitElapsedMs: null } |
| 27 | 30 | const portProbe: BrowserLaunchObservation['portProbe'] = { attempts: 0, successfulReads: 0, failedReads: 0, lastReadError: null, discovered: false } |
| 28 | 31 | let pageVisited = false |
| 29 | const retain = (phase: BrowserLaunchObservation['phase']) => retainBrowserLaunchEvidence(root, { phase, elapsedMs: performance.now() - began, launch: { ...launch }, portProbe: { ...portProbe } }, { startupStderr: !pageVisited, exportDirectory: process.env.WORKSPACE_BROWSER_EVIDENCE ?? process.env.EXECUTION_BROWSER_EVIDENCE }) | |
| 32 | const retain = (phase: BrowserLaunchObservation['phase']) => retainBrowserLaunchEvidence(root, { phase, elapsedMs: performance.now() - began, launch: { ...launch }, portProbe: { ...portProbe }, startup: startup.evidence() }, { startupStderr: !pageVisited, exportDirectory: process.env.WORKSPACE_BROWSER_EVIDENCE ?? process.env.EXECUTION_BROWSER_EVIDENCE }) | |
| 33 | const cleanup = async () => { | |
| 34 | closing = true; launch.cleanupRequested = true | |
| 35 | if (child.exitCode === null) child.kill() | |
| 36 | await Promise.race([child.exited, Bun.sleep(2000)]) | |
| 37 | if (!pageVisited) await startup.cleanupEnded() | |
| 38 | } | |
| 30 | 39 | void child.exited.then(code => { |
| 31 | 40 | launch.exitObserved = true; launch.settledExitCode = code; launch.signalCode = child.signalCode; launch.exitObservedBeforeCleanup = !closing; launch.exitElapsedMs = performance.now() - began |
| 32 | 41 | if (!closing) console.log(`QA isolated Chrome exited unexpectedly: ${code}, elapsedMs:${Date.now() - started}`) |
| 33 | 42 | }) |
| 34 | 43 | let port = '' |
| 35 | 44 | for (let attempt = 0; attempt < 400; attempt++) { |
| 45 | startup.tick() | |
| 36 | 46 | portProbe.attempts++ |
| 37 | 47 | port = await readFile(join(profile, 'DevToolsActivePort'), 'utf8').then(value => { portProbe.successfulReads++; portProbe.lastReadError = null; return value.split('\n')[0]! }).catch(error => { portProbe.failedReads++; portProbe.lastReadError = browserFileErrorCode(error); return '' }) |
| 38 | 48 | if (port) { portProbe.discovered = true; break } |
| 39 | 49 | await Bun.sleep(50) |
| 40 | 50 | } |
| 41 | if (!port) { closing = true; launch.cleanupRequested = true; if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('startup-unavailable'); throw new Error('Fresh browser must start') } | |
| 51 | await startup.finishDiscovery() | |
| 52 | if (!port) { await cleanup(); await retain('startup-unavailable'); throw new Error('Fresh browser must start') } | |
| 42 | 53 | // Keep a browser-level session, as well as page sessions, for the owned lifecycle. |
| 43 | 54 | let browserSocket: WebSocket |
| 44 | 55 | try { |
| 45 | 56 | const response = await observeBrowserOperation('version-fetch', () => fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) })) |
| 46 | const version = await observeBrowserOperation('version-json', () => response.json()) as { webSocketDebuggerUrl: string } | |
| 57 | const version = await observeBrowserOperation('version-json', () => response.json()) as { webSocketDebuggerUrl: string, Browser?: unknown } | |
| 58 | startup.recordVersion(version.Browser) | |
| 47 | 59 | browserSocket = await observeBrowserOperation('browser-socket', () => openSocket(version.webSocketDebuggerUrl)) |
| 48 | 60 | } |
| 49 | catch (error) { closing = true; launch.cleanupRequested = true; if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('discovery-failed'); throw error } | |
| 61 | catch (error) { await cleanup(); await retain('discovery-failed'); throw error } | |
| 50 | 62 | let browserSequence = 0 |
| 51 | 63 | browserSocket.onmessage = () => {} |
| 52 | 64 | const keepAlive = setInterval(() => { if (browserSocket.readyState === WebSocket.OPEN) browserSocket.send(JSON.stringify({ id: ++browserSequence, method: 'Browser.getVersion' })) }, 5000) |
| @@ -96,6 +108,6 @@ export async function startWorkspaceBrowser(root: string) { | ||
| 96 | 108 | await page.wait(javascript ? "document.querySelector('#incident-key') !== null && document.querySelector('[data-workspace-status]')?.textContent.trim() === 'disconnected'" : "document.querySelector('#incident-key') !== null", 'Compiled workspace must initialize') |
| 97 | 109 | return page |
| 98 | 110 | }, |
| 99 | async close() { console.log(`QA browser.close requested, elapsedMs:${Date.now() - started}`); closing = true; launch.cleanupRequested = true; clearInterval(keepAlive); browserSocket.close(); for (const page of pages) page.close(); if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('closed') }, | |
| 111 | async close() { console.log(`QA browser.close requested, elapsedMs:${Date.now() - started}`); clearInterval(keepAlive); browserSocket.close(); for (const page of pages) page.close(); await cleanup(); await retain('closed') }, | |
| 100 | 112 | } |
| 101 | 113 | } |
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | "./unit/controls.test.ts", |
| 24 | 24 | "../resources/functions/drone/cargo/**/*.ts", |
| 25 | 25 | "./unit/cargo-load.test.ts", |
| 26 | "./unit/cargo-intake.test.ts", | |
| 26 | 27 | "./unit/cargo-delivery.test.ts", |
| 27 | 28 | "./unit/cargo-delivery-config.test.ts", |
| 28 | 29 | "./unit/cargo-delivery-phase-faults.test.ts", |
| @@ -80,6 +81,7 @@ | ||
| 80 | 81 | "./unit/vehicle-mission-checkpoints.test.ts", |
| 81 | 82 | "./unit/vehicle-authority.test.ts", |
| 82 | 83 | "./unit/vehicle-runtime.test.ts", |
| 84 | "./unit/vehicle-sessions.test.ts", | |
| 83 | 85 | "./unit/multirotor-vehicle.test.ts", |
| 84 | 86 | "./unit/vehicle-links.test.ts", |
| 85 | 87 | "./unit/air.test.ts", |
| @@ -0,0 +1,167 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createReceivingRigidBody, createRigidBody } from '../../resources/functions/drone/controls/physics' | |
| 3 | import type { SensorVector } from '../../resources/functions/drone/sensors/types' | |
| 4 | import type { Quaternion } from '../../resources/functions/drone/estimation/frames' | |
| 5 | ||
| 6 | const plus = (a: SensorVector, b: SensorVector) => a.map((n, i) => n + b[i]!) as SensorVector | |
| 7 | const minus = (a: SensorVector, b: SensorVector) => a.map((n, i) => n - b[i]!) as SensorVector | |
| 8 | const scale = (a: SensorVector, n: number) => a.map(v => v * n) as SensorVector | |
| 9 | const cross = (a: SensorVector, b: SensorVector): SensorVector => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] | |
| 10 | const dot = (a: SensorVector, b: SensorVector) => a.reduce((sum, n, i) => sum + n * b[i]!, 0) | |
| 11 | function rotate([w, x, y, z]: Quaternion, v: SensorVector): SensorVector { | |
| 12 | return [[1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)], [2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)], [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)]].map(row => row.reduce((sum, n, i) => sum + n * v[i]!, 0)) as SensorVector | |
| 13 | } | |
| 14 | function closeVector(actual: SensorVector, expected: SensorVector) { expect(Math.max(...minus(actual, expected).map(Math.abs))).toBeLessThanOrEqual(2e-12) } | |
| 15 | function mechanical(state: ReturnType<ReturnType<typeof createReceivingRigidBody>['snapshot']>, parcel: { massKg: number, positionM: SensorVector, velocityMps: SensorVector }) { | |
| 16 | const origin: SensorVector = [7, -3, 5], inertia: SensorVector = [.03, .03, .05] | |
| 17 | const baseP = scale(state.velocityMps, 1.5), parcelP = scale(parcel.velocityMps, parcel.massKg) | |
| 18 | const spin = rotate(state.quaternionWxyz, state.angularRateRadS.map((n, i) => n * inertia[i]!) as SensorVector) | |
| 19 | return { momentum: plus(baseP, parcelP), angular: plus(spin, plus(cross(minus(state.positionM, origin), baseP), cross(minus(parcel.positionM, origin), parcelP))), | |
| 20 | kinetic: .75 * dot(state.velocityMps, state.velocityMps) + .5 * state.angularRateRadS.reduce((sum, n, i) => sum + inertia[i]! * n * n, 0) + .5 * parcel.massKg * dot(parcel.velocityMps, parcel.velocityMps), | |
| 21 | potential: -9.80665 * (1.5 * state.positionM[2] + parcel.massKg * parcel.positionM[2]) } | |
| 22 | } | |
| 23 | ||
| 24 | // Actual physics seam: proves point-load capture and state continuity, not a | |
| 25 | // mechanism acknowledgement, vehicle authority or fleet custody protocol. | |
| 26 | test('a receiving body captures one coincident point mass with the worked inelastic momentum result', () => { | |
| 27 | const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } }) | |
| 28 | expect(body.loadSnapshot().properties.phase).toBe('awaiting') | |
| 29 | expect(body.loadSnapshot().properties.totalMassKg).toBe(1.5) | |
| 30 | const before = body.snapshot(), point = body.intakePoint() | |
| 31 | expect(point.positionM).toEqual([.08, 0, -1]) | |
| 32 | const event = body.attachParcel({ id: 'parcel-a', massKg: .1, positionM: point.positionM, velocityMps: [0, 1, 0] }) | |
| 33 | expect(event.outcome).toBe('attached') | |
| 34 | if (event.outcome !== 'attached') throw new Error('Expected actual modeled capture') | |
| 35 | expect(event.before.properties.phase).toBe('awaiting') | |
| 36 | expect(event.after.properties.phase).toBe('attached') | |
| 37 | expect(event.after.properties.totalMassKg).toBe(1.6) | |
| 38 | expect(event.after.properties.cgBodyM).toEqual([.005, 0, 0]) | |
| 39 | // Rational expected values allow the binary64 accumulation error, not a model change. | |
| 40 | for (const [i, expected] of [.03, .0306, .0506].entries()) expect(event.after.properties.inertiaDiagonalKgM2[i]!).toBeCloseTo(expected, 14) | |
| 41 | expect(body.snapshot().angularRateRadS[2]).toBeCloseTo(.1482213438735178, 13) | |
| 42 | expect(body.snapshot().velocityMps[1]).toBeCloseTo(.06175889328063241, 13) | |
| 43 | expect(event.energy.kineticBeforeJ).toBeCloseTo(.05, 14) | |
| 44 | expect(event.energy.kineticAfterJ).toBeCloseTo(.003680830039525692, 14) | |
| 45 | expect(event.energy.dissipatedJ).toBeCloseTo(.04631916996047431, 14) | |
| 46 | expect(body.snapshot().positionM).toEqual(before.positionM) | |
| 47 | expect(body.snapshot().quaternionWxyz).toEqual(before.quaternionWxyz) | |
| 48 | expect(body.snapshot().motorsN).toEqual(before.motorsN) | |
| 49 | expect(body.attachParcel({ id: 'parcel-a', massKg: .1, positionM: point.positionM, velocityMps: [0, 1, 0] })).toEqual({ outcome: 'refused', reason: 'intake-consumed' }) | |
| 50 | const separated = body.separateCarriedLoad() | |
| 51 | expect(separated.outcome).toBe('separated') | |
| 52 | if (separated.outcome !== 'separated') throw new Error('Expected actual subsequent release') | |
| 53 | expect(mechanical(body.snapshot(), separated.parcel).kinetic).toBeCloseTo(.003680830039525692, 14) | |
| 54 | }) | |
| 55 | ||
| 56 | test('rotated captures conserve world momentum about an independent origin and dissipate relative motion', () => { | |
| 57 | for (const massKg of [.01, .1, .25]) for (const forwardM of [-.1, 0, .1]) { | |
| 58 | const body = createReceivingRigidBody({ positionM: [1, -2, -1], motorsN: [2, 2, 2, 2], expectedPointLoad: { id: 'parcel-a', massKg, forwardM } }) | |
| 59 | for (let i = 0; i < 40; i++) body.step([3, 2.9, 3.1, 3], .004, { windMps: [.2, -.1, 0], torqueNm: [.02, -.01, .03] }) | |
| 60 | const before = body.snapshot(), point = body.intakePoint() | |
| 61 | const parcel = { id: 'parcel-a', massKg, ...point, velocityMps: plus(point.velocityMps, [.2, -.3, .1]) } | |
| 62 | closeVector(point.positionM, plus(before.positionM, rotate(before.quaternionWxyz, [forwardM, 0, 0]))) | |
| 63 | const initial = mechanical(before, parcel), event = body.attachParcel(parcel) | |
| 64 | expect(event.outcome).toBe('attached') | |
| 65 | if (event.outcome !== 'attached') throw new Error('Expected rotated capture') | |
| 66 | const after = body.snapshot(), afterPoint = { massKg, positionM: plus(after.positionM, rotate(after.quaternionWxyz, [forwardM, 0, 0])), velocityMps: plus(after.velocityMps, rotate(after.quaternionWxyz, cross(after.angularRateRadS, [forwardM, 0, 0]))) } | |
| 67 | const final = mechanical(after, afterPoint) | |
| 68 | closeVector(final.momentum, initial.momentum) | |
| 69 | closeVector(final.angular, initial.angular) | |
| 70 | expect(Math.abs(final.potential - initial.potential)).toBeLessThanOrEqual(2e-12) | |
| 71 | expect(initial.kinetic - final.kinetic).toBeGreaterThan(0) | |
| 72 | expect(event.energy.kineticBeforeJ).toBeCloseTo(initial.kinetic, 12) | |
| 73 | expect(event.energy.kineticAfterJ).toBeCloseTo(final.kinetic, 12) | |
| 74 | expect(event.energy.dissipatedJ).toBeCloseTo(initial.kinetic - final.kinetic, 12) | |
| 75 | expect(event.energy.potentialBeforeJ).toBeCloseTo(initial.potential, 12) | |
| 76 | expect(event.energy.potentialAfterJ).toBeCloseTo(final.potential, 12) | |
| 77 | expect(after.positionM).toEqual(before.positionM) | |
| 78 | expect(after.quaternionWxyz).toEqual(before.quaternionWxyz) | |
| 79 | expect(after.motorsN).toEqual(before.motorsN) | |
| 80 | event.after.bodyVelocityMps[0] = 999; event.parcel.positionM[0] = 999 | |
| 81 | expect(body.snapshot()).toEqual(after) | |
| 82 | } | |
| 83 | }) | |
| 84 | ||
| 85 | test('co-moving intake preserves body motion through a later separation', () => { | |
| 86 | const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: -.08 } }) | |
| 87 | for (let i = 0; i < 30; i++) body.step([4, 3.8, 4.1, 4], .004, { torqueNm: [.01, -.02, .03] }) | |
| 88 | const before = body.snapshot(), parcel = { id: 'parcel-a', massKg: .1, ...body.intakePoint() }, event = body.attachParcel(parcel) | |
| 89 | expect(event.outcome).toBe('attached') | |
| 90 | if (event.outcome !== 'attached') throw new Error('Expected co-moving capture') | |
| 91 | closeVector(body.snapshot().velocityMps, before.velocityMps) | |
| 92 | closeVector(body.snapshot().angularRateRadS, before.angularRateRadS) | |
| 93 | expect(Math.abs(event.energy.dissipatedJ)).toBeLessThanOrEqual(2e-12) | |
| 94 | const separated = body.separateCarriedLoad() | |
| 95 | expect(separated.outcome).toBe('separated') | |
| 96 | if (separated.outcome !== 'separated') throw new Error('Expected subsequent separation') | |
| 97 | closeVector(separated.parcel.positionM, parcel.positionM) | |
| 98 | closeVector(separated.parcel.velocityMps, parcel.velocityMps) | |
| 99 | closeVector(body.snapshot().velocityMps, before.velocityMps) | |
| 100 | closeVector(body.snapshot().angularRateRadS, before.angularRateRadS) | |
| 101 | expect(body.loadSnapshot().properties.phase).toBe('separated') | |
| 102 | expect(body.loadSnapshot().properties.totalMassKg).toBe(1.5) | |
| 103 | expect(body.attachParcel(parcel)).toEqual({ outcome: 'refused', reason: 'intake-consumed' }) | |
| 104 | expect(body.separateCarriedLoad()).toEqual({ outcome: 'refused', reason: 'already-separated' }) | |
| 105 | }) | |
| 106 | ||
| 107 | test('the actual subsequent acceleration uses the received mass and the empty receiver has no phantom load', () => { | |
| 108 | for (const attached of [false, true]) { | |
| 109 | const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: 0 } }) | |
| 110 | if (attached) expect(body.attachParcel({ id: 'parcel-a', massKg: .1, ...body.intakePoint() }).outcome).toBe('attached') | |
| 111 | const state = body.step([4, 4, 4, 4], .004), mass = attached ? 1.6 : 1.5 | |
| 112 | const thrust = 16 * (1 - Math.exp(-.004 / .04)) | |
| 113 | expect(state.accelerationMps2[2]).toBeCloseTo(9.80665 - thrust / mass, 12) | |
| 114 | expect(body.loadSnapshot().properties.totalMassKg).toBe(mass) | |
| 115 | } | |
| 116 | }) | |
| 117 | ||
| 118 | test('intake refuses foreign or noncoincident parcels without consuming or moving the body', () => { | |
| 119 | const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } }) | |
| 120 | const before = body.snapshot(), point = { id: 'parcel-a', massKg: .1, ...body.intakePoint() } | |
| 121 | expect(body.separateCarriedLoad()).toEqual({ outcome: 'refused', reason: 'awaiting-load' }) | |
| 122 | for (const candidate of [{ ...point, id: 'parcel-b' }, { ...point, massKg: .2 }]) expect(body.attachParcel(candidate)).toEqual({ outcome: 'refused', reason: 'unexpected-parcel' }) | |
| 123 | expect(body.attachParcel({ ...point, positionM: [.081, 0, -1] })).toEqual({ outcome: 'refused', reason: 'mount-not-coincident' }) | |
| 124 | expect(body.snapshot()).toEqual(before) | |
| 125 | expect(body.loadSnapshot().properties.phase).toBe('awaiting') | |
| 126 | expect(body.attachParcel(point).outcome).toBe('attached') | |
| 127 | }) | |
| 128 | ||
| 129 | test('intake captures exact own data and malformed input cannot evaluate getters or mutate state', () => { | |
| 130 | const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: 0 } }) | |
| 131 | const before = body.snapshot(), point = { id: 'parcel-a', massKg: .1, ...body.intakePoint() } | |
| 132 | let called = false | |
| 133 | const accessor = { ...point, get velocityMps() { called = true; return [0, 0, 0] } } | |
| 134 | const tuple = [0, 0, 0]; Object.defineProperty(tuple, '0', { enumerable: true, get() { called = true; return 0 } }) | |
| 135 | for (const value of [null, { ...point, extra: true }, { ...point, massKg: NaN }, { ...point, velocityMps: [0, Infinity, 0] }, accessor, { ...point, velocityMps: tuple }, { ...point, positionM: [, 0, -1] }]) expect(() => body.attachParcel(value)).toThrow('cargo-intake-parcel') | |
| 136 | const proxy = new Proxy(point, { ownKeys() { throw new Error('private-caller-sentinel') } }) | |
| 137 | expect(() => body.attachParcel(proxy)).toThrow(/^cargo-intake-parcel$/) | |
| 138 | expect(() => Reflect.apply(body.attachParcel, body, [point, true])).toThrow('cargo-intake-parcel') | |
| 139 | expect(called).toBe(false) | |
| 140 | expect(body.snapshot()).toEqual(before) | |
| 141 | expect(body.loadSnapshot().properties.phase).toBe('awaiting') | |
| 142 | expect(() => createReceivingRigidBody({ expectedPointLoad: { get id() { called = true; return 'parcel-a' }, massKg: .1, forwardM: 0 } })).toThrow() | |
| 143 | expect(called).toBe(false) | |
| 144 | expect(body.attachParcel(point).outcome).toBe('attached') | |
| 145 | }) | |
| 146 | ||
| 147 | test('joint contact is current physical state and the planned identity is captured once', () => { | |
| 148 | const expected = { id: 'parcel-a', massKg: .1, forwardM: .08 } | |
| 149 | const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: expected }) | |
| 150 | const stale = { id: 'parcel-a', massKg: .1, ...body.intakePoint() } | |
| 151 | expected.id = 'parcel-b'; expected.massKg = .2; expected.forwardM = -.08 | |
| 152 | body.step([0, 0, 0, 0], .004) | |
| 153 | expect(body.attachParcel(stale)).toEqual({ outcome: 'refused', reason: 'mount-not-coincident' }) | |
| 154 | expect(body.loadSnapshot().properties.phase).toBe('awaiting') | |
| 155 | const current = { id: 'parcel-a', massKg: .1, ...body.intakePoint() } | |
| 156 | expect(body.attachParcel(current).outcome).toBe('attached') | |
| 157 | expect(body.loadSnapshot().properties.forwardM).toBe(.08) | |
| 158 | }) | |
| 159 | ||
| 160 | test('legacy body factories keep their original methods and attached/separated phases', () => { | |
| 161 | expect(Object.keys(createRigidBody())).toEqual(['snapshot', 'step']) | |
| 162 | const legacy = createRigidBody({ carriedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } }) | |
| 163 | expect(Object.keys(legacy)).toEqual(['snapshot', 'step', 'loadSnapshot', 'separateCarriedLoad']) | |
| 164 | expect(legacy.loadSnapshot().properties.phase).toBe('attached') | |
| 165 | expect(legacy.separateCarriedLoad().outcome).toBe('separated') | |
| 166 | expect(legacy.loadSnapshot().properties.phase).toBe('separated') | |
| 167 | }) | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from 'bun:test' |
| 2 | import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 2 | import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' | |
| 3 | 3 | import { tmpdir } from 'node:os' |
| 4 | 4 | import { join } from 'node:path' |
| 5 | 5 | import process from 'node:process' |
| @@ -25,6 +25,11 @@ test.skipIf(process.platform === 'win32')('failed native launch retains startup | ||
| 25 | 25 | expect(report.launch.cleanupRequested).toBe(true) |
| 26 | 26 | expect(report.portProbe.attempts).toBe(400) |
| 27 | 27 | expect(report.portProbe.lastReadError).toBe('ENOENT') |
| 28 | expect(report.startup.platform).toBe(process.platform) | |
| 29 | expect(report.startup.method).toBe('startup-only-process-observation-v1') | |
| 30 | expect(report.startup.snapshots.at(-1).label).toBe('cleanup-ended') | |
| 31 | expect(report.startup.snapshots.at(-1).root.pid).toBe(actualPid) | |
| 32 | expect(report.startup.snapshots.at(-1).root.probe).toBe('missing') | |
| 28 | 33 | expect(report.stderr.status).toBe('retained') |
| 29 | 34 | expect(report.stderr.retainedBytes).toBeLessThanOrEqual(65536) |
| 30 | 35 | expect(await readFile(join(root, 'chrome-startup-stderr.log'), 'utf8')).toContain('controlled native browser startup refusal') |
| @@ -46,3 +51,57 @@ test.skipIf(process.platform === 'win32')('failed native launch retains startup | ||
| 46 | 51 | else console.log(JSON.stringify({ evidence: 'controlled-native-launch', retainedRoot: root, passed })) |
| 47 | 52 | } |
| 48 | 53 | }, 30000) |
| 54 | ||
| 55 | // The existing native launch seam checks observed processes, not the cause of a Chrome failure. | |
| 56 | test.skipIf(process.platform === 'win32')('a live native launcher and child remain observable until cleanup without exporting argv or environment', async () => { | |
| 57 | const supplied = process.env.BROWSER_LAUNCH_LIVE_EVIDENCE | |
| 58 | const root = supplied ? (await mkdir(supplied), supplied) : await mkdtemp(join(tmpdir(), 'drone-browser-live-')) | |
| 59 | const executable = join(root, 'launch.ts'), pidPath = join(root, 'pids.json') | |
| 60 | const original = process.env.BROWSE_BROWSER | |
| 61 | let passed = false | |
| 62 | try { | |
| 63 | await writeFile(executable, `#!${process.execPath}\nimport { writeFileSync } from 'node:fs'\nconst child=Bun.spawn([process.execPath,'-e','setInterval(()=>{},1000)','PRIVATE_CHILD_ARG_SENTINEL'],{stdout:'ignore',stderr:'ignore',env:{...process.env,PRIVATE_FIXTURE_TOKEN:'PRIVATE_CHILD_ENV_SENTINEL'}})\nwriteFileSync(${JSON.stringify(pidPath)},JSON.stringify([process.pid,child.pid]))\nprocess.on('SIGTERM',async()=>{child.kill();await child.exited;process.exit(0)})\nconsole.error('controlled live browser startup fixture')\nsetInterval(()=>{},1000)\n`, { mode: 0o700 }) | |
| 64 | process.env.BROWSE_BROWSER = executable | |
| 65 | await expect(startWorkspaceBrowser(root)).rejects.toThrow('Fresh browser must start') | |
| 66 | const [pid, childPid] = JSON.parse(await readFile(pidPath, 'utf8')) as [number, number] | |
| 67 | const text = await readFile(join(root, 'browser-launch.json'), 'utf8'), report = JSON.parse(text) | |
| 68 | expect(text).not.toContain('PRIVATE_CHILD_ARG_SENTINEL') | |
| 69 | expect(text).not.toContain('PRIVATE_CHILD_ENV_SENTINEL') | |
| 70 | expect(report.portProbe.attempts).toBe(400) | |
| 71 | expect(report.launch.exitObservedBeforeCleanup).toBe(false) | |
| 72 | expect(report.launch.exitObserved).toBe(true) | |
| 73 | const before = report.startup.snapshots.find((s: any) => s.label === 'discovery-ended') | |
| 74 | expect(before.root.pid).toBe(pid) | |
| 75 | expect(before.root.probe).toBe('present') | |
| 76 | expect(report.startup.snapshots.at(-1).root.probe).toBe('missing') | |
| 77 | expect(report.startup.executables).toBeArray() | |
| 78 | if (process.platform === 'linux') { | |
| 79 | expect(before.linux.status).toBe('observed') | |
| 80 | expect(before.linux.root.startTicks).toMatch(/^\d+$/) | |
| 81 | expect(before.linux.root.rssKiB).toBeGreaterThan(0) | |
| 82 | expect(before.linux.descendants.some((p: any) => p.pid === childPid && p.parentPid === pid)).toBe(true) | |
| 83 | const identity = report.startup.executables.find((e: any) => e.observedPids.includes(pid) && e.sha256 !== null) | |
| 84 | expect(identity).toBeDefined() | |
| 85 | expect(identity.byteLength).toBe((await stat(process.execPath)).size) | |
| 86 | expect(identity.sha256).toBe(new Bun.CryptoHasher('sha256').update(await Bun.file(process.execPath).arrayBuffer()).digest('hex')) | |
| 87 | expect(identity.hashedAfterDiscovery).toBe(true) | |
| 88 | } | |
| 89 | else expect(before.linux.status).toBe('unsupported') | |
| 90 | expect(() => process.kill(pid, 0)).toThrow() | |
| 91 | expect(() => process.kill(childPid, 0)).toThrow() | |
| 92 | passed = true | |
| 93 | } | |
| 94 | finally { | |
| 95 | if (original === undefined) delete process.env.BROWSE_BROWSER | |
| 96 | else process.env.BROWSE_BROWSER = original | |
| 97 | const pids: number[] = JSON.parse(await readFile(pidPath, 'utf8').catch(() => '[]')) | |
| 98 | for (const pid of pids) { | |
| 99 | let gone = false | |
| 100 | try { process.kill(pid, 0) } | |
| 101 | catch (error) { gone = (error as NodeJS.ErrnoException).code === 'ESRCH' } | |
| 102 | expect(gone).toBe(true) | |
| 103 | } | |
| 104 | if (passed && !supplied) await rm(root, { recursive: true, force: true }) | |
| 105 | else console.log(JSON.stringify({ evidence: 'controlled-live-native-launch', retainedRoot: root, passed })) | |
| 106 | } | |
| 107 | }, 35000) | |
| @@ -0,0 +1,80 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdtemp, readlink, readdir, rm } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { createBrowserStartupObserver } from '../helpers/browser-startup-process' | |
| 6 | import type { StartupProcessAdapter } from '../helpers/browser-startup-process' | |
| 7 | import { createLinuxStartupObservation } from '../helpers/browser-startup-linux' | |
| 8 | ||
| 9 | // OS adapter seam: a held syscall promise checks ownership/cancellation, not Linux field acquisition. | |
| 10 | test('an expired observation remains owned through actual settlement and prevents later provider work', async () => { | |
| 11 | let release!: () => void | |
| 12 | const held = new Promise<void>(resolve => { release = resolve }) | |
| 13 | let calls = 0, active = 0, maximumActive = 0, finalized = false | |
| 14 | const settlements: boolean[] = [] | |
| 15 | const adapter: StartupProcessAdapter = { | |
| 16 | async snapshot() { | |
| 17 | calls++; active++; maximumActive = Math.max(maximumActive, active) | |
| 18 | try { if (calls === 1) await held; return { status: 'unavailable', code: 'CONTROLLED_OS' } } | |
| 19 | finally { active-- } | |
| 20 | }, | |
| 21 | async settleExecutables(hash) { expect(active).toBe(0); settlements.push(hash) }, | |
| 22 | evidence: () => ({ executables: [], identityErrors: [], executableLimitReached: false }), | |
| 23 | } | |
| 24 | const observer = createBrowserStartupObserver(process.pid, performance.now() - 1001, adapter) | |
| 25 | let finishing: Promise<void> | undefined | |
| 26 | try { | |
| 27 | observer.tick() | |
| 28 | await Bun.sleep(600) | |
| 29 | observer.tick() | |
| 30 | expect(calls).toBe(1) | |
| 31 | finishing = observer.finishDiscovery().then(() => { finalized = true }) | |
| 32 | await Bun.sleep(10) | |
| 33 | expect(finalized).toBe(false) | |
| 34 | expect(settlements).toEqual([]) | |
| 35 | release(); await finishing | |
| 36 | expect(maximumActive).toBe(1) | |
| 37 | expect(calls).toBe(1) | |
| 38 | expect(settlements).toEqual([false]) | |
| 39 | const final = observer.evidence() | |
| 40 | observer.tick(); await Bun.sleep(10) | |
| 41 | expect(observer.evidence()).toEqual(final) | |
| 42 | expect(final.snapshots[0]!.linux.status).toBe('timeout') | |
| 43 | expect(final.snapshots[0]!.completedAtMs).toBeGreaterThan(final.snapshots[0]!.elapsedMs + 500) | |
| 44 | } | |
| 45 | finally { release(); await finishing; await Bun.sleep(0) } | |
| 46 | }) | |
| 47 | ||
| 48 | test.skipIf(process.platform !== 'linux')('cancellation closes actual pinned executable descriptors without hashing after the held native sample settles', async () => { | |
| 49 | const root = await mkdtemp(join(tmpdir(), 'drone-observer-descriptors-')) | |
| 50 | const executable = await readlink('/proc/self/exe') | |
| 51 | const descriptors = async () => { | |
| 52 | const ids = await readdir('/proc/self/fd') | |
| 53 | const targets = await Promise.all(ids.map(id => readlink(`/proc/self/fd/${id}`).catch(() => null))) | |
| 54 | return targets.filter(path => path === executable).length | |
| 55 | } | |
| 56 | const before = await descriptors(), began = performance.now() | |
| 57 | const native = createLinuxStartupObservation(process.pid, root, began) | |
| 58 | let release!: () => void, ready!: () => void | |
| 59 | const held = new Promise<void>(resolve => { release = resolve }), captured = new Promise<void>(resolve => { ready = resolve }) | |
| 60 | const observer = createBrowserStartupObserver(process.pid, began, { | |
| 61 | async snapshot(signal) { | |
| 62 | const result = await native.snapshot(signal).catch(() => ({ status: 'unavailable' as const, code: 'NATIVE_UNAVAILABLE' })) | |
| 63 | ready(); await held; return result | |
| 64 | }, | |
| 65 | settleExecutables: hash => native.settleExecutables(hash), evidence: native.evidence, | |
| 66 | }) | |
| 67 | let finishing: Promise<void> | undefined | |
| 68 | try { | |
| 69 | observer.tick(); await captured | |
| 70 | expect(await descriptors()).toBeGreaterThan(before) | |
| 71 | await Bun.sleep(600) | |
| 72 | finishing = observer.finishDiscovery() | |
| 73 | release(); await finishing | |
| 74 | const report = observer.evidence() | |
| 75 | expect(report.executables.length).toBeGreaterThan(0) | |
| 76 | expect(report.executables.every(e => e.sha256 === null && e.code === 'OBSERVATION_CANCELED' && e.closeCode === null)).toBe(true) | |
| 77 | expect(await descriptors()).toBe(before) | |
| 78 | } | |
| 79 | finally { release(); await (finishing ?? observer.finishDiscovery()); await rm(root, { recursive: true, force: true }) } | |
| 80 | }) | |
| @@ -0,0 +1,370 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVehicleSimulationSession } from '../../resources/functions/drone/vehicles/runtime' | |
| 3 | import { createMultirotorProfile } from '../../resources/functions/drone/vehicles/multirotor/profile' | |
| 4 | import type { VehiclePayloadCommand } from '../../resources/functions/drone/vehicles/types' | |
| 5 | import type { SensorVector } from '../../resources/functions/drone/sensors/types' | |
| 6 | ||
| 7 | test('two vehicle sessions share policy epochs while their raw sensor and authority scopes remain distinct', () => { | |
| 8 | const a = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope: { bootId: 101n, sessionId: 201n } }) | |
| 9 | const b = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope: { bootId: 102n, sessionId: 202n } }) | |
| 10 | try { | |
| 11 | expect(() => a.result()).toThrow('still active') | |
| 12 | for (let nowMs = 0; nowMs <= 1000; nowMs += 4) { | |
| 13 | const left = a.step(nowMs), right = b.step(nowMs) | |
| 14 | expect(left.atMs).toBe(right.atMs) | |
| 15 | expect(left.board.bootId).toBe(101n) | |
| 16 | expect(right.board.bootId).toBe(102n) | |
| 17 | if (nowMs === 1000) { | |
| 18 | expect(left.reference.startsWith('101:')).toBe(true) | |
| 19 | expect(right.reference.startsWith('102:')).toBe(true) | |
| 20 | expect(left.authority.sessionId).toBe(201n) | |
| 21 | expect(right.authority.sessionId).toBe(202n) | |
| 22 | expect(left.authority.state).toBe('autonomous') | |
| 23 | expect(right.authority.state).toBe('autonomous') | |
| 24 | expect(left.board.actuators.some(n => n > 0)).toBe(true) | |
| 25 | expect(right.board.actuators.some(n => n > 0)).toBe(true) | |
| 26 | expect(left.policyAtMs).toBe(1000) | |
| 27 | expect(right.policyAtMs).toBe(1000) | |
| 28 | } | |
| 29 | } | |
| 30 | } | |
| 31 | finally { a.close(); b.close() } | |
| 32 | expect(a.result().board.actuators).toEqual([0, 0, 0, 0]) | |
| 33 | expect(b.result().board.actuators).toEqual([0, 0, 0, 0]) | |
| 34 | }) | |
| 35 | ||
| 36 | test('one vehicle can lose its host and settle independently while the peer continues fresh policy', () => { | |
| 37 | const profile = createMultirotorProfile(), original = profile.faults | |
| 38 | profile.faults = (scenario, atMs, phase) => ({ ...original(scenario, atMs, phase), hostLost: atMs >= 740 }) | |
| 39 | const a = createVehicleSimulationSession(profile, { seconds: 2, neutralTailMs: 1000, scope: { bootId: 101n, sessionId: 201n } }) | |
| 40 | const b = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope: { bootId: 102n, sessionId: 202n } }) | |
| 41 | try { | |
| 42 | for (let nowMs = 0; nowMs <= 1840; nowMs += 4) { | |
| 43 | const left = a.step(nowMs), right = b.step(nowMs) | |
| 44 | if (nowMs >= 844) { | |
| 45 | expect(left.phase).toBe('independent-tail') | |
| 46 | expect(left.hostAvailable).toBe(false) | |
| 47 | expect(left.policyAtMs).toBe(720) | |
| 48 | expect(left.board.actuators).toEqual([0, 0, 0, 0]) | |
| 49 | } | |
| 50 | if (nowMs === 1840) { | |
| 51 | expect(left.terminal).toBe(true) | |
| 52 | expect(right.terminal).toBe(false) | |
| 53 | expect(right.hostAvailable).toBe(true) | |
| 54 | expect(right.policyAtMs).toBe(1840) | |
| 55 | expect(right.authority.state).toBe('autonomous') | |
| 56 | expect(right.board.actuators.some(n => n > 0)).toBe(true) | |
| 57 | } | |
| 58 | } | |
| 59 | const run = a.result() | |
| 60 | expect(run.status).toBe('host-lost') | |
| 61 | expect(run.board.fault).toBe('output-lease-expired') | |
| 62 | expect(run.path.at(-1)!.atMs).toBe(720) | |
| 63 | expect(run.path.at(-1)!.measurement!.reference.startsWith('101:')).toBe(true) | |
| 64 | expect(() => a.step(1844)).toThrow('closed') | |
| 65 | } | |
| 66 | finally { a.close(); b.close() } | |
| 67 | }) | |
| 68 | ||
| 69 | test('vehicle sessions reject invalid scope before allocation and invalid epochs without advancing state', () => { | |
| 70 | const profile = createMultirotorProfile() | |
| 71 | let opened = false, getterCalled = false | |
| 72 | profile.createSession = () => { opened = true; throw new Error('Unexpected allocation') } | |
| 73 | for (const scope of [null, { bootId: 0n, sessionId: 1n }, { bootId: 1n, sessionId: 0x10000000000000000n }, { bootId: 1, sessionId: 2n }, { get bootId() { getterCalled = true; return 1n }, sessionId: 2n }]) { | |
| 74 | expect(() => createVehicleSimulationSession(profile, { scope: scope as never })).toThrow('scope') | |
| 75 | } | |
| 76 | expect(opened).toBe(false) | |
| 77 | expect(getterCalled).toBe(false) | |
| 78 | const scope = { bootId: 101n, sessionId: 201n }, session = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope }) | |
| 79 | scope.bootId = 999n; scope.sessionId = 999n | |
| 80 | try { | |
| 81 | expect(() => session.step(4)).toThrow('next 4 ms epoch') | |
| 82 | expect(session.step(0).board.bootId).toBe(101n) | |
| 83 | expect(() => session.step(0)).toThrow('next 4 ms epoch') | |
| 84 | expect(() => session.step(8)).toThrow('next 4 ms epoch') | |
| 85 | expect(session.step(4).atMs).toBe(4) | |
| 86 | } | |
| 87 | finally { session.close() } | |
| 88 | const result = session.result() | |
| 89 | expect(result.status).toBe('blocked') | |
| 90 | expect(result.fault).toBe('simulation-session-closed') | |
| 91 | result.board.actuators[0] = 99 | |
| 92 | expect(session.result().board.actuators).toEqual([0, 0, 0, 0]) | |
| 93 | }) | |
| 94 | ||
| 95 | test('a step failure and explicit close both release payload ownership once and revoke the board', () => { | |
| 96 | for (const fail of [false, true]) { | |
| 97 | const profile = createMultirotorProfile(), create = profile.createSession | |
| 98 | let closes = 0 | |
| 99 | profile.createSession = () => { | |
| 100 | const base = create() | |
| 101 | return { ...base, renderMeasurement(...args) { if (fail && args[1] === 1000) throw new Error('Captured renderer failure'); return base.renderMeasurement(...args) }, | |
| 102 | createPayload: () => ({ request() {}, poll() { return null }, close() { closes++ } }), | |
| 103 | } | |
| 104 | } | |
| 105 | const session = createVehicleSimulationSession(profile, { seconds: 2 }) | |
| 106 | try { | |
| 107 | for (let nowMs = 0; nowMs < 1000; nowMs += 4) session.step(nowMs) | |
| 108 | if (fail) expect(() => session.step(1000)).toThrow('Captured renderer failure') | |
| 109 | } | |
| 110 | finally { session.close(); session.close() } | |
| 111 | expect(closes).toBe(1) | |
| 112 | expect(session.result().board.actuators).toEqual([0, 0, 0, 0]) | |
| 113 | expect(session.result().board.expiresAtMs).toBeNull() | |
| 114 | expect(session.result().fault).toBe(fail ? 'simulation-step-failed' : 'simulation-session-closed') | |
| 115 | expect(() => session.step(1004)).toThrow('closed') | |
| 116 | } | |
| 117 | }) | |
| 118 | ||
| 119 | test('a simultaneous step and teardown failure is retained after the independent board is revoked', () => { | |
| 120 | const profile = createMultirotorProfile(), create = profile.createSession | |
| 121 | let closes = 0 | |
| 122 | profile.createSession = () => { | |
| 123 | const base = create() | |
| 124 | return { ...base, renderMeasurement(...args) { if (args[1] === 1000) throw new Error('Renderer failed'); return base.renderMeasurement(...args) }, | |
| 125 | createPayload: () => ({ request() {}, poll() { return null }, close() { closes++; throw new Error('Payload teardown failed') } }), | |
| 126 | } | |
| 127 | } | |
| 128 | const session = createVehicleSimulationSession(profile, { seconds: 2 }) | |
| 129 | for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs) | |
| 130 | let failure: unknown | |
| 131 | try { session.step(1000) } | |
| 132 | catch (error) { failure = error } | |
| 133 | expect(failure).toBeInstanceOf(AggregateError) | |
| 134 | expect((failure as AggregateError).errors.map(error => error.message)).toEqual(['Renderer failed', 'Payload teardown failed']) | |
| 135 | session.close() | |
| 136 | expect(closes).toBe(1) | |
| 137 | expect(session.result().board.actuators).toEqual([0, 0, 0, 0]) | |
| 138 | expect(session.result().board.expiresAtMs).toBeNull() | |
| 139 | }) | |
| 140 | ||
| 141 | test('a controller callback cannot reopen the board lease after closing its own session', () => { | |
| 142 | const profile = createMultirotorProfile(), create = profile.createSession | |
| 143 | let session: ReturnType<typeof createVehicleSimulationSession>, returnedNonzero = false | |
| 144 | profile.createSession = () => { | |
| 145 | const base = create() | |
| 146 | return { ...base, createController() { | |
| 147 | const controller = base.createController() | |
| 148 | return { step(context) { | |
| 149 | const output = controller.step(context) | |
| 150 | if (context.nowMs === 1000) { returnedNonzero = output.actuators.some(n => n > 0); session.close() } | |
| 151 | return output | |
| 152 | } } | |
| 153 | } } | |
| 154 | } | |
| 155 | session = createVehicleSimulationSession(profile, { seconds: 2 }) | |
| 156 | try { | |
| 157 | for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs) | |
| 158 | const tick = session.step(1000) | |
| 159 | expect(returnedNonzero).toBe(true) | |
| 160 | expect(tick.terminal).toBe(true) | |
| 161 | expect(tick.hostAvailable).toBe(false) | |
| 162 | expect(tick.policyAtMs).toBe(980) | |
| 163 | expect(tick.board.actuators).toEqual([0, 0, 0, 0]) | |
| 164 | expect(tick.board.expiresAtMs).toBeNull() | |
| 165 | expect(session.result().board.actuators).toEqual([0, 0, 0, 0]) | |
| 166 | expect(session.result().fault).toBe('simulation-session-closed') | |
| 167 | } | |
| 168 | finally { session.close() } | |
| 169 | }) | |
| 170 | ||
| 171 | test('a controller cannot recursively advance the same in-flight session epoch', () => { | |
| 172 | const profile = createMultirotorProfile(), create = profile.createSession | |
| 173 | let session: ReturnType<typeof createVehicleSimulationSession>, attempted = false | |
| 174 | profile.createSession = () => { | |
| 175 | const base = create() | |
| 176 | return { ...base, createController() { | |
| 177 | const controller = base.createController() | |
| 178 | return { step(context) { | |
| 179 | if (context.nowMs === 1000 && !attempted) { | |
| 180 | attempted = true | |
| 181 | expect(() => session.step(1000)).toThrow('already stepping') | |
| 182 | } | |
| 183 | return controller.step(context) | |
| 184 | } } | |
| 185 | } } | |
| 186 | } | |
| 187 | session = createVehicleSimulationSession(profile, { seconds: 2 }) | |
| 188 | try { | |
| 189 | for (let atMs = 0; atMs <= 1000; atMs += 4) session.step(atMs) | |
| 190 | expect(attempted).toBe(true) | |
| 191 | expect(session.step(1004).atMs).toBe(1004) | |
| 192 | } | |
| 193 | finally { session.close() } | |
| 194 | }) | |
| 195 | ||
| 196 | test('closing from a payload observation prevents later polling and controller work in the same epoch', () => { | |
| 197 | const profile = createMultirotorProfile(), create = profile.createSession | |
| 198 | let session: ReturnType<typeof createVehicleSimulationSession>, closes = 0, workAfterClose = 0 | |
| 199 | profile.createSession = () => { | |
| 200 | const base = create() | |
| 201 | return { ...base, createController() { | |
| 202 | const controller = base.createController() | |
| 203 | return { step(context) { if (closes) workAfterClose++; return controller.step(context) } } | |
| 204 | }, createPayload: () => ({ | |
| 205 | observe(context) { if (context.nowMs === 1000) session.close() }, | |
| 206 | request() { if (closes) workAfterClose++ }, | |
| 207 | poll() { if (closes) workAfterClose++; return null }, | |
| 208 | close() { closes++ }, | |
| 209 | }) } | |
| 210 | } | |
| 211 | session = createVehicleSimulationSession(profile, { seconds: 2 }) | |
| 212 | try { | |
| 213 | for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs) | |
| 214 | const tick = session.step(1000) | |
| 215 | expect(tick.terminal).toBe(true) | |
| 216 | expect(tick.policyAtMs).toBe(980) | |
| 217 | expect(tick.hostAvailable).toBe(false) | |
| 218 | expect(workAfterClose).toBe(0) | |
| 219 | expect(closes).toBe(1) | |
| 220 | expect(tick.board.actuators).toEqual([0, 0, 0, 0]) | |
| 221 | expect(tick.board.expiresAtMs).toBeNull() | |
| 222 | } | |
| 223 | finally { session.close() } | |
| 224 | }) | |
| 225 | ||
| 226 | test('a closing link callback failure is surfaced without calling the closed payload', () => { | |
| 227 | const profile = createMultirotorProfile(), create = profile.createSession | |
| 228 | let session: ReturnType<typeof createVehicleSimulationSession>, closes = 0, observationsAfterClose = 0 | |
| 229 | profile.createSession = () => ({ ...create(), | |
| 230 | linkObservation(nowMs) { | |
| 231 | if (nowMs === 1000) { session.close(); throw new Error('Link callback failed after close') } | |
| 232 | return { capturedAtMs: nowMs, connected: true } | |
| 233 | }, | |
| 234 | createPayload: () => ({ observe() { if (closes) observationsAfterClose++ }, request() {}, poll() { return null }, close() { closes++ } }), | |
| 235 | }) | |
| 236 | session = createVehicleSimulationSession(profile, { seconds: 2 }) | |
| 237 | try { | |
| 238 | for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs) | |
| 239 | expect(() => session.step(1000)).toThrow('Link callback failed after close') | |
| 240 | expect(observationsAfterClose).toBe(0) | |
| 241 | expect(closes).toBe(1) | |
| 242 | expect(session.result().board.actuators).toEqual([0, 0, 0, 0]) | |
| 243 | } | |
| 244 | finally { session.close() } | |
| 245 | }) | |
| 246 | ||
| 247 | test('a returned actuator getter cannot reopen output when capture closes its session', () => { | |
| 248 | const profile = createMultirotorProfile(), create = profile.createSession | |
| 249 | let session: ReturnType<typeof createVehicleSimulationSession>, getterCalled = false | |
| 250 | profile.createSession = () => { | |
| 251 | const base = create() | |
| 252 | return { ...base, createController() { | |
| 253 | const controller = base.createController() | |
| 254 | return { step(context) { | |
| 255 | const output = controller.step(context) | |
| 256 | if (context.nowMs === 1000) { | |
| 257 | const actuators = [...output.actuators] | |
| 258 | Object.defineProperty(output, 'actuators', { enumerable: true, get() { getterCalled = true; session.close(); return actuators } }) | |
| 259 | } | |
| 260 | return output | |
| 261 | } } | |
| 262 | } } | |
| 263 | } | |
| 264 | session = createVehicleSimulationSession(profile, { seconds: 2 }) | |
| 265 | try { | |
| 266 | for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs) | |
| 267 | const tick = session.step(1000) | |
| 268 | expect(getterCalled).toBe(true) | |
| 269 | expect(tick.terminal).toBe(true) | |
| 270 | expect(tick.board.actuators).toEqual([0, 0, 0, 0]) | |
| 271 | expect(tick.board.expiresAtMs).toBeNull() | |
| 272 | expect(tick.policyAtMs).toBe(980) | |
| 273 | } | |
| 274 | finally { session.close() } | |
| 275 | }) | |
| 276 | ||
| 277 | test('a returned ACK getter cannot confirm payload work after closing its session', () => { | |
| 278 | const profile = createMultirotorProfile(), create = profile.createSession | |
| 279 | profile.mission.tasks = [{ id: 'start', kind: 'start', next: 'capture' }, { id: 'capture', kind: 'payload', operation: 'camera-still', containerId: null, timeoutMs: 1500, next: 'depart' }, ...profile.mission.tasks.filter(task => task.kind !== 'start')] | |
| 280 | let session: ReturnType<typeof createVehicleSimulationSession>, pending: VehiclePayloadCommand | null = null, getterCalled = false | |
| 281 | profile.createSession = () => ({ ...create(), createPayload: () => ({ | |
| 282 | request(command) { pending = structuredClone(command) }, | |
| 283 | poll(nowMs) { | |
| 284 | if (!pending || nowMs <= pending.issuedAtMs) return null | |
| 285 | const command = pending | |
| 286 | return { eventId: command.eventId, taskId: command.taskId, operation: command.operation, containerId: command.containerId, reference: command.reference, | |
| 287 | capturedAtMs: nowMs, outcome: 'confirmed', metadata: {}, get positionM(): SensorVector { getterCalled = true; session.close(); return [...command.positionM] } } | |
| 288 | }, | |
| 289 | close() { pending = null }, | |
| 290 | }) }) | |
| 291 | session = createVehicleSimulationSession(profile, { seconds: 12 }) | |
| 292 | try { | |
| 293 | for (let atMs = 0; ; atMs += 4) { if (session.step(atMs).terminal) break } | |
| 294 | expect(getterCalled).toBe(true) | |
| 295 | expect(session.result().trace.rows.some(row => row.result.reason === 'payload-confirmed')).toBe(false) | |
| 296 | expect(session.result().fault).toBe('simulation-session-closed') | |
| 297 | expect(session.result().board.actuators).toEqual([0, 0, 0, 0]) | |
| 298 | } | |
| 299 | finally { session.close() } | |
| 300 | }) | |
| 301 | ||
| 302 | test('a returned fault getter cannot advance the physical plant after closing its session', () => { | |
| 303 | const profile = createMultirotorProfile(), create = profile.createSession, faults = profile.faults | |
| 304 | let session: ReturnType<typeof createVehicleSimulationSession>, closed = false, stepsAfterClose = 0, getterCalled = false | |
| 305 | profile.faults = (scenario, atMs, phase) => atMs !== 1000 ? faults(scenario, atMs, phase) : { | |
| 306 | get windMps(): SensorVector { getterCalled = true; closed = true; session.close(); return [0, 0, 0] }, | |
| 307 | } | |
| 308 | profile.createSession = () => { | |
| 309 | const base = create() | |
| 310 | return { ...base, createPlant() { | |
| 311 | const plant = base.createPlant() | |
| 312 | return { snapshot: plant.snapshot, step(...args) { if (closed) stepsAfterClose++; return plant.step(...args) } } | |
| 313 | } } | |
| 314 | } | |
| 315 | session = createVehicleSimulationSession(profile, { seconds: 2 }) | |
| 316 | try { | |
| 317 | for (let atMs = 0; atMs <= 1000; atMs += 4) session.step(atMs) | |
| 318 | expect(getterCalled).toBe(true) | |
| 319 | expect(stepsAfterClose).toBe(0) | |
| 320 | expect(session.result().fault).toBe('simulation-session-closed') | |
| 321 | expect(session.result().board.actuators).toEqual([0, 0, 0, 0]) | |
| 322 | } | |
| 323 | finally { session.close() } | |
| 324 | }) | |
| 325 | ||
| 326 | test('the initial plant snapshot is captured before a live session can close through its getters', () => { | |
| 327 | const profile = createMultirotorProfile(), create = profile.createSession | |
| 328 | let session: ReturnType<typeof createVehicleSimulationSession> | undefined, readsAfterExposure = 0, callbacksAfterClose = 0, closed = false | |
| 329 | profile.createSession = () => { | |
| 330 | const base = create() | |
| 331 | return { ...base, createPlant() { | |
| 332 | const plant = base.createPlant() | |
| 333 | return { step: plant.step, snapshot() { | |
| 334 | const state = plant.snapshot() | |
| 335 | return { ...state, get details() { if (session) { readsAfterExposure++; closed = true; session.close() } return state.details } } | |
| 336 | } } | |
| 337 | }, observeIndependent() { if (closed) callbacksAfterClose++ } } | |
| 338 | } | |
| 339 | session = createVehicleSimulationSession(profile, { seconds: 2 }) | |
| 340 | try { | |
| 341 | for (let atMs = 0; atMs <= 1000; atMs += 4) { if (session.step(atMs).terminal) break } | |
| 342 | expect(readsAfterExposure).toBe(0) | |
| 343 | expect(callbacksAfterClose).toBe(0) | |
| 344 | expect(() => session!.result()).toThrow('still active') | |
| 345 | } | |
| 346 | finally { session.close() } | |
| 347 | }) | |
| 348 | ||
| 349 | test('adapter methods are bound before exposure so method getters cannot close then invoke work', () => { | |
| 350 | const profile = createMultirotorProfile(), create = profile.createSession | |
| 351 | let session: ReturnType<typeof createVehicleSimulationSession> | undefined, readsAfterExposure = 0, callbacksAfterClose = 0, closed = false | |
| 352 | profile.createSession = () => { | |
| 353 | const base = create() | |
| 354 | return { ...base, createController() { | |
| 355 | const controller = base.createController() | |
| 356 | return { get step() { | |
| 357 | if (session) { readsAfterExposure++; closed = true; session.close() } | |
| 358 | return (context: Parameters<typeof controller.step>[0]) => { if (closed) callbacksAfterClose++; return controller.step(context) } | |
| 359 | } } | |
| 360 | } } | |
| 361 | } | |
| 362 | session = createVehicleSimulationSession(profile, { seconds: 2 }) | |
| 363 | try { | |
| 364 | for (let atMs = 0; atMs <= 1000; atMs += 4) { if (session.step(atMs).terminal) break } | |
| 365 | expect(readsAfterExposure).toBe(0) | |
| 366 | expect(callbacksAfterClose).toBe(0) | |
| 367 | expect(() => session!.result()).toThrow('still active') | |
| 368 | } | |
| 369 | finally { session.close() } | |
| 370 | }) | |