@bldrs-ai/conway 1.536.1507 → 1.543.1513
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/compiled/examples/browser-bundled.cjs +39 -5
- package/compiled/examples/cli-bundled.cjs +197 -22
- package/compiled/examples/cli-step-bundled.cjs +39 -5
- package/compiled/examples/validator-bundled.cjs +39 -5
- package/compiled/src/compat/web-ifc/geometry_shard_coordination.test.d.ts +2 -0
- package/compiled/src/compat/web-ifc/geometry_shard_coordination.test.d.ts.map +1 -0
- package/compiled/src/compat/web-ifc/geometry_shard_coordination.test.js +236 -0
- package/compiled/src/compat/web-ifc/ifc_api.d.ts +26 -0
- package/compiled/src/compat/web-ifc/ifc_api.d.ts.map +1 -1
- package/compiled/src/compat/web-ifc/ifc_api.js +33 -0
- package/compiled/src/compat/web-ifc/ifc_api_model_passthrough.d.ts +6 -0
- package/compiled/src/compat/web-ifc/ifc_api_model_passthrough.d.ts.map +1 -1
- package/compiled/src/compat/web-ifc/ifc_api_preview_channel.test.js +264 -1
- package/compiled/src/compat/web-ifc/ifc_api_proxy_ifc.d.ts +116 -5
- package/compiled/src/compat/web-ifc/ifc_api_proxy_ifc.d.ts.map +1 -1
- package/compiled/src/compat/web-ifc/ifc_api_proxy_ifc.js +256 -85
- package/compiled/src/compat/web-ifc/store_preview_channel.d.ts +79 -0
- package/compiled/src/compat/web-ifc/store_preview_channel.d.ts.map +1 -1
- package/compiled/src/compat/web-ifc/store_preview_channel.js +237 -14
- package/compiled/src/compat/web-ifc/store_preview_channel.test.js +270 -1
- package/compiled/src/compat/web-ifc/streamed_preview_channel.d.ts +106 -1
- package/compiled/src/compat/web-ifc/streamed_preview_channel.d.ts.map +1 -1
- package/compiled/src/compat/web-ifc/streamed_preview_channel.js +269 -17
- package/compiled/src/core/progress_log.d.ts +75 -2
- package/compiled/src/core/progress_log.d.ts.map +1 -1
- package/compiled/src/core/progress_log.js +59 -2
- package/compiled/src/core/progress_log.test.js +75 -1
- package/compiled/src/ifc/dangling_placement_error.d.ts +35 -0
- package/compiled/src/ifc/dangling_placement_error.d.ts.map +1 -0
- package/compiled/src/ifc/dangling_placement_error.js +38 -0
- package/compiled/src/ifc/geometry_dispatch.d.ts +74 -3
- package/compiled/src/ifc/geometry_dispatch.d.ts.map +1 -1
- package/compiled/src/ifc/geometry_dispatch.js +261 -5
- package/compiled/src/ifc/geometry_dispatch.test.js +147 -1
- package/compiled/src/ifc/ifc_geometry_extraction.d.ts +54 -0
- package/compiled/src/ifc/ifc_geometry_extraction.d.ts.map +1 -1
- package/compiled/src/ifc/ifc_geometry_extraction.js +153 -20
- package/compiled/src/index.d.ts +1 -1
- package/compiled/src/index.d.ts.map +1 -1
- package/compiled/src/index.js +1 -1
- package/compiled/src/step/dangling_reference_error.d.ts +34 -0
- package/compiled/src/step/dangling_reference_error.d.ts.map +1 -0
- package/compiled/src/step/dangling_reference_error.js +36 -0
- package/compiled/src/step/step_entity_base.d.ts +17 -0
- package/compiled/src/step/step_entity_base.d.ts.map +1 -1
- package/compiled/src/step/step_entity_base.js +28 -4
- package/compiled/src/version/version.js +1 -1
- package/compiled/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
|
@@ -7,6 +7,9 @@ import { WindowedStepBufferProvider } from "../../step/step_buffer_provider.js";
|
|
|
7
7
|
import * as glmatrix from "gl-matrix";
|
|
8
8
|
import { composeTransformF64, deriveCoordinationF64, IDENTITY_MAT4, NORMALIZE_MAT_F64, } from "./coordination_f64.js";
|
|
9
9
|
import { emitSpatialStructureImposters } from "./spatial_imposter.js";
|
|
10
|
+
import { DanglingPlacementError } from "../../ifc/dangling_placement_error.js";
|
|
11
|
+
import { formatPreviewLine } from "../../core/progress_log.js";
|
|
12
|
+
import Logger from "../../logging/logger.js";
|
|
10
13
|
import { releaseModelGeometry, } from "./streamed_preview_channel.js";
|
|
11
14
|
/* eslint-disable no-magic-numbers */
|
|
12
15
|
const TICK_INTERVAL_MS = 150;
|
|
@@ -24,13 +27,53 @@ const TICK_BUDGET_MS = 20;
|
|
|
24
27
|
* Products a single tick may attempt, whatever the clock says.
|
|
25
28
|
*
|
|
26
29
|
* `deferDanglingPlacements` (Share#1744) defers any product whose
|
|
27
|
-
* placement records the prefix does not hold yet, and
|
|
28
|
-
*
|
|
29
|
-
* runs of products that cannot extract. Each such
|
|
30
|
-
* source through the windowed provider, so a run of
|
|
31
|
-
* this caps the run rather than letting the deadline
|
|
30
|
+
* placement records the prefix does not hold yet, and some exporters
|
|
31
|
+
* write the tail of a placement chain toward the tail of the file — so
|
|
32
|
+
* early ticks meet long runs of products that cannot extract. Each such
|
|
33
|
+
* attempt still pages source through the windowed provider, so a run of
|
|
34
|
+
* them is not free; this caps the run rather than letting the deadline
|
|
35
|
+
* be the only bound.
|
|
36
|
+
*
|
|
37
|
+
* Measured across the corpus in conway#542, and note it is the WRITER
|
|
38
|
+
* that decides record order, not the authoring tool: files written by
|
|
39
|
+
* the ODA IFC SDK and Tekla's exporter are strictly backward-
|
|
40
|
+
* referencing and never hit this path, while DDS_IFC (Archicad's IFC
|
|
41
|
+
* add-on) and ST-DEVELOPER defer 100% of products. On the Archicad
|
|
42
|
+
* files the last record in a chain to arrive is always a LEAF —
|
|
43
|
+
* IfcDirection or IfcCartesianPoint, not the placement and not the
|
|
44
|
+
* geometry — which is why a head-only prefix cannot fix it however far
|
|
45
|
+
* it grows.
|
|
46
|
+
*
|
|
47
|
+
* An earlier version of this comment blamed Revit. The one
|
|
48
|
+
* Revit-authored file in that corpus is the BEST case measured (first
|
|
49
|
+
* preview mesh at 269 ms, zero deferrals) — though it was written out
|
|
50
|
+
* through the ODA SDK, so it says nothing about Revit's own exporter
|
|
51
|
+
* either way. The 398 MB Archicad file is the worst: 531 of 532
|
|
52
|
+
* attempts deferred, first mesh at 98.3% of the file, 8.9 s of blank
|
|
53
|
+
* screen.
|
|
32
54
|
*/
|
|
33
55
|
const TICK_MAX_ATTEMPTS = 32;
|
|
56
|
+
/**
|
|
57
|
+
* Deferred products carried into the next generation for a second look.
|
|
58
|
+
*
|
|
59
|
+
* The channel used to be strictly forward-only: a product that deferred was
|
|
60
|
+
* attempted once, at the earliest — and therefore most index-starved —
|
|
61
|
+
* generation it appeared in, and never again, because `unitOrdinal_` only
|
|
62
|
+
* ever advances and a rebuilt generation regenerates `products` in the same
|
|
63
|
+
* dense parse order. On a file whose placement chains resolve late that
|
|
64
|
+
* throws away nearly everything: DOWA has 19,854 of its 45,860 products
|
|
65
|
+
* emittable once 70% of the file is indexed, and emitted 1, because all of
|
|
66
|
+
* them had been attempted and discarded before the index reached their
|
|
67
|
+
* leaves (conway#542).
|
|
68
|
+
*
|
|
69
|
+
* A retry is affordable in a way a first attempt is not: a deferring
|
|
70
|
+
* attempt costs ~0.17 ms — the whole 532-attempt run on DOWA is 93 ms,
|
|
71
|
+
* against 2.1 s for that load's three generation rebuilds — because it
|
|
72
|
+
* fails on an index lookup before any geometry is paged. The cap is here
|
|
73
|
+
* because the queue must not grow with the model; it only has to keep
|
|
74
|
+
* DEFAULT_MAX_PREVIEW_UNITS worth of candidates in play.
|
|
75
|
+
*/
|
|
76
|
+
const RETRY_QUEUE_MAX = 4096;
|
|
34
77
|
const FIRST_GENERATION_MIN_RECORDS = 1024;
|
|
35
78
|
const GENERATION_GROWTH_FACTOR = 2;
|
|
36
79
|
const DEFAULT_MAX_PREVIEW_UNITS = 512;
|
|
@@ -75,6 +118,36 @@ export class StorePreviewChannel {
|
|
|
75
118
|
this.firstGenerationMinRecords = firstGenerationMinRecords;
|
|
76
119
|
this.stopped_ = false;
|
|
77
120
|
this.emittedUnits_ = 0;
|
|
121
|
+
/**
|
|
122
|
+
* Preview meshes handed to `onMesh_`. Tracked apart from
|
|
123
|
+
* {@link emittedUnits_} because the two genuinely diverge: a product can
|
|
124
|
+
* place several meshes, and a product with no Representation extracts
|
|
125
|
+
* cleanly while placing none — `extractProductGeometry` returns early on
|
|
126
|
+
* a null Representation and `extractProductGeometryByLocalID` still
|
|
127
|
+
* answers true. Reporting only units let the load log say "no mesh, 20
|
|
128
|
+
* emitted" about one load (codex round 2 on #543).
|
|
129
|
+
*/
|
|
130
|
+
this.meshesEmitted_ = 0;
|
|
131
|
+
/* Products a tick attempted but could not extract, and how many of those
|
|
132
|
+
* were specifically waiting on a placement chain the prefix does not hold
|
|
133
|
+
* yet. Reported at the end of the preview so a blank first load is
|
|
134
|
+
* attributable: "9 812 of 9 840 attempts deferred, 9 810 on placements" is
|
|
135
|
+
* a file-layout problem that a sharded parse addresses, and anything else
|
|
136
|
+
* is not (conway#542). */
|
|
137
|
+
this.deferredUnits_ = 0;
|
|
138
|
+
this.deferredOnPlacement_ = 0;
|
|
139
|
+
/**
|
|
140
|
+
* Products that deferred under the CURRENT generation, promoted to
|
|
141
|
+
* {@link retryQueue_} when the next one is built. Held separately so a
|
|
142
|
+
* retry always runs against a longer index than the attempt that failed —
|
|
143
|
+
* re-running one inside its own generation would fail identically and
|
|
144
|
+
* spin.
|
|
145
|
+
*/
|
|
146
|
+
this.deferredForRetry_ = [];
|
|
147
|
+
this.retryQueue_ = [];
|
|
148
|
+
this.retryCursor_ = 0;
|
|
149
|
+
this.retriedUnits_ = 0;
|
|
150
|
+
this.startedMs_ = Date.now();
|
|
78
151
|
this.emittedBytes_ = 0;
|
|
79
152
|
this.unitOrdinal_ = 0;
|
|
80
153
|
this.lastInlineTick_ = 0;
|
|
@@ -105,6 +178,37 @@ export class StorePreviewChannel {
|
|
|
105
178
|
get emittedUnits() {
|
|
106
179
|
return this.emittedUnits_;
|
|
107
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* What the preview delivered, and how fast.
|
|
183
|
+
*
|
|
184
|
+
* `firstMeshMs` is time-to-first-pixel measured from channel
|
|
185
|
+
* construction, undefined when no placed product was ever emitted.
|
|
186
|
+
* `meshes` counts what actually reached the consumer, `emitted` counts
|
|
187
|
+
* the products extraction accepted — they are not the same number in
|
|
188
|
+
* either direction, so the line reports both.
|
|
189
|
+
* `deferred` counts products a tick attempted and could not extract;
|
|
190
|
+
* `deferredOnPlacement` is the subset still waiting on a placement chain
|
|
191
|
+
* the prefix does not hold; `retried` counts attempts that were second
|
|
192
|
+
* looks at an earlier deferral rather than first attempts, so a channel
|
|
193
|
+
* whose retry path has silently stopped firing is visible rather than
|
|
194
|
+
* merely slower. A first load that shows nothing is a very
|
|
195
|
+
* different problem depending on that ratio — near 1.0 is the file-layout
|
|
196
|
+
* case a sharded parse attacks, anything else is not — and inferring it
|
|
197
|
+
* from a code comment is what conway#542 exists to stop.
|
|
198
|
+
*
|
|
199
|
+
* @return {object} `{firstMeshMs, meshes, emitted, deferred,
|
|
200
|
+
* deferredOnPlacement, retried}`
|
|
201
|
+
*/
|
|
202
|
+
get previewYield() {
|
|
203
|
+
return {
|
|
204
|
+
firstMeshMs: this.firstMeshMs_,
|
|
205
|
+
meshes: this.meshesEmitted_,
|
|
206
|
+
emitted: this.emittedUnits_,
|
|
207
|
+
deferred: this.deferredUnits_,
|
|
208
|
+
deferredOnPlacement: this.deferredOnPlacement_,
|
|
209
|
+
retried: this.retriedUnits_,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
108
212
|
/** Products the current generation would run (test seam). */
|
|
109
213
|
get productCount() {
|
|
110
214
|
return this.generation_?.products.length ?? 0;
|
|
@@ -205,6 +309,25 @@ export class StorePreviewChannel {
|
|
|
205
309
|
stop() {
|
|
206
310
|
this.stopped_ = true;
|
|
207
311
|
this.disposeGeneration_();
|
|
312
|
+
// Say what the preview delivered, and how fast, on the way out. The
|
|
313
|
+
// channel is a local of the open call — nothing outside holds it — so a
|
|
314
|
+
// counter that is not reported here is a counter nobody can read. And a
|
|
315
|
+
// first load that showed the user nothing is exactly the case where this
|
|
316
|
+
// line is the whole diagnosis: a deferral ratio near 1.0 ON PLACEMENTS
|
|
317
|
+
// is the file-layout problem, and anything else is a different problem
|
|
318
|
+
// wearing the same blank screen (conway#542). Rendered by the shared
|
|
319
|
+
// load-log formatter, so the resident path's line and this one are the
|
|
320
|
+
// same line.
|
|
321
|
+
//
|
|
322
|
+
// Unconditional, not gated on emittedUnits_ or deferredUnits_ being
|
|
323
|
+
// nonzero: a channel that never reached firstGenerationMinRecords, or
|
|
324
|
+
// whose every generation build threw, is precisely the worst-case
|
|
325
|
+
// blank-first-load this issue exists to make diagnosable — and
|
|
326
|
+
// formatPreviewLine already renders that as "no mesh, 0 emitted, 0
|
|
327
|
+
// deferred". Suppressing the line there made an enabled preview that
|
|
328
|
+
// produced nothing indistinguishable from a preview that never ran
|
|
329
|
+
// (codex round 1 on #543).
|
|
330
|
+
Logger.info(formatPreviewLine(this.previewYield));
|
|
208
331
|
}
|
|
209
332
|
/**
|
|
210
333
|
* Test seam: drain until the current sink is exhausted or a cap hits.
|
|
@@ -232,10 +355,19 @@ export class StorePreviewChannel {
|
|
|
232
355
|
return false;
|
|
233
356
|
}
|
|
234
357
|
const active = this.generation_;
|
|
235
|
-
if (this.unitOrdinal_
|
|
358
|
+
if (!StorePreviewChannel.hasPending_(this.retryCursor_, this.retryQueue_.length, this.unitOrdinal_, active.products.length)) {
|
|
236
359
|
return false;
|
|
237
360
|
}
|
|
238
|
-
|
|
361
|
+
// Retries first: they are the products the index has most recently
|
|
362
|
+
// become able to place, and they are cheaper than a fresh attempt
|
|
363
|
+
// whichever way they go.
|
|
364
|
+
const isRetry = this.retryCursor_ < this.retryQueue_.length;
|
|
365
|
+
const localID = isRetry ?
|
|
366
|
+
this.retryQueue_[this.retryCursor_++] :
|
|
367
|
+
active.products[this.unitOrdinal_++];
|
|
368
|
+
if (isRetry) {
|
|
369
|
+
++this.retriedUnits_;
|
|
370
|
+
}
|
|
239
371
|
const model = active.model;
|
|
240
372
|
const pins = new Set();
|
|
241
373
|
const leafSpans = [];
|
|
@@ -246,8 +378,22 @@ export class StorePreviewChannel {
|
|
|
246
378
|
++this.emittedUnits_;
|
|
247
379
|
}
|
|
248
380
|
}
|
|
249
|
-
catch {
|
|
250
|
-
// Forward-ref / unplaced — durable pump extracts later.
|
|
381
|
+
catch (error) {
|
|
382
|
+
// Forward-ref / unplaced — durable pump extracts later. Counted by
|
|
383
|
+
// cause: a preview that stays blank because every product's placement
|
|
384
|
+
// is still ahead of the prefix wants a different fix from one blank
|
|
385
|
+
// for any other reason, and only this split can tell them apart
|
|
386
|
+
// (conway#542).
|
|
387
|
+
++this.deferredUnits_;
|
|
388
|
+
if (error instanceof DanglingPlacementError) {
|
|
389
|
+
++this.deferredOnPlacement_;
|
|
390
|
+
// Only placement deferrals are worth another look. Any other throw
|
|
391
|
+
// is a property of the product rather than of how much index exists,
|
|
392
|
+
// so a longer prefix does not change the answer.
|
|
393
|
+
if (this.deferredForRetry_.length < RETRY_QUEUE_MAX) {
|
|
394
|
+
this.deferredForRetry_.push(localID);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
251
397
|
}
|
|
252
398
|
finally {
|
|
253
399
|
model.releaseSourceViews(pins);
|
|
@@ -258,15 +404,62 @@ export class StorePreviewChannel {
|
|
|
258
404
|
}
|
|
259
405
|
return true;
|
|
260
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* Whether either queue still has a product to attempt.
|
|
409
|
+
*
|
|
410
|
+
* Static and fully parameterised so the two callers — the tick and the
|
|
411
|
+
* generation gate — cannot drift apart on what "pending" means, which is
|
|
412
|
+
* the bug that would silently strand the retry queue.
|
|
413
|
+
*
|
|
414
|
+
* @param retryCursor How far into the retry queue this generation is.
|
|
415
|
+
* @param retryLength The retry queue's length.
|
|
416
|
+
* @param ordinal The forward cursor into the generation's products.
|
|
417
|
+
* @param productCount That generation's product count.
|
|
418
|
+
* @return {boolean} True when something is left to attempt.
|
|
419
|
+
*/
|
|
420
|
+
static hasPending_(retryCursor, retryLength, ordinal, productCount) {
|
|
421
|
+
return retryCursor < retryLength || ordinal < productCount;
|
|
422
|
+
}
|
|
261
423
|
/**
|
|
262
424
|
* @return {Promise<boolean>} True when a generation with pending units exists.
|
|
263
425
|
*/
|
|
264
426
|
async ensureGeneration_() {
|
|
265
427
|
const active = this.generation_;
|
|
266
|
-
|
|
428
|
+
const records = this.sink_.topLevelCount;
|
|
429
|
+
// Rebuild on INDEX GROWTH, not on running out of products. The order of
|
|
430
|
+
// these two tests is the whole behaviour of the channel on a large file.
|
|
431
|
+
//
|
|
432
|
+
// It used to keep the current generation for as long as that generation
|
|
433
|
+
// still had an unattempted product, and only then consider rebuilding.
|
|
434
|
+
// A generation snapshotted at FIRST_GENERATION_MIN_RECORDS already lists
|
|
435
|
+
// thousands of products, and the tick attempts at most
|
|
436
|
+
// TICK_MAX_ATTEMPTS of them per cadence interval, so on anything large
|
|
437
|
+
// the list never emptied and the rebuild never fired: DOWA spent all 9 s
|
|
438
|
+
// of its parse extracting against a snapshot of its first 1024 records,
|
|
439
|
+
// which is why 100% of its attempts deferred and its first mesh landed
|
|
440
|
+
// at 99.2% of the file (conway#542). The growth gate below was written
|
|
441
|
+
// to bound rebuild cost and instead became unreachable.
|
|
442
|
+
//
|
|
443
|
+
// Preempting makes generations arrive on the index's schedule —
|
|
444
|
+
// O(log records) of them, GENERATION_GROWTH_FACTOR apart — and the
|
|
445
|
+
// products the old generation had not reached carry over untouched,
|
|
446
|
+
// because `unitOrdinal_` indexes a dense parse-order list that a longer
|
|
447
|
+
// snapshot only extends (see ColumnarIndexSink.snapshot).
|
|
448
|
+
// Eligibility to rebuild at all — the original growth gate, unchanged.
|
|
449
|
+
const growthReady = records >= this.lastSnapshotRecords_ * GENERATION_GROWTH_FACTOR &&
|
|
450
|
+
records >= this.lastFailedSnapshotRecords_ * GENERATION_GROWTH_FACTOR;
|
|
451
|
+
// Whether to rebuild EARLY, without waiting for the current generation's
|
|
452
|
+
// products to run out. Only worth it for a generation that is actually
|
|
453
|
+
// deferring: a longer index cannot help one whose products extract fine,
|
|
454
|
+
// and preempting anyway costs the case that already works best — on PSB,
|
|
455
|
+
// which defers nothing, unconditional preemption pushed the first mesh
|
|
456
|
+
// from 269 ms out to 495 ms. Deferrals waiting for retry are precisely
|
|
457
|
+
// the evidence that more index is the missing ingredient.
|
|
458
|
+
const preempt = active !== void 0 && growthReady &&
|
|
459
|
+
this.deferredForRetry_.length > 0;
|
|
460
|
+
if (!preempt && active !== void 0 && StorePreviewChannel.hasPending_(this.retryCursor_, this.retryQueue_.length, this.unitOrdinal_, active.products.length)) {
|
|
267
461
|
return true;
|
|
268
462
|
}
|
|
269
|
-
const records = this.sink_.topLevelCount;
|
|
270
463
|
if (records < this.firstGenerationMinRecords) {
|
|
271
464
|
return false;
|
|
272
465
|
}
|
|
@@ -274,6 +467,10 @@ export class StorePreviewChannel {
|
|
|
274
467
|
records < this.lastSnapshotRecords_ * GENERATION_GROWTH_FACTOR) {
|
|
275
468
|
return false;
|
|
276
469
|
}
|
|
470
|
+
// Unconditional, unlike the snapshot gate above: a prefix whose build
|
|
471
|
+
// THREW leaves no active generation, so folding this into `growthReady`
|
|
472
|
+
// and testing it only when one exists would let a structurally
|
|
473
|
+
// incomplete prefix re-snapshot and re-throw on every tick.
|
|
277
474
|
if (records < this.lastFailedSnapshotRecords_ * GENERATION_GROWTH_FACTOR) {
|
|
278
475
|
return false;
|
|
279
476
|
}
|
|
@@ -289,7 +486,11 @@ export class StorePreviewChannel {
|
|
|
289
486
|
this.lastFailReason = `no products in ${records} records`;
|
|
290
487
|
return false;
|
|
291
488
|
}
|
|
292
|
-
|
|
489
|
+
// A generation that adds no new products is still worth building when
|
|
490
|
+
// deferred ones are waiting: what changed is the INDEX behind them,
|
|
491
|
+
// which is the only reason a retry could now succeed.
|
|
492
|
+
if (products.length <= this.unitOrdinal_ &&
|
|
493
|
+
this.deferredForRetry_.length === 0) {
|
|
293
494
|
this.lastFailReason = `ordinal ${this.unitOrdinal_} >= ${products.length}`;
|
|
294
495
|
return false;
|
|
295
496
|
}
|
|
@@ -305,6 +506,21 @@ export class StorePreviewChannel {
|
|
|
305
506
|
model.unpinLocalIDs(prepPins);
|
|
306
507
|
}
|
|
307
508
|
this.disposeGeneration_();
|
|
509
|
+
// Carry the OLD queue's unconsumed suffix forward, not just this
|
|
510
|
+
// generation's fresh deferrals. Preemption can fire mid-drain of
|
|
511
|
+
// retryQueue_ — bounded per-tick attempts (TICK_MAX_ATTEMPTS) against
|
|
512
|
+
// an index that grew enough to trigger a rebuild before the queue
|
|
513
|
+
// emptied — and every one of those un-popped entries is a product
|
|
514
|
+
// whose ordinal unitOrdinal_ has already passed, so replacing the
|
|
515
|
+
// queue outright (rather than concatenating) would strand them
|
|
516
|
+
// exactly the way abandoned deferrals were stranded before this fix
|
|
517
|
+
// existed (conway#542, codex round 1 on #543).
|
|
518
|
+
this.retryQueue_ = [
|
|
519
|
+
...this.retryQueue_.slice(this.retryCursor_),
|
|
520
|
+
...this.deferredForRetry_,
|
|
521
|
+
].slice(0, RETRY_QUEUE_MAX);
|
|
522
|
+
this.retryCursor_ = 0;
|
|
523
|
+
this.deferredForRetry_ = [];
|
|
308
524
|
this.generation_ = {
|
|
309
525
|
model,
|
|
310
526
|
extraction,
|
|
@@ -447,15 +663,18 @@ export class StorePreviewChannel {
|
|
|
447
663
|
/**
|
|
448
664
|
* Copy newly extracted instances out of wasm — same placement math
|
|
449
665
|
* as {@link StreamedPreviewChannel}.
|
|
666
|
+
*
|
|
667
|
+
* @return {number} Meshes emitted by this pass.
|
|
450
668
|
*/
|
|
451
669
|
captureNewInstances_() {
|
|
452
670
|
const active = this.generation_;
|
|
453
671
|
if (active === void 0) {
|
|
454
|
-
return;
|
|
672
|
+
return 0;
|
|
455
673
|
}
|
|
456
674
|
const { extraction, capturedCounts } = active;
|
|
457
675
|
const scene = extraction.scene;
|
|
458
676
|
const seenThisPass = new Map();
|
|
677
|
+
let emitted = 0;
|
|
459
678
|
for (const walked of scene.walk()) {
|
|
460
679
|
const [, nativeTransform, geometry, material, entity] = walked;
|
|
461
680
|
if (entity?.localID === void 0 || entity.expressID === void 0) {
|
|
@@ -515,10 +734,14 @@ export class StorePreviewChannel {
|
|
|
515
734
|
this.emittedBytes_ +=
|
|
516
735
|
(vertexData.length + indexData.length) * BYTES_PER_FLOAT;
|
|
517
736
|
}
|
|
737
|
+
this.firstMeshMs_ ??= Date.now() - this.startedMs_;
|
|
518
738
|
this.onMesh_(payload);
|
|
739
|
+
++this.meshesEmitted_;
|
|
740
|
+
++emitted;
|
|
519
741
|
if (this.emittedBytes_ >= this.maxBytes) {
|
|
520
|
-
return;
|
|
742
|
+
return emitted;
|
|
521
743
|
}
|
|
522
744
|
}
|
|
745
|
+
return emitted;
|
|
523
746
|
}
|
|
524
747
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/* eslint-disable no-magic-numbers */
|
|
2
2
|
import * as fs from "fs";
|
|
3
|
-
import { beforeAll, describe, expect, test } from "@jest/globals";
|
|
3
|
+
import { beforeAll, describe, expect, jest, test } from "@jest/globals";
|
|
4
4
|
import { ConwayGeometry } from "../../../dependencies/conway-geom/index.js";
|
|
5
5
|
import IfcStepParser from "../../ifc/ifc_step_parser.js";
|
|
6
|
+
import Logger from "../../logging/logger.js";
|
|
6
7
|
import { BufferByteSource } from "../../step/parsing/byte_source.js";
|
|
7
8
|
import { ColumnarIndexSink } from "../../step/parsing/columnar_index.js";
|
|
8
9
|
import { ParseResult } from "../../step/parsing/step_parser.js";
|
|
@@ -73,6 +74,147 @@ describe("StorePreviewChannel", () => {
|
|
|
73
74
|
expect(attempted).toBeLessThanOrEqual(internals.tickMaxAttempts_);
|
|
74
75
|
channel.stop();
|
|
75
76
|
});
|
|
77
|
+
test("a tail-placement file recovers its deferred products", async () => {
|
|
78
|
+
// data/index_tail_placements.ifc is index_georeferenced_multicell.ifc
|
|
79
|
+
// with every leaf point/direction record moved to the end of DATA --
|
|
80
|
+
// same ids, same text, hostile order. It is the Archicad shape at
|
|
81
|
+
// fixture scale: its products reference a placement chain that bottoms
|
|
82
|
+
// out after them, so an index stopping short of the tail cannot place
|
|
83
|
+
// any of them (conway#542). The multicell fixture rather than
|
|
84
|
+
// index.ifc because it carries enough products that a few ticks cannot
|
|
85
|
+
// exhaust the generation -- which is what leaves the preemption path,
|
|
86
|
+
// not the exhaustion path, as the only way to rebuild.
|
|
87
|
+
const tailText = fs.readFileSync("data/index_tail_placements.ifc", "utf8");
|
|
88
|
+
const lines = tailText.split("\n");
|
|
89
|
+
const firstLeaf = lines.findIndex((line) => /=\s*IFC(CARTESIANPOINT|DIRECTION)/.test(line));
|
|
90
|
+
expect(firstLeaf).toBeGreaterThan(0);
|
|
91
|
+
// A genuine prefix, closed off so it parses: every record up to the
|
|
92
|
+
// leaf block, and nothing after it. The full file is stage two.
|
|
93
|
+
const prefixText = `${lines.slice(0, firstLeaf).join("\n")}\nENDSEC;\nEND-ISO-10303-21;\n`;
|
|
94
|
+
const prefixBytes = new Uint8Array(Buffer.from(prefixText, "latin1"));
|
|
95
|
+
const tailBytes = new Uint8Array(Buffer.from(tailText, "latin1"));
|
|
96
|
+
const store = new InMemoryStepByteStore(tailBytes);
|
|
97
|
+
const sink = new ColumnarIndexSink();
|
|
98
|
+
const channel = new StorePreviewChannel(store, sink, conwayGeometry, true, () => { }, 64, 48 * 1024 * 1024, 1);
|
|
99
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
100
|
+
const internals = channel;
|
|
101
|
+
// One product per tick, so stage one leaves products UNATTEMPTED. That
|
|
102
|
+
// is what forces the preemption path: with products still pending, a
|
|
103
|
+
// channel that only rebuilds on exhaustion keeps its starved generation
|
|
104
|
+
// and never looks at the deferrals again.
|
|
105
|
+
internals.tickBudgetMs_ = Number.MAX_SAFE_INTEGER;
|
|
106
|
+
internals.tickMaxAttempts_ = 1;
|
|
107
|
+
// Stage one: index only the prefix, then let the channel work it.
|
|
108
|
+
expect(buildIndexStreaming(new BufferByteSource(prefixBytes), IfcStepParser.Instance, 4 * 1024, void 0, sink).result).toBe(ParseResult.COMPLETE);
|
|
109
|
+
internals.lastInlineTick_ = 0;
|
|
110
|
+
await channel.maybeTickAsync();
|
|
111
|
+
const afterPrefix = channel.previewYield;
|
|
112
|
+
// The fixture has to actually exercise the path, or the rest proves
|
|
113
|
+
// nothing about it -- and products must be left over, or the rebuild
|
|
114
|
+
// would happen by exhaustion and prove nothing about preemption.
|
|
115
|
+
expect(afterPrefix.deferredOnPlacement).toBeGreaterThan(0);
|
|
116
|
+
expect(afterPrefix.emitted).toBe(0);
|
|
117
|
+
expect(internals.unitOrdinal_).toBeLessThan(channel.productCount);
|
|
118
|
+
// Stage two: the same reset-and-replay the streaming builder itself
|
|
119
|
+
// performs when it grows its window, so top-level localIDs stay in
|
|
120
|
+
// dense parse order and the deferred ids the channel is holding still
|
|
121
|
+
// mean what they meant (see ColumnarIndexSink.reset).
|
|
122
|
+
sink.reset();
|
|
123
|
+
expect(buildIndexStreaming(new BufferByteSource(tailBytes), IfcStepParser.Instance, 4 * 1024, void 0, sink).result).toBe(ParseResult.COMPLETE);
|
|
124
|
+
// Exactly one tick, and one attempt inside it. That is what makes this
|
|
125
|
+
// an assertion about PREEMPTION: with products still pending, a channel
|
|
126
|
+
// that rebuilds only on exhaustion spends this tick on the next fresh
|
|
127
|
+
// product against the old starved generation, and the retry never
|
|
128
|
+
// happens.
|
|
129
|
+
internals.lastInlineTick_ = 0;
|
|
130
|
+
await channel.maybeTickAsync();
|
|
131
|
+
const afterFull = channel.previewYield;
|
|
132
|
+
// Both halves of the fix, and `retried` is what separates them from a
|
|
133
|
+
// channel that merely got lucky on a later product. Without the retry
|
|
134
|
+
// queue a deferred product is attempted once, at the most
|
|
135
|
+
// index-starved generation it appears in, and abandoned. Without
|
|
136
|
+
// preemptive rebuilds the generation is kept while products remain, so
|
|
137
|
+
// the deferrals are never revisited against the longer index. Either
|
|
138
|
+
// one missing holds this at zero.
|
|
139
|
+
expect(afterFull.retried).toBeGreaterThan(0);
|
|
140
|
+
expect(afterFull.emitted).toBeGreaterThan(0);
|
|
141
|
+
channel.stop();
|
|
142
|
+
}, 120000);
|
|
143
|
+
test("a generation is preempted only when it is deferring", async () => {
|
|
144
|
+
// The counterweight. Rebuilding on index growth alone regressed the
|
|
145
|
+
// case that already worked: PSB, which defers nothing, went from a
|
|
146
|
+
// first mesh at 269ms to 495ms because it kept paying for rebuilds it
|
|
147
|
+
// had no use for. index.ifc is that shape at fixture scale, so the
|
|
148
|
+
// same two-stage growth must leave its generation alone.
|
|
149
|
+
const store = new InMemoryStepByteStore(bytes);
|
|
150
|
+
const sink = new ColumnarIndexSink();
|
|
151
|
+
const channel = new StorePreviewChannel(store, sink, conwayGeometry, true, () => { }, 64, 48 * 1024 * 1024, 1);
|
|
152
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
153
|
+
const internals = channel;
|
|
154
|
+
// One product per tick, but with the wall clock lifted -- a zero
|
|
155
|
+
// budget would stop the tick before it ever built a generation.
|
|
156
|
+
internals.tickBudgetMs_ = Number.MAX_SAFE_INTEGER;
|
|
157
|
+
internals.tickMaxAttempts_ = 1;
|
|
158
|
+
expect(buildIndexStreaming(new BufferByteSource(bytes), IfcStepParser.Instance, 4 * 1024, void 0, sink).result).toBe(ParseResult.COMPLETE);
|
|
159
|
+
internals.lastInlineTick_ = 0;
|
|
160
|
+
await channel.maybeTickAsync();
|
|
161
|
+
const snapshotRecords = internals.lastSnapshotRecords_;
|
|
162
|
+
expect(snapshotRecords).toBeGreaterThan(0);
|
|
163
|
+
expect(channel.previewYield.deferredOnPlacement).toBe(0);
|
|
164
|
+
// Index growth well past GENERATION_GROWTH_FACTOR, with products still
|
|
165
|
+
// pending and nothing deferring: the generation must be kept.
|
|
166
|
+
sink.reset();
|
|
167
|
+
for (let repeat = 0; repeat < 3; ++repeat) {
|
|
168
|
+
expect(buildIndexStreaming(new BufferByteSource(bytes), IfcStepParser.Instance, 4 * 1024, void 0, sink).result).toBe(ParseResult.COMPLETE);
|
|
169
|
+
}
|
|
170
|
+
expect(sink.topLevelCount).toBeGreaterThan(snapshotRecords * 2);
|
|
171
|
+
internals.lastInlineTick_ = 0;
|
|
172
|
+
await channel.maybeTickAsync();
|
|
173
|
+
expect(internals.lastSnapshotRecords_).toBe(snapshotRecords);
|
|
174
|
+
expect(channel.previewYield.retried).toBe(0);
|
|
175
|
+
channel.stop();
|
|
176
|
+
}, 120000);
|
|
177
|
+
test("a throwing prefix build retires the attempt, not the channel", async () => {
|
|
178
|
+
// A mid-parse prefix can be structurally incomplete, and building a
|
|
179
|
+
// generation over it throws. That must retire THIS attempt and then
|
|
180
|
+
// wait for index growth: without the wait the channel re-snapshots and
|
|
181
|
+
// re-throws on every tick, which is a snapshot copy of the whole prefix
|
|
182
|
+
// per tick on a file large enough to matter.
|
|
183
|
+
//
|
|
184
|
+
// The gate has to hold with NO active generation, because a build that
|
|
185
|
+
// throws is exactly the case that leaves none — that is why it is
|
|
186
|
+
// tested on its own rather than folded in with the growth gate.
|
|
187
|
+
const store = new InMemoryStepByteStore(bytes);
|
|
188
|
+
const sink = new ColumnarIndexSink();
|
|
189
|
+
expect(buildIndexStreaming(new BufferByteSource(bytes), IfcStepParser.Instance, 4 * 1024, void 0, sink).result).toBe(ParseResult.COMPLETE);
|
|
190
|
+
let reportedRecords = sink.topLevelCount;
|
|
191
|
+
let snapshots = 0;
|
|
192
|
+
const flakySink = {
|
|
193
|
+
get topLevelCount() {
|
|
194
|
+
return reportedRecords;
|
|
195
|
+
},
|
|
196
|
+
snapshot: () => {
|
|
197
|
+
if (++snapshots === 1) {
|
|
198
|
+
throw new Error("structurally incomplete prefix");
|
|
199
|
+
}
|
|
200
|
+
return sink.snapshot();
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
const payloads = [];
|
|
204
|
+
const channel = new StorePreviewChannel(store, flakySink, conwayGeometry, true, (mesh) => payloads.push(mesh), 64, 48 * 1024 * 1024, 1);
|
|
205
|
+
await channel.drainForTest();
|
|
206
|
+
expect(snapshots).toBe(1);
|
|
207
|
+
expect(payloads.length).toBe(0);
|
|
208
|
+
// Same record count: the failure gate must hold the retry.
|
|
209
|
+
await channel.drainForTest();
|
|
210
|
+
expect(snapshots).toBe(1);
|
|
211
|
+
// The index grows past the gate: the retry builds and emits.
|
|
212
|
+
reportedRecords *= 4;
|
|
213
|
+
await channel.drainForTest();
|
|
214
|
+
expect(snapshots).toBe(2);
|
|
215
|
+
expect(payloads.length).toBeGreaterThan(0);
|
|
216
|
+
channel.stop();
|
|
217
|
+
}, 120000);
|
|
76
218
|
test("an unproductive tick does not decay the cadence", async () => {
|
|
77
219
|
const sink = new ColumnarIndexSink();
|
|
78
220
|
// Nothing parsed into the sink, so no generation can build. Every
|
|
@@ -231,4 +373,131 @@ describe("StorePreviewChannel", () => {
|
|
|
231
373
|
expect(spatial[0].color).toEqual({ x: 0, y: 0, z: 0, w: 0.3 });
|
|
232
374
|
api.CloseModel(id);
|
|
233
375
|
});
|
|
376
|
+
test("preemption preserves the unconsumed suffix of the retry queue", async () => {
|
|
377
|
+
// Codex round 1 on #543: preemption replaced retryQueue_ outright with
|
|
378
|
+
// this generation's fresh deferredForRetry_, discarding whatever the
|
|
379
|
+
// OLD queue's tail had not yet been popped. A retry queue can easily
|
|
380
|
+
// span more than one tick's TICK_MAX_ATTEMPTS budget, so index growth
|
|
381
|
+
// can preempt a generation mid-drain of it — and every un-popped entry
|
|
382
|
+
// is a product whose unitOrdinal_ has already passed, so replacing
|
|
383
|
+
// rather than concatenating strands it exactly the way abandoned
|
|
384
|
+
// deferrals were stranded before this queue existed (conway#542).
|
|
385
|
+
//
|
|
386
|
+
// The bug is purely mechanical (which array wins the assignment), so
|
|
387
|
+
// this seeds the private queue state directly and forces the growth
|
|
388
|
+
// gate via lastSnapshotRecords_, rather than engineering a real
|
|
389
|
+
// multi-tick deferral sequence — store_preview_channel.ts's extraction
|
|
390
|
+
// seam isn't pluggable the way the resident channel's
|
|
391
|
+
// PreviewSchemaAdapter is, so a real gen1/gen2 pair here means real
|
|
392
|
+
// IfcStepModel/IfcGeometryExtraction builds regardless.
|
|
393
|
+
const store = new InMemoryStepByteStore(bytes);
|
|
394
|
+
const sink = new ColumnarIndexSink();
|
|
395
|
+
expect(buildIndexStreaming(new BufferByteSource(bytes), IfcStepParser.Instance, 4 * 1024, void 0, sink).result).toBe(ParseResult.COMPLETE);
|
|
396
|
+
const channel = new StorePreviewChannel(store, sink, conwayGeometry, true, () => { }, 64, 48 * 1024 * 1024, 1);
|
|
397
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
398
|
+
const internals = channel;
|
|
399
|
+
// Generation 1: a real, successful build (active must be defined for
|
|
400
|
+
// the preemption branch to even be reachable).
|
|
401
|
+
expect(await internals.ensureGeneration_()).toBe(true);
|
|
402
|
+
// A retry queue spanning more than one tick's budget: 6 queued
|
|
403
|
+
// retries, only the first 3 consumed (retryCursor_ short of
|
|
404
|
+
// retryQueue_.length is exactly "mid-drain") before the index grew
|
|
405
|
+
// again. deferredForRetry_ is this generation's OWN fresh deferral —
|
|
406
|
+
// the only thing the buggy version kept.
|
|
407
|
+
internals.retryQueue_ = [10, 11, 12, 13, 14, 15];
|
|
408
|
+
internals.retryCursor_ = 3;
|
|
409
|
+
internals.deferredForRetry_ = [99];
|
|
410
|
+
// Force the growth gate trivially open on the next check, without
|
|
411
|
+
// needing the sink to actually grow: the gate is a ratio against
|
|
412
|
+
// lastSnapshotRecords_, not an absolute count.
|
|
413
|
+
internals.lastSnapshotRecords_ = 1;
|
|
414
|
+
// Preemption fires: active is defined, growthReady (real topLevelCount
|
|
415
|
+
// >= 1 * factor), and deferredForRetry_.length > 0.
|
|
416
|
+
expect(await internals.ensureGeneration_()).toBe(true);
|
|
417
|
+
// The unconsumed suffix of the old queue (indices 3..5) must survive,
|
|
418
|
+
// carried ahead of this generation's own fresh deferral — not replaced
|
|
419
|
+
// by it.
|
|
420
|
+
expect(internals.retryQueue_).toEqual([13, 14, 15, 99]);
|
|
421
|
+
expect(internals.retryCursor_).toBe(0);
|
|
422
|
+
channel.stop();
|
|
423
|
+
}, 120000);
|
|
424
|
+
test("a permanently malformed placement never enters the retry queue", async () => {
|
|
425
|
+
// Codex round 1 on #543: extractPlacementStrict_ tagged EVERY throw out
|
|
426
|
+
// of the strict placement read as DanglingPlacementError, so a placement
|
|
427
|
+
// that is broken rather than unscanned was classified as a file-layout
|
|
428
|
+
// deferral. That is not a cosmetic mislabel: the tag is what puts a
|
|
429
|
+
// product in deferredForRetry_, and a non-empty deferredForRetry_ is
|
|
430
|
+
// also the trigger for early generation PREEMPTION — so one malformed
|
|
431
|
+
// placement bought a full model/extraction rebuild on every growth step
|
|
432
|
+
// for a retry that could never come good.
|
|
433
|
+
//
|
|
434
|
+
// Two products, two causes, one file, so the assertion is about the
|
|
435
|
+
// SPLIT rather than about either case alone:
|
|
436
|
+
//
|
|
437
|
+
// #334 RelativePlacement -> #345, an IFCPOLYGONALFACESET. Indexed
|
|
438
|
+
// and present, but the field is an IfcAxis2Placement select, so
|
|
439
|
+
// IfcLocalPlacement's generated getter rejects it. No amount of
|
|
440
|
+
// further index changes that answer.
|
|
441
|
+
// #408 Location -> #999999, which no record in the file defines.
|
|
442
|
+
// Absent from the index, which on a prefix is exactly "not
|
|
443
|
+
// scanned yet" — the case that must still defer and retry.
|
|
444
|
+
const text = fs.readFileSync("data/index.ifc", "utf8");
|
|
445
|
+
expect(text).toContain("#334= IFCLOCALPLACEMENT(#152,#333);");
|
|
446
|
+
expect(text).toContain("#408= IFCAXIS2PLACEMENT3D(#406,#404,#402);");
|
|
447
|
+
expect(text).toContain("#345= IFCPOLYGONALFACESET(");
|
|
448
|
+
expect(text).not.toContain("#999999=");
|
|
449
|
+
const brokenText = text
|
|
450
|
+
.replace("#334= IFCLOCALPLACEMENT(#152,#333);", "#334= IFCLOCALPLACEMENT(#152,#345);")
|
|
451
|
+
.replace("#408= IFCAXIS2PLACEMENT3D(#406,#404,#402);", "#408= IFCAXIS2PLACEMENT3D(#999999,#404,#402);");
|
|
452
|
+
const brokenBytes = new Uint8Array(Buffer.from(brokenText, "latin1"));
|
|
453
|
+
const store = new InMemoryStepByteStore(brokenBytes);
|
|
454
|
+
const sink = new ColumnarIndexSink();
|
|
455
|
+
// The COMPLETE index, deliberately: it takes prefix-vs-tail out of the
|
|
456
|
+
// picture, so the only thing left to explain a deferral is the cause of
|
|
457
|
+
// the throw. It also means no generation is ever rebuilt (the record
|
|
458
|
+
// count cannot grow past the growth factor again), so the retry queue
|
|
459
|
+
// this inspects is the one the tick built and never drained.
|
|
460
|
+
expect(buildIndexStreaming(new BufferByteSource(brokenBytes), IfcStepParser.Instance, 4 * 1024, void 0, sink).result).toBe(ParseResult.COMPLETE);
|
|
461
|
+
const channel = new StorePreviewChannel(store, sink, conwayGeometry, true, () => { }, 64, 48 * 1024 * 1024, 1);
|
|
462
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
463
|
+
const internals = channel;
|
|
464
|
+
await channel.drainForTest();
|
|
465
|
+
const observed = channel.previewYield;
|
|
466
|
+
// Both products fail to extract — the split is in how they are counted,
|
|
467
|
+
// and in which of them is worth another attempt.
|
|
468
|
+
expect(observed.deferred).toBe(2);
|
|
469
|
+
expect(observed.deferredOnPlacement).toBe(1);
|
|
470
|
+
expect(internals.deferredForRetry_).toHaveLength(1);
|
|
471
|
+
// And the rest of the file is unaffected: these two are the only
|
|
472
|
+
// products that lost their geometry.
|
|
473
|
+
expect(observed.emitted).toBe(channel.productCount - 2);
|
|
474
|
+
channel.stop();
|
|
475
|
+
}, 120000);
|
|
476
|
+
test("reports a preview line even when nothing was ever attempted", () => {
|
|
477
|
+
// Codex round 1 on #543: stop() suppressed the Preview line unless
|
|
478
|
+
// emittedUnits_ or deferredUnits_ was nonzero. A channel that never
|
|
479
|
+
// reached firstGenerationMinRecords -- or whose every generation build
|
|
480
|
+
// threw -- leaves both at zero, and that is exactly the worst-case
|
|
481
|
+
// blank-first-load conway#542 exists to make diagnosable: an enabled
|
|
482
|
+
// preview that produced nothing must not read the same as a preview
|
|
483
|
+
// that never ran. formatPreviewLine already renders the zero case as
|
|
484
|
+
// "no mesh, 0 meshes from 0 units, 0 deferred".
|
|
485
|
+
const store = new InMemoryStepByteStore(bytes);
|
|
486
|
+
const emptySink = {
|
|
487
|
+
get topLevelCount() {
|
|
488
|
+
return 0;
|
|
489
|
+
},
|
|
490
|
+
snapshot: () => {
|
|
491
|
+
throw new Error("must not be called: topLevelCount never grows");
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
const infoSpy = jest.spyOn(Logger, "info").mockImplementation(() => { });
|
|
495
|
+
// Default firstGenerationMinRecords (1024): with topLevelCount pinned
|
|
496
|
+
// at 0, ensureGeneration_ never builds a generation, so the channel
|
|
497
|
+
// reaches stop() having attempted nothing at all.
|
|
498
|
+
const channel = new StorePreviewChannel(store, emptySink, conwayGeometry, false, () => { });
|
|
499
|
+
channel.stop();
|
|
500
|
+
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("Preview: no mesh, 0 meshes from 0 units, 0 deferred"));
|
|
501
|
+
infoSpy.mockRestore();
|
|
502
|
+
});
|
|
234
503
|
});
|