partforge 0.130.0 → 0.130.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -857,6 +857,22 @@ Manifold backend, thrown from `imports.js`'s registration policy, not `errors.js
857
857
  `KernelCapabilityError`/`NEEDS_OCCT`) — is the same message shape and the same
858
858
  non-epoch-gated risk as `needs-occt`; a host handling one should handle both the same way.
859
859
 
860
+ **Export replies cross a rebind by design.** Exports run to completion, so a host that
861
+ keeps one worker across mounts, and routes that worker's messages to whichever mount is
862
+ listening now, hands the next mount every reply to the previous mount's exports.
863
+ `mount()` handles this itself. Its export jobIds (`export-<c>-<n>`, and `warm-<c>-<n>`
864
+ for `warmExportKernel`) are unique to each mount, not numbered from 1 per mount. So a
865
+ previous mount's reply can never settle the new mount's export with the old geometry.
866
+ The new mount claims that reply and drops it, rather than letting its own handler read
867
+ the reply's `error` as a failed build or its `download` as a viewbar save. The previous
868
+ mount already rejected that export when it was disposed.
869
+
870
+ `captureView` replies cross a rebind the same way, and `mount()` handles them the same
871
+ way. Its `capture-generate` jobIds (`cap-<b>-<n>`) are unique to each mount, so a previous
872
+ mount's `capture-meshes` can never settle the new mount's capture with the old geometry,
873
+ and the new mount claims and drops that reply, including an `error`. The previous mount
874
+ already resolved that capture to `null` when it was disposed.
875
+
860
876
  **Cancellation granularity is the sub-part.** The guard is checked only between
861
877
  sub-parts (one macrotask yield each), so a single long WASM op — a big boolean, an OCCT
862
878
  fillet — runs to completion no matter how stale it is. That is by design: WASM kernel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.130.0",
3
+ "version": "0.130.2",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,7 +2,23 @@
2
2
  // does NOT go through the regen loop. Same shape as export-controller's pending
3
3
  // Map (export-controller.js): allocate a jobId, resolve when the matching
4
4
  // reply arrives. Pure — no DOM, no worker; `send` is injected.
