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.
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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
|
@@ -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-
|
|
16
|
-
// export-controller's
|
|
17
|
-
// space, and exportCtl.handleMessage
|
|
18
|
-
// checking type, so a colliding id could otherwise settle
|
|
19
|
-
|
|
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
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// build (the worker's shared
|
|
33
|
-
// needs-import-mesh, jobId intact)
|
|
34
|
-
//
|
|
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
|
-
|
|
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
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
|
56
|
-
// can skip legacy handling)
|
|
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
|
|
59
|
-
|
|
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") {
|
package/src/framework/mount.js
CHANGED
|
@@ -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-
|
|
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
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
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":
|