preview lifecycle - #929
Conversation
🦋 Changeset detectedLatest commit: 914aa57 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
08bd378 to
adeb920
Compare
adeb920 to
b539db1
Compare
b539db1 to
d933828
Compare
|
d933828 to
3be06ca
Compare
3be06ca to
dd12cc1
Compare
dd12cc1 to
768af27
Compare
768af27 to
723a7c3
Compare
723a7c3 to
013ad0a
Compare
013ad0a to
67739d9
Compare
67739d9 to
647f56f
Compare
914aa57 to
15fd290
Compare
|
Claude (@claude) review |
|
Claude finished Devin T. Currie (@DTCurrie)'s task in 4m 51s —— View job Review
The lifecycle design is solid. The generation + abort dual-guard is correct and well-motivated: abort stops the machine work, generation discards a settled promise that beat the abort. The CorrectnessTeardown asymmetry — the cleanup
Tests
Nits
|
| expect(h.pending[0]!.signal?.aborted).toBe(true) | ||
| }) | ||
|
|
||
| it('keeps a discarded request’s failure off the panel', async () => { |
There was a problem hiding this comment.
Typographic apostrophe (curly ') in the test description. This is valid JS but may cause trouble if copy-pasted into a terminal or matched with it.only.
| it('keeps a discarded request’s failure off the panel', async () => { | |
| it("keeps a discarded request's failure off the panel", async () => { |
|
|
||
| // `$effect` cleanup rather than `onDestroy`, matching `useMoveGhosts`: it is the same teardown, | ||
| // and it does not need a component around it — which is what lets a spec drive this hook. | ||
| $effect(() => () => { |
There was a problem hiding this comment.
The cleanup does not reset trajectory, playbackFrames, message, or status. That is correct — once the $effect.root is destroyed nothing can observe them — but it means teardown is not symmetric with resetPreview. If the cleanup responsibilities ever widen (e.g. a ref-counted world), resetPreview() here would be safer than another diverging copy of the teardown sequence.
|
|
||
| const result = parsePlanResult(response) | ||
| if (isAlreadyAtGoal(result.trajectory)) { | ||
| settle('already-at-goal', `"${frameName()}" is already at the target.`) |
There was a problem hiding this comment.
frameName() is read after the await. If frameName changes after the plan was sent but is not included in invalidateOn, the already-at-goal message names the current frame rather than the one the plan was computed for. The generation guard only fires if invalidateOn changes, not frameName independently.
This is a caller-contract issue rather than a hook bug, and is worth documenting on invalidateOn:
/**
* Every input the plan was computed from, not just the goal: world state, constraints, service,
* frame system, **and frame name**. A change to any discards the plan rather than leaving a ghost
* of a stale problem.
*/
invalidateOn: () => unknown| let playbackFrames = $state.raw<TrajectoryStep[]>([]) | ||
|
|
||
| // Playback covers `playbackFrames.length - 1` transitions, so that is what the duration divides. | ||
| const frameIntervalMs = $derived( |
There was a problem hiding this comment.
The Math.max(1, ...) guard is needed because playbackFrames starts at [] and 0 - 1 = -1 would make PREVIEW_DURATION_MS / -1 negative, passing straight through Math.max(MIN_FRAME_MS, ...). Worth a brief comment since the shape of the guard (length - 1) makes it easy to miss:
| const frameIntervalMs = $derived( | |
| // `length - 1` counts transitions, not frames. Guard against 0 or 1 frames (denominator ≤ 0). | |
| Math.max(MIN_FRAME_MS, PREVIEW_DURATION_MS / Math.max(1, playbackFrames.length - 1)) |
| h.pending[0]!.resolve(PLAN_REPLY) | ||
| await done | ||
|
|
||
| expect(h.preview.player.totalSteps).toBe(2) |
There was a problem hiding this comment.
h.preview.trajectory is TrajectoryStep[] but PLAN_REPLY.plan is JsonValue[]. The assertion works today because the values happen to match structurally, but toEqual will fail if parsePlanResult ever wraps or transforms the steps. Consider asserting trajectory against the expected typed value rather than the raw fixture:
| }) | ||
| }) | ||
|
|
||
| const client = { doCommand } as unknown as MotionClient |
There was a problem hiding this comment.
as unknown as MotionClient sidesteps the type checker here, which is understandable since only doCommand is needed. The doCommand variable above it is typed to MotionClient['doCommand'], which catches signature drift, but the cast still suppresses any MotionClient required properties added later. A minimal structural type ({ doCommand: MotionClient['doCommand'] }) would catch that without needing the full client shape.
15fd290 to
0b594fe
Compare
0b594fe to
986d9fd
Compare
Adds
usePreviewMove, the hook that runs a move preview end to end: send the builtin motion service'splanDoCommand, rebuild the kinematics fromframeSystemConfig, spawn the ghost set, and drive it from aTrajectoryPlayer. Stacks on #928. Nothing mounts it yet; the panel in #908 is the consumer.The lifecycle is where this feature's ordering hazards live, so it lands on its own rather than inside the panel. A spec that calls the hook directly can suspend a request at its
awaitand move the goal, swap the frame system, or close the panel underneath it, which is not something a rendered panel makes reachable.Stack
$lib/motion(make motion utils reusable #917)MoveFrameplugin (Motion plan preview #908)Frontend
usePreviewMoveowns the whole sequence: reset, request,parsePlanResult,frameSystemToPlanFramesintobuildFrameDescriptors,createForwardKinematics,spawnPreviewGhosts, render step 0, and exposestatus,message,trajectory,plannedStepsandplayer.worldandframesas arguments rather than callinguseWorld()anduseFrames(), and tears down in an$effectcleanup rather thanonDestroy, matchinguseMoveGhosts.resetPreviewis the single teardown path: bumpgeneration, abortinFlight,clearPreviewGhosts, drop both step arrays,player.reset(). Its three callers (clear,settle, and the top ofrequestPreview) differ only in thestatusandmessagethey leave behind.PreviewStatusseparatesalready-at-goalfromerror, and the two failure branches carry distinct messages: no descriptors at all versus descriptors with nothing a ghost can be made of.requestPreviewreturns without touchingstatuswhen the client, the service name or the staged goal is missing. The caller is expected to gate the action on all three; nothing has failed.ghostsis a plainMapheld outside$state, filled in place byspawnPreviewGhosts, so it stays the one handle teardown has across theawait.trajectoryandplaybackFramesare$state.raw: they are replaced wholesale and onlyplaybackFrames.lengthis read reactively.frameIntervalMsdividesPREVIEW_DURATION_MSbyplaybackFrames.length - 1and floors the result atMIN_FRAME_MS.useFrames.svelte.tspublishes the rawframeSystemConfigreply asFramesContext.parts, and exports theFramesContextinterface so the hook can name what it is handed.Why?
Why both an
AbortControllerand a generation counter?They cover different halves of the same race.
inFlight.abort()stops the RPC, which is what you want once the user has dragged the gizmo somewhere else: planning is not cheap on the machine, and a plan for an abandoned goal is work nobody will look at. What abort cannot cover is a response that resolved before it landed. Aborting does nothing to a promise that has already settled, so the continuation past theawaitstill runs, and every line after it writes the state a panel armsExecute previewfrom.generationis what that continuation checks.resetPreviewbumps it,requestPreviewcaptures it intominebefore the request goes out, and the success path returns early when the two no longer match. Without it,clear()leavesstatusatidleand then a late resolution walks it up toready, so the panel goes from nothing pending to offering a plan for a goal the user has already replaced, with an execute button behind it.The
catchcarries the same guard, for a reason of its own: an aborted request rejects, and reporting a cancellation the user caused as a failed plan is worse than saying nothing. The same is true of a genuine planning failure that belongs to a goal nobody is asking about any more.Why is the frame system read before the
awaitrather than after?frames.partsis a getter over a query that refetches on every config revision, so the frame system really can be replaced while a plan is in flight. Reading it afterwards builds the descriptors from kinematics the plan was never computed against. Nothing throws: forward kinematics runs, the ghosts appear, and they stand somewhere the machine has never been, with nothing on screen to say the two halves disagree. Reading it alongsideworldStateandconstraintsmakes the request's whole input set one snapshot.Discarding a plan when the frame system changes is the caller's job, not the hook's, which is why
invalidateOnis documented as every input the plan was computed from and not just the goal.Why does
FramesContextgrow apartsfield when it already exposescurrent?currentisTransform[]: the flattened frames, each with a pose, a parent and a physical object, and nokinematicsanywhere. Running forward kinematics in the browser needs the model JSON, andFrameSystemConfig.kinematicson the raw reply is the only place it survives. That is the same routeframeSystemToPlanFrameswas written against in #923.The field carries a caveat worth knowing before anything else reads it: the query is disabled in build mode and a disabled query keeps its last data, so a non-empty
partsdoes not mean live.currentcan meanwhile have merged in or fallen back to config frames. The two can disagree, andpartsis the one that has not been merged with anything.Why is an empty ghost set its own error rather than folded into the empty-descriptor one?
Because they fail for different reasons and only one of them is about the frame system being missing.
spawnPreviewGhostsghosts a descriptor only when it is static, carries geometry, is not hidden, and hangs under a joint this plan actually moves. Joints and geometry-less mounts are most of a descriptor set, so a frame system can produce a hundred descriptors and no ghosts at all. Checking onlydescriptors.lengthwould reportreadywith a live scrubber and an armed execute button over an empty scene, and the message would blame a frame system that is plainly there.Why is "already at the target" not an error?
Because RDK answered, and answered correctly. It seeds a trajectory with the start configuration, so a satisfied goal comes back as two bit-identical steps rather than as an empty plan or a failure. Drawing it would be a scrub between two identical configurations. Giving it its own status lets the panel present it as information, and
settleruns the same reset behind it, sotrajectoryis empty and there is nothing to execute.Why keep
trajectoryandplaybackFramesapart when they hold the same steps?trajectoryis what the planner said and the only thingexecutemay ever be handed.playbackFramesis what the scrubber walks. TodayapplyPlaybackcallswaypointFrames, which hands back the planned array itself, so the separation buys nothing observable. It stops being free at #930, whereinterpolatedFramesreplaces it andplaybackFramesstarts holding configurations RDK never planned. Executing those would ask the machine to run through poses no planner checked. Splitting the fields now makes that swap a one-line change inapplyPlaybackwith no way for the interpolated frames to reachexecute.Why does playback pace off a duration rather than a frame rate?
A trajectory carries no timing at all. RDK returns joint configurations and says nothing about how long the move takes, so no frame rate is more correct than any other. "The whole preview takes about four seconds" is at least a consistent claim, and it keeps a two-waypoint plan and a two-hundred-waypoint one comparable to watch.
MIN_FRAME_MSfloors the interval at 16 ms, so a very dense plan runs longer than the target instead of asking a display for frames it cannot show. The divisor isplaybackFrames.length - 1because playback covers transitions, not frames.Testing
pnpm exec vitest --runpasses 1096 tests across 83 files, up 18 tests and one file from the base branch.pnpm exec svelte-checkreports 0 errors and 0 warnings.previewMoveHarness.svelte.tsholds everydoCommandopen and never answers on its own: each call parks inpendingwith itsresolve,rejectandsignal, and the spec decides when, and whether, it comes back. That is what makes the ordering cases real interleavings rather than sequential fakes. The request is genuinely suspended at itsawaitwheninvalidate()fires, whensetParts([])swaps the frame system, and whendispose()unmounts the hook. The hook runs under a bare$effect.rootwithcreateWorld(), which is what takingworldandframesas arguments is for.Each guard in the hook has a test standing on it. The
mine !== generationcheck after theawaitis held bydiscards the answer instead of arming the panel with it; the copy of it in thecatchbykeeps a discarded request’s failure off the panel;inFlight.abort()bycancels the request rather than letting it run to completion, which readssignal.aborteddirectly; readingframes.partsabove theawaitbydraws the plan through the kinematics it was requested with; and the trailingrenderStep(0)byplaces them at step 0 rather than leaving them at the origin, sinceplayer.reset()deliberately does not callonStep.The real gate on drawing is one assertion:
moves the ghosts it drewis the only test in the file that fails whenapplyPreviewStepis reduced to a no-op. Everything else asserts ghost counts and statuses, which a preview that draws every ghost in the wrong place satisfies perfectly well.returns to exactly those poses when scrubbed back to the startcovers the other axis, and also pins the direction of the index, since playing the plan backwards lands somewhere else.What the fixtures are protecting:
ARMis built out ofplan.json's ownleft-armmodel config, on the identity that a part'skinematicsis the sameModelConfigJSONa plan dump nests underframe.model. The descriptors under test are a real six-joint arm rather than a stub shaped to pass.SHAPELESSis the counterweight: links and joints, no geometry anywhere. It is the only way to reach a large descriptor set and zero ghosts, and so the only way the two error branches can be told apart. They run as anit.eachwhere each case's pattern has to reject the other case's message, so collapsing them onto one string fails.PLAN_REPLY's two steps are deliberately different configurations, orisAlreadyAtGoalwould divert most of the file.AT_GOALis the same configuration twice, which is exactly what RDK returns for a goal already met.passes through the world state and constraints the panel parsedis the only case that builds its ownmoveOptions. On the harness default of twoundefineds, forwarded and silently dropped look identical, so no other test here can tell them apart.afterEachcallingdestroy()is load-bearing rather than hygiene. Koota's world pool is 16 and onlydestroyreturns one, so a spec that leaks worlds starves whatever runs later in the same browser context.The two panel-closed cases are not duplicates of each other: one unmounts before any ghost exists and one after, and only the second gives teardown something to actually clear. Both matter because the world outlives the panel and a preview ghost carries no
Nameand noChildOf, which puts a leaked one out of reach of every sweep in the codebase short of a page reload.One thing this file cannot check: that
trajectoryandplaybackFramesare genuinely distinct.waypointFramesreturns the planned array itself, so today they are the same reference and every assertion about one holds for the other. The test that separates them arrives withinterpolatedFramesin #930.