5
+
6
+ // Job ids are unique per capture build, not just per mount: `cap-<b>-<n>`,
7
+ // where <b> numbers the capture builds this module has made. Each mount makes
8
+ // one, but a host may keep a worker across mounts (partforge-cloud's pool
9
+ // rebinds it with setPart), and that worker's replies then reach whichever
10
+ // mount is listening now. A capture-generate runs to completion, so a disposed
11
+ // mount's capture still answers after the remount. When every mount numbered
12
+ // its captures from 1, that late reply settled the next mount's same-numbered
13
+ // capture with pre-edit geometry. The same scheme as export-controller.js's
14
+ // `export-<c>-<n>`.
15
+ let builds = 0;
16
+ // Every id any capture build mints, so a reply to another build's job is
17
+ // recognised even though this build never sent it.
18
+ const CAPTURE_JOB_ID = /^cap-\d+-\d+$/;
19
+
5
20
  export function createCaptureBuild({ send }) {
21
+ const build = ++builds;
6
22
  let nextId = 1;
7
23
  let disposed = false;
8
24
  const pending = new Map(); // jobId -> resolve
@@ -12,11 +28,12 @@ export function createCaptureBuild({ send }) {
12
28
  // worker (a silent no-op) and its promise would hang forever. Resolve null instead
13
29
  // — captureView's documented "disposed runtime resolves null" contract.
14
30
  if (disposed) return Promise.resolve(null);
15
- // String-namespaced ("cap-N") so a capture jobId can never collide with
16
- // export-controller's numeric jobIds — both share the same worker message
17
- // space, and exportCtl.handleMessage does a raw pending.get(m.jobId) before
18
- // checking type, so a colliding id could otherwise settle the wrong promise.
19
- const jobId = `cap-${nextId++}`;
31
+ // String-namespaced ("cap-") so a capture jobId stays outside
32
+ // export-controller's "export-"/"warm-" ids — both share the same worker
33
+ // message space, and exportCtl.handleMessage claims every id in its own
34
+ // namespace before checking type, so a colliding id could otherwise settle
35
+ // the wrong promise.
36
+ const jobId = `cap-${build}-${nextId++}`;
20
37
  return new Promise((resolve) => {
21
38
  pending.set(jobId, resolve);
22
39
  // cache:true so the worker reuses its per-sub-part geometry memo (the
@@ -25,13 +42,14 @@ export function createCaptureBuild({ send }) {
25
42
  });
26
43
  }
27
44
 
28
- // Returns true iff this message was a reply this controller owns (so the
29
- // caller — mount.js's onWorkerMessage — can skip it entirely). Keyed on
30
- // membership in `pending` first: the namespaced jobId guarantees another
31
- // channel's message never matches, so a hit here is always ours. A failed
32
- // build (the worker's shared catch posts a generic error/needs-occt/
33
- // needs-import-mesh, jobId intact) resolves to null rather than leaving the
34
- // caller hanging forever — captureView treats null as "capture failed, skip".
45
+ // Returns true iff this message is a reply to a capture job (so the caller —
46
+ // mount.js's onWorkerMessage — can skip it entirely): a pending job is
47
+ // settled, any other capture id is dropped. Keyed on membership in `pending`
48
+ // first: the namespaced jobId guarantees another channel's message never
49
+ // matches, so a hit here is always ours. A failed build (the worker's shared
50
+ // catch posts a generic error/needs-occt/needs-import-mesh, jobId intact)
51
+ // resolves to null rather than leaving the caller hanging forever —
52
+ // captureView treats null as "capture failed, skip".
35
53
  // needs-import-mesh MUST be claimed here rather than falling through to
36
54
  // mount's live-loop crossover case: this capture job never went through the
37
55
  // regen loop, so treating its reply as a live crossover would call
@@ -42,7 +60,12 @@ export function createCaptureBuild({ send }) {
42
60
  // gets primed by the next live build instead.
43
61
  function handleMessage(data) {
44
62
  const jobId = data?.jobId;
45
- if (jobId == null || !pending.has(jobId)) return false;
63
+ // A capture id with nothing pending here: a job this build already
64
+ // settled, or one a disposed build sent to a worker the host kept. Either
65
+ // way its promise is already settled, so claim the reply and drop it.
66
+ // Passing it on would let mount's switch read an "error" as a failed build
67
+ // and a needs-import-mesh as a live crossover.
68
+ if (jobId == null || !pending.has(jobId)) return typeof jobId === "string" && CAPTURE_JOB_ID.test(jobId);
46
69
  if (data.type === "capture-meshes") {
47
70
  pending.get(jobId)(data.meshes);
48
71
  } else if (data.type === "error" || data.type === "needs-occt" || data.type === "needs-import-mesh") {
@@ -15,17 +15,32 @@ export function backendForFormat(format, defaultBackend) {
15
15
  return format === "step" ? "occt" : defaultBackend();
16
16
  }
17
17
 
18
+ // Job ids are unique per controller, not just per mount: `export-<c>-<n>` and
19
+ // `warm-<c>-<n>`, where <c> numbers the controllers this module has made. Each
20
+ // mount makes one controller, but a host may keep a worker across mounts
21
+ // (partforge-cloud's pool rebinds it with setPart), and that worker's replies
22
+ // then reach whichever mount is listening now. Exports are never epoch-guarded
23
+ // (KERNEL-CONTRACT), so a disposed mount's slow export still answers after the
24
+ // remount. When every mount numbered its jobs from 1, that late reply settled
25
+ // the next mount's same-numbered export with pre-edit geometry. A worker can
26
+ // only be shared within one realm, and this counter covers the whole realm.
27
+ let controllers = 0;
28
+ // Every id any controller mints, so a reply to another controller's job is
29
+ // recognised even though this controller never sent it.
30
+ const CONTROLLER_JOB_ID = /^(?:export|warm)-\d+-\d+$/;
31
+
18
32
  export function createExportController({ send, currentView, title, defaultBackend = () => "manifold", currentParams = () => ({}) }) {
19
33
  const pending = new Map(); // jobId -> { resolve, reject, onProgress }
34
+ const controller = ++controllers;
20
35
  let nextId = 1;
21
- // Warm jobs are STRING-namespaced ("warm-N") for the reason mount.js spells
22
- // out for tessellate-imports: this map is keyed by jobId alone and read before
23
- // any type check, so a bare numeric id here could be claimed by a pending
24
- // export (both counters start at 1) and settle the wrong Promise.
36
+ // Warm jobs get their own prefix for the reason mount.js spells out for
37
+ // tessellate-imports: this map is keyed by jobId alone and read before any
38
+ // type check, so a warm and an export sharing an id would settle each
39
+ // other's Promise.
25
40
  let nextWarmId = 1;
26
41
 
27
42
  function exportParts({ parts, format, quality = "print", onProgress } = {}) {
28
- const jobId = nextId++;
43
+ const jobId = `export-${controller}-${nextId++}`;
29
44
  const type = `export-${format}`;
30
45
  const backend = backendForFormat(format, defaultBackend);
31
46
  return new Promise((resolve, reject) => {
@@ -45,18 +60,25 @@ export function createExportController({ send, currentView, title, defaultBacken
45
60
  // failure or teardown, and NEVER rejects — a speculative warm must not become
46
61
  // an unhandled rejection in a host that fired it and moved on.
47
62
  function warmKernel() {
48
- const jobId = `warm-${nextWarmId++}`;
63
+ const jobId = `warm-${controller}-${nextWarmId++}`;
49
64
  return new Promise((resolve) => {
50
65
  pending.set(jobId, { resolve: () => resolve(true), reject: () => resolve(false) });
51
66
  send({ type: "warm-kernel", jobId }, backendForFormat("step", defaultBackend));
52
67
  });
53
68
  }
54
69
 
55
- // Returns true iff this message belonged to a pending export (so the caller
56
- // can skip legacy handling). `sink` is partforge's onDownload.
70
+ // Returns true iff this message is a reply to an export-controller job (so
71
+ // the caller can skip legacy handling): a pending job is settled, any other
72
+ // is dropped. `sink` is partforge's onDownload.
57
73
  function handleMessage(m, sink) {
58
- const entry = m && m.jobId != null ? pending.get(m.jobId) : undefined;
59
- if (!entry) return false;
74
+ const jobId = m?.jobId;
75
+ const entry = jobId != null ? pending.get(jobId) : undefined;
76
+ // A controller's id with nothing pending here: a job this controller
77
+ // already settled, or one a disposed controller sent to a worker the host
78
+ // kept. Either way its Promise is already settled, so claim the reply and
79
+ // drop it. Passing it on would let mount's switch read an "error" as a
80
+ // failed build and a "download" as a viewbar save.
81
+ if (!entry) return typeof jobId === "string" && CONTROLLER_JOB_ID.test(jobId);
60
82
  if (m.type === "progress") { entry.onProgress?.(m.phase); return true; }
61
83
  if (m.type === "kernel-warm") { pending.delete(m.jobId); entry.resolve(); return true; }
62
84
  if (m.type === "download") {
@@ -526,16 +526,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
526
526
  // Lets the "error" case below tell a correlated tessellation failure apart
527
527
  // from an unrelated build error sharing the same message type, so it can
528
528
  // reset the latch instead of stranding it at "requested" forever.
529
- // String-namespaced ("tess-N"), mirroring capture-build.js's "cap-N" —
529
+ // String-namespaced ("tess-N"), mirroring capture-build.js's "cap-" ids —
530
530
  // this request shares the OCCT worker's message space with
531
- // export-controller's PLAIN NUMERIC jobIds (both start counting at 1), and
532
- // exportCtl.handleMessage (called before mount's own switch, below) does a
533
- // raw pending.get(m.jobId) before checking type. A bare numeric id here
534
- // could collide with a pending STEP export's jobId, so the export
535
- // controller would wrongly claim this reply — rejecting an unrelated
536
- // export with the tessellation failure text AND leaving importMeshState
537
- // stranded at "requested" (mount's own switch, which would have reset it,
538
- // never runs). The string namespace makes that collision impossible.
531
+ // export-controller's "export-"/"warm-" jobIds, and exportCtl.handleMessage
532
+ // (called before mount's own switch, below) claims every id in its own
533
+ // namespace before checking type. An id inside that namespace would let the
534
+ // export controller wrongly claim this reply — rejecting an unrelated
535
+ // export with the tessellation failure text, or dropping the reply, AND
536
+ // leaving importMeshState stranded at "requested" (mount's own switch,
537
+ // which would have reset it, never runs). The "tess-" prefix makes that
538
+ // collision impossible.
539
539
  let importTessellateJobId = null;
540
540
  let importTessellateJobSeq = 0;
541
541
 
@@ -1015,6 +1015,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1015
1015
  if (exportCtl.handleMessage(data, onDownload)) return;
1016
1016
  // captureView's off-loop build channel: consume its replies before the
1017
1017
  // live `meshes` case — capture-meshes must never touch live cache/display.
1018
+ // Like exportCtl, it also claims a disposed mount's late capture reply on
1019
+ // a worker the host kept, so a stale "error" never reads as a failed build.
1018
1020
  if (captureBuild.handleMessage(data)) return;
1019
1021
  switch (data.type) {
1020
1022
  case "ready":