fhirsmith 0.11.0 → 0.11.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/CHANGELOG.md CHANGED
@@ -5,6 +5,55 @@ All notable changes to the Health Intersections Node Server will be documented i
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 0.11.2 - 2026-08-12
9
+
10
+ ### Fixed
11
+
12
+ - Update snomed test content
13
+
14
+ ### Fixed
15
+
16
+ - Fix snomed designation handling for languages
17
+ - Fix for SSEF protection in the validator-
18
+
19
+ ### Tx Conformance Statement
20
+
21
+ FHIRsmith passed all 2729 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.3, runner v6.10.1)
22
+
23
+ ## [0.11.1] - 2026-07-30
24
+
25
+ ### Added
26
+
27
+ - Cooperative yielding for long terminology operations: after every 25ms of continuous compute the
28
+ operation yields the event loop (`checkAndYield`), so a heavy $expand can no longer stall every
29
+ other request on the server. Operations whose client has disconnected are aborted at their next
30
+ yield point instead of computing a response nobody will read
31
+ - The operation time limit is now configurable (`operationTimeLimit`, seconds) and defaults to 20s
32
+ (below common client timeouts, so clients see the too-costly OperationOutcome rather than a
33
+ socket timeout), and is measured against compute time consumed rather than wall-clock, so
34
+ concurrent operations time-sharing the event loop don't abort each other
35
+ - The Node Blocking chart on the status page now shows the worst single event-loop stall per
36
+ window as well as the mean
37
+
38
+ ### Fixed
39
+
40
+ - fix whole-code-system SNOMED CT expansions blocking the server for 30 seconds and then failing:
41
+ the hierarchy walk read a non-existent `.code` property, so every concept was treated as a
42
+ duplicate of the first and no expansion size guard could ever stop the walk. This is what caused
43
+ the rash of client timeouts on tx.fhir.org after the 0.11.0 upgrade
44
+ - the hierarchy walk now visits each concept once rather than once per path to it (SNOMED CT is a
45
+ poly-hierarchy: the old walk made ~15.8M visits for ~520k concepts)
46
+ - restore the up-front too-costly refusal for expansions that cannot fit the expansion limit (the
47
+ totalCount size guard was silently dead - method read as a property); expansions with a
48
+ count/offset window inside the limit still succeed as partial, unclosed expansions
49
+ - re-enable the CPT 1000-code expansion cap (same method-read-as-property bug)
50
+ - fix parameter misalignment in the no-details expansion path (19 arguments passed to a
51
+ 20-parameter function)
52
+
53
+ ### Tx Conformance Statement
54
+
55
+ FHIRsmith passed all 2729 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.2, runner v6.9.12)
56
+
8
57
  ## [0.11.0] - 2026-mm-dd
9
58
 
10
59
  ### Added
@@ -2,6 +2,8 @@ const fs = require('fs').promises;
2
2
  const path = require('path');
3
3
  const { PackageContentLoader } = require('./package-manager');
4
4
 
5
+ const TERMINOLOGY_TYPES = ['CodeSystem', 'ValueSet', 'ConceptMap'];
6
+
5
7
  /**
6
8
  * A content loader that scans a folder for JSON FHIR terminology resources
7
9
  * (CodeSystem, ValueSet, ConceptMap). It synthesizes the package layout that
@@ -26,14 +28,27 @@ class FolderContentLoader extends PackageContentLoader {
26
28
  }
27
29
 
28
30
  /**
29
- * Replaces the standard initialize: there is no package.json or .index.json
30
- * to read, so we synthesize an index from the JSON files in the folder.
31
+ * The synthesized package name reported by id()/pid().
32
+ * @returns {string}
31
33
  */
32
- async initialize() {
33
- if (this.loaded) {
34
- return;
35
- }
34
+ _packageName() {
35
+ return 'folder.' + path.basename(this.sourceFolder);
36
+ }
37
+
38
+ /**
39
+ * Whether a file that can't be parsed, or that isn't a terminology resource,
40
+ * should be quietly skipped. True for folder scans (the folder may hold all
41
+ * sorts of things); false when a specific file was nominated.
42
+ * @returns {boolean}
43
+ */
44
+ get _skipInvalidFiles() {
45
+ return true;
46
+ }
36
47
 
48
+ /**
49
+ * Check the source exists and is the right kind of thing.
50
+ */
51
+ async _validateSource() {
37
52
  try {
38
53
  const stat = await fs.stat(this.sourceFolder);
39
54
  if (!stat.isDirectory()) {
@@ -45,36 +60,63 @@ class FolderContentLoader extends PackageContentLoader {
45
60
  }
46
61
  throw err;
47
62
  }
63
+ }
64
+
65
+ /**
66
+ * The file names (relative to sourceFolder) to consider.
67
+ * @returns {Promise<Array<string>>}
68
+ */
69
+ async _candidateFiles() {
70
+ const names = [];
71
+ const entries = await fs.readdir(this.sourceFolder, { withFileTypes: true });
72
+ for (const entry of entries) {
73
+ if (!entry.isFile()) continue;
74
+ if (!entry.name.toLowerCase().endsWith('.json')) continue;
75
+ names.push(entry.name);
76
+ }
77
+ return names;
78
+ }
79
+
80
+ /**
81
+ * Replaces the standard initialize: there is no package.json or .index.json
82
+ * to read, so we synthesize an index from the JSON files in the source.
83
+ */
84
+ async initialize() {
85
+ if (this.loaded) {
86
+ return;
87
+ }
88
+
89
+ await this._validateSource();
48
90
 
49
91
  // Synthesize package metadata so id()/version()/pid() return something usable.
50
92
  this.package = {
51
- name: 'folder.' + path.basename(this.sourceFolder),
93
+ name: this._packageName(),
52
94
  version: '0.0.0',
53
95
  fhirVersions: ['5.0.0']
54
96
  };
55
97
 
56
98
  const files = [];
57
- const entries = await fs.readdir(this.sourceFolder, { withFileTypes: true });
58
- for (const entry of entries) {
59
- if (!entry.isFile()) continue;
60
- if (!entry.name.toLowerCase().endsWith('.json')) continue;
61
-
62
- const fullPath = path.join(this.sourceFolder, entry.name);
99
+ for (const name of await this._candidateFiles()) {
100
+ const fullPath = path.join(this.sourceFolder, name);
63
101
  let json;
64
102
  try {
65
103
  const content = await fs.readFile(fullPath, 'utf8');
66
104
  json = JSON.parse(content);
67
- } catch {
68
- // Skip unreadable / non-JSON files rather than failing the whole load.
69
- continue;
105
+ } catch (e) {
106
+ // Skip unreadable / non-JSON files rather than failing the whole load,
107
+ // unless this exact file was the nominated source.
108
+ if (this._skipInvalidFiles) continue;
109
+ throw new Error(`Unable to read FHIR resource from ${fullPath}: ${e.message}`);
70
110
  }
71
111
 
72
- if (!json || typeof json !== 'object') continue;
73
- const rt = json.resourceType;
74
- if (rt !== 'CodeSystem' && rt !== 'ValueSet' && rt !== 'ConceptMap') continue;
112
+ const rt = json && typeof json === 'object' ? json.resourceType : null;
113
+ if (!TERMINOLOGY_TYPES.includes(rt)) {
114
+ if (this._skipInvalidFiles) continue;
115
+ throw new Error(`${fullPath} is not a CodeSystem, ValueSet or ConceptMap (resourceType = ${rt || 'none'})`);
116
+ }
75
117
 
76
118
  files.push({
77
- filename: entry.name,
119
+ filename: name,
78
120
  resourceType: rt,
79
121
  id: json.id,
80
122
  url: json.url,
@@ -88,4 +130,52 @@ class FolderContentLoader extends PackageContentLoader {
88
130
  }
89
131
  }
90
132
 
91
- module.exports = { FolderContentLoader };
133
+ /**
134
+ * A content loader for a single nominated JSON file holding one CodeSystem,
135
+ * ValueSet or ConceptMap. Everything else works exactly as for a folder source:
136
+ * the file's own directory is the read-only content root, and the provider
137
+ * caches live in the supplied cacheFolder.
138
+ *
139
+ * Unlike a folder scan, a bad file is an error rather than something to skip -
140
+ * the config named this file explicitly, so failing silently would just hide
141
+ * a typo.
142
+ */
143
+ class FileContentLoader extends FolderContentLoader {
144
+ /**
145
+ * @param {string} filePath - The JSON file to load
146
+ * @param {string} cacheFolder - Folder where Package*Provider DBs may live
147
+ */
148
+ constructor(filePath, cacheFolder) {
149
+ super(path.dirname(filePath), cacheFolder);
150
+ this.filePath = filePath;
151
+ this.fileName = path.basename(filePath);
152
+ }
153
+
154
+ _packageName() {
155
+ return 'file.' + this.fileName.replace(/\.json$/i, '');
156
+ }
157
+
158
+ get _skipInvalidFiles() {
159
+ return false;
160
+ }
161
+
162
+ async _validateSource() {
163
+ try {
164
+ const stat = await fs.stat(this.filePath);
165
+ if (!stat.isFile()) {
166
+ throw new Error(`File source path is not a file: ${this.filePath}`);
167
+ }
168
+ } catch (err) {
169
+ if (err.code === 'ENOENT') {
170
+ throw new Error(`File source path does not exist: ${this.filePath}`);
171
+ }
172
+ throw err;
173
+ }
174
+ }
175
+
176
+ async _candidateFiles() {
177
+ return [this.fileName];
178
+ }
179
+ }
180
+
181
+ module.exports = { FolderContentLoader, FileContentLoader };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.11.0",
4
- "txVersion": "1.9.1",
3
+ "version": "0.11.2",
4
+ "txVersion": "1.9.3",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
7
7
  "engines": {
@@ -46,7 +46,7 @@
46
46
  "express-rate-limit": "^7.4.1",
47
47
  "express-session": "^1.19.0",
48
48
  "fast-xml-parser": "^5.3.4",
49
- "fhir-validator-wrapper": "1.2.2",
49
+ "fhir-validator-wrapper": "1.3.0",
50
50
  "fhirpath": "^4.8.3",
51
51
  "fs-extra": "^11.3.3",
52
52
  "ini": "^6.0.0",
package/stats.js CHANGED
@@ -37,6 +37,10 @@ class ServerStats {
37
37
  const requestsPerMin = minutesSinceStart > 0 ? requestsDelta / minutesSinceStart : 0;
38
38
 
39
39
  const loopDelay = this.eventLoopMonitor.mean / 1e6;
40
+ // Worst single stall in the window - the mean dilutes a multi-second
41
+ // event-loop block into noise; the max is what shows request-freezing
42
+ // stalls (see the 2026-07-30 tx.fhir.org incident).
43
+ const loopMax = this.eventLoopMonitor.max / 1e6;
40
44
  // Two distinct caches: the expansion cache (count entries) and the client
41
45
  // (resource) cache (count concepts held - a sense of how much it's carrying).
42
46
  let expansionItems = 0;
@@ -50,7 +54,7 @@ class ServerStats {
50
54
  }
51
55
  }
52
56
 
53
- this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, block: loopDelay, expansion: expansionItems, clientConcepts: clientConcepts});
57
+ this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, block: loopDelay, blockMax: loopMax, expansion: expansionItems, clientConcepts: clientConcepts});
54
58
 
55
59
  this.eventLoopMonitor.reset();
56
60
  this.requestCountSnapshot = combinedCount;
package/tx/cs/cs-api.js CHANGED
@@ -404,9 +404,15 @@ class CodeSystemProvider {
404
404
  * @param {boolean} [significantOnly=false] - when true, emit only the
405
405
  * designations that are significant for a value set expansion (the display,
406
406
  * and - for code systems that distinguish them - the fully specified name
407
- * and the preferred term), rather than every designation in every language.
408
- * The $expand worker passes true; $lookup uses the default (false) to return
409
- * the complete set. Providers that do not distinguish significant
407
+ * and the preferred term), rather than every designation. This narrows which
408
+ * designations are emitted, NOT which languages: the display for a request is
409
+ * chosen downstream by preferredDesignation(displayLanguage), so a provider
410
+ * that emits significant designations for one language only makes localized
411
+ * expansions fall back to that language. Emit the significant designations
412
+ * for every language the concept has.
413
+ * The $expand worker passes true when the client did not ask for
414
+ * designations; $lookup and includeDesignations=true use the default (false)
415
+ * to return the complete set. Providers that do not distinguish significant
410
416
  * designations may ignore this parameter and return all designations either
411
417
  * way.
412
418
  * @returns {Designation[]} whatever designations exist (in all languages)
@@ -1605,13 +1605,25 @@ class SnomedProvider extends BaseCSServices {
1605
1605
  const K = this.sct._displayConstants();
1606
1606
 
1607
1607
  if (significantOnly) {
1608
- // $expand: emit the "significant" designations only — the FSN plus
1609
- // the preferred synonym (US English), active descriptions only.
1610
- // The preferred synonym is also the display, so it is added as the
1611
- // display designation (which the expand worker selects for
1612
- // `display` and skips from the emitted list) and additionally as a
1613
- // Synonym designation so it still appears in the designation array.
1614
- let fsn = null, prefSyn = null;
1608
+ // $expand: emit the "significant" designations only — the FSN and
1609
+ // the preferred synonym, active descriptions only. Acceptable
1610
+ // synonyms are the bulk of a SNOMED concept's descriptions and are
1611
+ // not needed to render an expansion, so they are dropped.
1612
+ //
1613
+ // One per LANGUAGE, though, not one overall: the display for a
1614
+ // request is chosen downstream by
1615
+ // Designations.preferredDesignation(displayLanguage), so a concept
1616
+ // that emits only its English terms has nothing for a
1617
+ // displayLanguage=de request to match and silently falls back to
1618
+ // English. Keeping the preferred synonym in every language the
1619
+ // concept has is what makes localized expansions work at all
1620
+ // (e.g. the Swiss edition, whose German terms are preferred in the
1621
+ // Swiss German language refset 2041000195100).
1622
+ //
1623
+ // The edition-default display is also added as the display
1624
+ // designation (which the expand worker selects for `display` when
1625
+ // no language was asked for, and skips from the emitted list).
1626
+ const fsns = new Map(), prefSyns = new Map();
1615
1627
  for (const descIndex of descriptionIndices) {
1616
1628
  const description = this.sct.descriptions.getDescription(descIndex);
1617
1629
  if (!description.active) continue;
@@ -1620,15 +1632,20 @@ class SnomedProvider extends BaseCSServices {
1620
1632
  const kind = this.sct.concepts.getConcept(description.kind);
1621
1633
  const use = { system: 'http://snomed.info/sct', code: String(kind.identity), display: this.sct.getDisplayName(description.kind) };
1622
1634
  if (description.kind === K.fsn) {
1623
- if (!fsn) fsn = { langCode, use, term };
1635
+ if (!fsns.has(langCode)) fsns.set(langCode, { langCode, use, term });
1624
1636
  } else if (this.sct._synonymIsPreferred(description)) {
1625
- if (!prefSyn) prefSyn = { langCode, use, term };
1637
+ if (!prefSyns.has(langCode)) prefSyns.set(langCode, { langCode, use, term });
1626
1638
  }
1627
1639
  }
1628
1640
  const display = this.sct.getDisplayName(ctxt.getReference());
1629
1641
  if (display) displays.addDesignation(true, 'active', 'en-US', null, display);
1630
- if (fsn) displays.addDesignation(false, 'active', fsn.langCode, fsn.use, fsn.term);
1631
- if (prefSyn) displays.addDesignation(false, 'active', prefSyn.langCode, prefSyn.use, prefSyn.term);
1642
+ // FSNs before synonyms, matching the designation order the published
1643
+ // tx-ecosystem expectations were written against (sct/expand-inactive).
1644
+ // Which of the two is chosen as the display is settled by
1645
+ // Designations.preferredDesignation(), which ranks the SNOMED synonym
1646
+ // above the FSN - not by the order they are emitted in.
1647
+ for (const d of fsns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
1648
+ for (const d of prefSyns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
1632
1649
  } else {
1633
1650
  // $lookup: emit every description (preferred synonym first so the
1634
1651
  // display resolves correctly; order is not otherwise significant).
@@ -221,23 +221,38 @@
221
221
  data: {
222
222
  labels: historyData.map(d => formatTime(d.time)),
223
223
  datasets: [{
224
+ label: 'mean',
224
225
  data: historyData.map(d => d.block),
225
226
  borderColor: '#2845a7',
226
227
  backgroundColor: 'rgba(40, 69, 167, 0.1)',
227
228
  fill: true,
228
229
  tension: 0.3,
229
230
  pointRadius: 2
231
+ }, {
232
+ // Worst single event-loop stall in the window: the mean dilutes a
233
+ // multi-second block into noise, so this is the line that shows
234
+ // request-freezing stalls.
235
+ label: 'max',
236
+ data: historyData.map(d => d.blockMax),
237
+ borderColor: '#a72828',
238
+ fill: false,
239
+ tension: 0.3,
240
+ pointRadius: 2
230
241
  }]
231
242
  },
232
243
  options: {
233
244
  ...chartOptions,
245
+ plugins: {
246
+ ...chartOptions.plugins,
247
+ legend: { display: true }
248
+ },
234
249
  scales: {
235
250
  ...chartOptions.scales,
236
251
  y: {
237
252
  ...chartOptions.scales.y,
238
253
  title: {
239
254
  display: true,
240
- text: 'points'
255
+ text: 'ms'
241
256
  }
242
257
  }
243
258
  }
@@ -610,8 +610,15 @@ class Designations {
610
610
 
611
611
  for (const activeOnly of [true, false]) {
612
612
  for (const matchType of matchTypes) {
613
+ // _isPreferred before isDisplay, matching the no-language branch above.
614
+ // The two sets differ only where a provider's own isDisplay() accepts a
615
+ // designation that isn't a preferred term - today that is the SNOMED FSN,
616
+ // which isDisplay() accepts and _isPreferred() deliberately does not. Testing
617
+ // isDisplay first made a request for a language whose concept has both an FSN
618
+ // and a preferred synonym display the "(disorder)"-suffixed FSN, purely
619
+ // because the FSN was emitted first.
613
620
  for (const cd of this.designations) {
614
- if (this._langMatches(lang, cd.language, matchType) && this.isDisplay(cd) && (!activeOnly || cd.isActive())) {
621
+ if (this._langMatches(lang, cd.language, matchType) && this._isPreferred(cd) && (!activeOnly || cd.isActive())) {
615
622
  if (supplements && cd.source) {
616
623
  supplements.add(cd.source);
617
624
  }
@@ -619,7 +626,7 @@ class Designations {
619
626
  }
620
627
  }
621
628
  for (const cd of this.designations) {
622
- if (this._langMatches(lang, cd.language, matchType) && this._isPreferred(cd)&& (!activeOnly || cd.isActive())) {
629
+ if (this._langMatches(lang, cd.language, matchType) && this.isDisplay(cd) && (!activeOnly || cd.isActive())) {
623
630
  if (supplements && cd.source) {
624
631
  supplements.add(cd.source);
625
632
  }
package/tx/library.js CHANGED
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const crypto = require('crypto');
4
4
  const yaml = require('yaml'); // npm install yaml
5
5
  const { PackageManager, PackageContentLoader } = require('../library/package-manager');
6
- const { FolderContentLoader } = require('../library/folder-content-loader');
6
+ const { FolderContentLoader, FileContentLoader } = require('../library/folder-content-loader');
7
7
  const { CodeSystem } = require("./library/codesystem");
8
8
  const {CountryCodeFactoryProvider} = require("./cs/cs-country");
9
9
  const {Iso4217FactoryProvider} = require("./cs/cs-currency");
@@ -301,6 +301,10 @@ class Library {
301
301
  await this.loadFolder(details, isDefault, mode);
302
302
  break;
303
303
 
304
+ case 'file':
305
+ await this.loadFile(details, isDefault, mode);
306
+ break;
307
+
304
308
  default:
305
309
  throw new Error(`Unknown source type: ${type}`);
306
310
  }
@@ -679,19 +683,68 @@ class Library {
679
683
  return;
680
684
  }
681
685
 
682
- const folderPath = path.isAbsolute(details)
683
- ? details
684
- : path.resolve(path.join(__dirname, '..', details));
686
+ const folderPath = this.#resolveLocalSourcePath(details);
687
+ const cacheSubdir = await this.#prepareLocalSourceCache(folderPath);
688
+ await this.#loadLocalSource(new FolderContentLoader(folderPath, cacheSubdir));
689
+ }
690
+
691
+ /**
692
+ * Loads a single nominated JSON file holding a CodeSystem, ValueSet or ConceptMap
693
+ * (source syntax: `file:<path>`). The resource is routed to the appropriate provider
694
+ * based on its resourceType.
695
+ *
696
+ * Unlike a folder source, the file must exist and must be one of the three terminology
697
+ * resource types - anything else is an error, since the config named it explicitly.
698
+ *
699
+ * Relative paths are resolved against the project root (same convention as loadUcum etc.).
700
+ *
701
+ * @param {string} details - The file to load
702
+ * @param {boolean} isDefault - Unused; file sources don't register factories
703
+ * @param {string} mode - One of "fetch", "cs", "npm"
704
+ */
705
+ // eslint-disable-next-line no-unused-vars
706
+ async loadFile(details, isDefault, mode) {
707
+ if (mode === "fetch" || mode === "cs") {
708
+ return;
709
+ }
710
+
711
+ const filePath = this.#resolveLocalSourcePath(details);
712
+ const cacheSubdir = await this.#prepareLocalSourceCache(filePath);
713
+ await this.#loadLocalSource(new FileContentLoader(filePath, cacheSubdir));
714
+ }
715
+
716
+ /**
717
+ * Relative paths in folder:/file: sources are resolved against the project root.
718
+ * @private
719
+ */
720
+ #resolveLocalSourcePath(details) {
721
+ const text = String(details || '').trim();
722
+ if (!text) {
723
+ throw new Error('A local source requires a path, e.g. file:tx/data/my-valueset.json');
724
+ }
725
+ return path.isAbsolute(text) ? text : path.resolve(path.join(__dirname, '..', text));
726
+ }
685
727
 
686
- // Park the Package*Provider SQLite caches under the terminology cache rather
687
- // than polluting the user's source folder, and wipe between runs so edits to
688
- // the source folder are reliably picked up.
689
- const hash = crypto.createHash('sha1').update(folderPath).digest('hex').substring(0, 16);
728
+ /**
729
+ * Parks the Package*Provider SQLite caches under the terminology cache rather
730
+ * than polluting the user's source folder, and wipes between runs so edits to
731
+ * the source are reliably picked up.
732
+ * @private
733
+ */
734
+ async #prepareLocalSourceCache(sourcePath) {
735
+ const hash = crypto.createHash('sha1').update(sourcePath).digest('hex').substring(0, 16);
690
736
  const cacheSubdir = path.join(this.cacheFolder, 'folder-source-' + hash);
691
737
  await fs.rm(cacheSubdir, { recursive: true, force: true });
692
738
  await fs.mkdir(cacheSubdir, { recursive: true });
739
+ return cacheSubdir;
740
+ }
693
741
 
694
- const contentLoader = new FolderContentLoader(folderPath, cacheSubdir);
742
+ /**
743
+ * Shared loader for folder- and file-sourced content: registers the loader's
744
+ * CodeSystems, ValueSets and ConceptMaps with this library.
745
+ * @private
746
+ */
747
+ async #loadLocalSource(contentLoader) {
695
748
  await contentLoader.initialize();
696
749
 
697
750
  this.packageSources.push(contentLoader.id() + "#" + contentLoader.version());
@@ -556,6 +556,15 @@ const MEMORY_LIMIT = readMemoryLimit();
556
556
  const MEMORY_FRACTION = 0.98;
557
557
  const MEMORY_THRESHOLD = MEMORY_LIMIT > 0 ? MEMORY_LIMIT * MEMORY_FRACTION : 0; // 90% of cgroup limit
558
558
  const CHECK_FREQUENCY = 100;
559
+ // How long an operation may compute without yielding the event loop (ms).
560
+ // Node runs all JS on one thread: while a long operation executes, no other
561
+ // request is serviced - awaiting in-memory promises does not help, because
562
+ // resolved promises only bounce through the microtask queue, which runs before
563
+ // I/O. checkAndYield() awaits a real setImmediate every YIELD_INTERVAL ms of
564
+ // compute, so pending I/O (new connections, /metadata, timers) runs between
565
+ // slices and one heavy $expand can no longer time out every other client.
566
+ const YIELD_INTERVAL = 25;
567
+ const { setImmediate: yieldToEventLoop } = require('timers/promises');
559
568
 
560
569
  class OperationContext {
561
570
  // Shared counter across all instances — only check RSS every CHECK_FREQUENCY calls
@@ -584,6 +593,24 @@ class OperationContext {
584
593
  // Shared by reference with copy()'d contexts so a sub-operation's
585
594
  // providers are cleaned up by the parent request's closeProviders().
586
595
  this._openProviders = [];
596
+ // Compute-time accounting for the deadline and for yielding. One object,
597
+ // shared by reference with copy()'d contexts, so an operation and its
598
+ // sub-operations (e.g. batch entries) draw down a single budget:
599
+ // - compute: ms of compute consumed in completed slices
600
+ // - sliceStart: when the current compute slice began
601
+ // - lastYield: when we last gave the event loop a turn
602
+ // - clientGone: set when the client disconnects before the response is sent
603
+ // The deadline is measured in COMPUTE time, not wall-clock: once operations
604
+ // time-share the event loop, wall-clock deadlines make concurrency itself
605
+ // cause aborts (N ops each take N x as long), which turns client retries
606
+ // into a failure amplifier. An operation that waited is not too costly;
607
+ // one that consumed its compute budget is.
608
+ this._clock = {
609
+ compute: 0,
610
+ sliceStart: this.startTime,
611
+ lastYield: this.startTime,
612
+ clientGone: false
613
+ };
587
614
 
588
615
  this.timeTracker.step('tx-op');
589
616
  }
@@ -616,6 +643,8 @@ class OperationContext {
616
643
  // Share the same provider-cleanup list so providers opened by the copy
617
644
  // are released when the parent operation ends.
618
645
  newContext._openProviders = this._openProviders;
646
+ // Share the compute clock: sub-operations spend the parent's budget.
647
+ newContext._clock = this._clock;
619
648
  return newContext;
620
649
  }
621
650
 
@@ -646,8 +675,10 @@ class OperationContext {
646
675
  }
647
676
  OperationContext._checkCounter = 0;
648
677
 
649
- // Time check
650
- const elapsed = performance.now() - this.startTime;
678
+ // Time check - against compute time consumed, not wall clock (see _clock in
679
+ // the constructor). For operations that never yield the two are identical,
680
+ // so this changes nothing for paths that don't use checkAndYield().
681
+ const elapsed = this.computeElapsed();
651
682
  if (elapsed > this.timeLimit) {
652
683
  const timeInSeconds = Math.round(this.timeLimit / 1000);
653
684
  this.log(`Operation took too long @ ${place} (${this.constructor.name})`);
@@ -674,6 +705,64 @@ class OperationContext {
674
705
  return false;
675
706
  }
676
707
 
708
+ /**
709
+ * Compute time this operation has consumed (ms): completed slices plus the
710
+ * slice currently executing. Equals wall-clock elapsed for operations that
711
+ * never yield.
712
+ * @returns {number}
713
+ */
714
+ computeElapsed() {
715
+ return this._clock.compute + (performance.now() - this._clock.sliceStart);
716
+ }
717
+
718
+ /**
719
+ * Mark that the client for this operation disconnected before the response
720
+ * was sent. The operation is aborted at its next yield point - there is no
721
+ * one left to send the result to. (Wired up by the request middleware from
722
+ * the response 'close' event.)
723
+ */
724
+ markClientGone() {
725
+ this._clock.clientGone = true;
726
+ }
727
+
728
+ /**
729
+ * deadCheck plus cooperative yielding: call this (awaited) from long-running
730
+ * loops in async code. Runs the normal deadCheck, and after every
731
+ * YIELD_INTERVAL ms of continuous compute awaits a real setImmediate so the
732
+ * event loop can service pending I/O - other requests keep getting answered
733
+ * while this operation works. On resuming from a yield, aborts if the client
734
+ * has disconnected in the meantime.
735
+ *
736
+ * Sync call sites keep using deadCheck(); the async loops that surround them
737
+ * yield on their behalf, which caps unbroken compute at one loop iteration.
738
+ *
739
+ * @param {string} place - Location identifier for debugging
740
+ */
741
+ async checkAndYield(place = 'unknown') {
742
+ this.deadCheck(place);
743
+ if (this.debugging) {
744
+ return;
745
+ }
746
+ const now = performance.now();
747
+ if (now - this._clock.lastYield >= YIELD_INTERVAL) {
748
+ // Close off this compute slice before suspending, so time spent waiting
749
+ // for our next turn is not charged against the deadline.
750
+ this._clock.compute += now - this._clock.sliceStart;
751
+ await yieldToEventLoop();
752
+ const resumed = performance.now();
753
+ this._clock.sliceStart = resumed;
754
+ this._clock.lastYield = resumed;
755
+ if (this._clock.clientGone) {
756
+ this.log(`Operation abandoned @ ${place}: client disconnected`);
757
+ const error = new Issue("error", "too-costly", null,
758
+ `Operation abandoned at ${place}: the client disconnected before the response was ready`);
759
+ error.abandoned = true;
760
+ error.diagnostics = this.diagnostics();
761
+ throw error;
762
+ }
763
+ }
764
+ }
765
+
677
766
  unSeeAll() {
678
767
  this.contexts = [];
679
768
  }
package/tx/tx.js CHANGED
@@ -291,9 +291,15 @@ class TXModule {
291
291
  // Get Accept-Language header for language preferences
292
292
  const acceptLanguage = req.get('Accept-Language') || 'en';
293
293
 
294
- // Create operation context with language, ID, time limit, and caches
294
+ // Create operation context with language, ID, time limit, and caches.
295
+ // The time limit is a compute-time budget (see OperationContext._clock)
296
+ // and is configurable (config.operationTimeLimit, seconds). The default
297
+ // sits deliberately under common client timeouts (okhttp reads give up at
298
+ // 10-30s): if the server's limit is >= the client's, the client hangs up
299
+ // first and the carefully-built too-costly OperationOutcome is written to
300
+ // a socket nobody is reading.
295
301
  const opContext = new OperationContext(
296
- acceptLanguage, this.i18n, requestId, 30,
302
+ acceptLanguage, this.i18n, requestId, this.config.operationTimeLimit ?? 20,
297
303
  endpointInfo.resourceCache, endpointInfo.expansionCache
298
304
  );
299
305
  opContext.usageTracker = this.usageTracker;
@@ -325,6 +331,16 @@ class TXModule {
325
331
  res.on('finish', releaseProviders);
326
332
  res.on('close', releaseProviders);
327
333
 
334
+ // If the connection closes before the response was completed, the client
335
+ // has given up (timeout, cancelled build, dropped connection). Mark the
336
+ // operation so it aborts at its next yield point instead of computing a
337
+ // response nobody will read.
338
+ res.on('close', () => {
339
+ if (!res.writableEnded) {
340
+ opContext.markClientGone();
341
+ }
342
+ });
343
+
328
344
  // Add X-Request-Id header to response
329
345
  res.setHeader('X-Request-Id', requestId);
330
346
 
@@ -314,14 +314,20 @@ class ValueSetExpander {
314
314
  return null;
315
315
  }
316
316
 
317
- if (cs != null && cs.expandLimitation > 0) {
318
- let cnt = this.csCounter.get(cs.system);
317
+ // expandLimitation() caps how many codes a single code system may contribute
318
+ // (e.g. CPT limits enumeration to 1000 codes by agreement with the AMA).
319
+ // NB: expandLimitation and system are methods - reading them as properties
320
+ // (cs.expandLimitation > 0) compares the function object and is always false,
321
+ // which silently disabled this cap.
322
+ const expandLimit = cs != null ? cs.expandLimitation() : 0;
323
+ if (expandLimit > 0) {
324
+ let cnt = this.csCounter.get(cs.system());
319
325
  if (cnt == null) {
320
326
  cnt = new ValueSetCounter();
321
- this.csCounter.set(cs.system, cnt);
327
+ this.csCounter.set(cs.system(), cnt);
322
328
  }
323
329
  cnt.increment();
324
- if (cnt.count > cs.expandLimitation) {
330
+ if (cnt.count > expandLimit) {
325
331
  return null;
326
332
  }
327
333
  }
@@ -566,7 +572,7 @@ class ValueSetExpander {
566
572
  this.checkResourceCanonicalStatus(expansion, vs, this.valueSet);
567
573
 
568
574
  for (const c of vs.expansion.contains || []) {
569
- this.worker.deadCheck('importValueSet');
575
+ await this.worker.checkAndYield('importValueSet');
570
576
  count += await this.importValueSetItem(null, c, imports, offset);
571
577
  }
572
578
  return count;
@@ -574,7 +580,7 @@ class ValueSetExpander {
574
580
 
575
581
  async importValueSetItem(p, c, imports, offset) {
576
582
  let count = 0;
577
- this.worker.deadCheck('importValueSetItem');
583
+ await this.worker.checkAndYield('importValueSetItem');
578
584
  const s = this.keyC(c);
579
585
  if (this.passesImports(imports, c.system, c.code, offset) && !this.map.has(s) && !this.isExcluded(c.system, c.version, c.code)) {
580
586
  count++;
@@ -588,7 +594,7 @@ class ValueSetExpander {
588
594
  this.map.set(s, c);
589
595
  }
590
596
  for (const cc of c.contains || []) {
591
- this.worker.deadCheck('importValueSetItem');
597
+ await this.worker.checkAndYield('importValueSetItem');
592
598
  count += await this.importValueSetItem(c, cc, imports, offset);
593
599
  }
594
600
  return count;
@@ -610,11 +616,11 @@ class ValueSetExpander {
610
616
  }
611
617
 
612
618
  async checkSource(cset, exp, filter, srcURL, ts, vsInfo , source) {
613
- this.worker.deadCheck('checkSource');
619
+ await this.worker.checkAndYield('checkSource');
614
620
  Extensions.checkNoModifiers(cset, 'ValueSetExpander.checkSource', 'set', srcURL);
615
621
  let imp = false;
616
622
  for (const u of cset.valueSet || []) {
617
- this.worker.deadCheck('checkSource');
623
+ await this.worker.checkAndYield('checkSource');
618
624
  const s = this.worker.pinValueSet(u);
619
625
  await this.checkCanExpandValueSet(s, '', source);
620
626
  imp = true;
@@ -663,8 +669,21 @@ class ValueSetExpander {
663
669
  } else if (filter.isNull) {
664
670
  // The unclosed marking for a grammar code system is added by
665
671
  // processCodes (the actual expansion); checkSource only guards size.
666
- if (!imp && this.count !== 0 && this.limitCount > 0 && cs.totalCount > this.limitCount) {
667
- throw new Issue("error", "too-costly", null, 'VALUESET_TOO_COSTLY', this.worker.i18n.translate('VALUESET_TOO_COSTLY', this.params.httpLanguages, [srcURL, '>' + this.limitCount]), null, 422).withDiagnostics(this.worker.opContext.diagnostics());
672
+ // Refuse up-front only when enumeration could not succeed anyway:
673
+ // count=0 is answered by counting (no enumeration), and a count/offset
674
+ // window that fits inside limitCount is answered by partial
675
+ // enumeration (includeCode stops at limitCount). Otherwise the walk
676
+ // would only ever end in VALUESET_TOO_COSTLY at limitCount codes -
677
+ // throw the same error now without doing the work.
678
+ // NB: totalCount is a method - as a property read (cs.totalCount) the
679
+ // comparison was against the function object, always false, so this
680
+ // guard was silently dead.
681
+ if (!imp && this.count !== 0 && this.limitCount > 0 && await cs.totalCount() > this.limitCount
682
+ && !(this.count > 0 && this.count + Math.max(this.offset, 0) <= this.limitCount)) {
683
+ // Report the versioned url (vurl), matching the message the
684
+ // enumeration path produces when it hits the same limit - this
685
+ // guard is just that refusal moved earlier.
686
+ throw new Issue("error", "too-costly", null, 'VALUESET_TOO_COSTLY', this.worker.i18n.translate('VALUESET_TOO_COSTLY', this.params.httpLanguages, [source && source.vurl ? source.vurl : srcURL, '>' + this.limitCount]), null, 422).withDiagnostics(this.worker.opContext.diagnostics());
668
687
  }
669
688
  }
670
689
  }
@@ -684,7 +703,7 @@ class ValueSetExpander {
684
703
  }
685
704
 
686
705
  async includeCodes(cset, path, vsSrc, compose, filter, expansion, excludeInactive, notClosed) {
687
- this.worker.deadCheck('processCodes#1');
706
+ await this.worker.checkAndYield('processCodes#1');
688
707
  const valueSets = [];
689
708
 
690
709
  Extensions.checkNoModifiers(cset, 'ValueSetExpander.processCodes', 'set', vsSrc.vurl);
@@ -695,7 +714,7 @@ class ValueSetExpander {
695
714
 
696
715
  if (!cset.system) {
697
716
  for (const u of cset.valueSet) {
698
- this.worker.deadCheck('processCodes#2');
717
+ await this.worker.checkAndYield('processCodes#2');
699
718
  const s = this.worker.pinValueSet(u);
700
719
  this.worker.opContext.log('import value set ' + s);
701
720
  let vs = await this.worker.findValueSet(s, '', vsSrc);
@@ -722,7 +741,7 @@ class ValueSetExpander {
722
741
  this.addParamUri(expansion, 'used-codesystem', sv);
723
742
 
724
743
  for (const u of cset.valueSet || []) {
725
- this.worker.deadCheck('processCodes#2');
744
+ await this.worker.checkAndYield('processCodes#2');
726
745
  const s = this.worker.pinValueSet(u);
727
746
  this.worker.opContext.log('import value set ' + s);
728
747
  let vs = await this.worker.findValueSet(s, '', vsSrc);
@@ -774,11 +793,14 @@ class ValueSetExpander {
774
793
 
775
794
  }
776
795
  let tcount = 0;
796
+ // One visited set across all roots: in a poly-hierarchy the walks
797
+ // from different roots can reach the same concepts.
798
+ const visited = new Set();
777
799
  let c = await cs.nextContext(iter);
778
800
  while (c) {
779
- this.worker.deadCheck('processCodes#3a');
801
+ await this.worker.checkAndYield('processCodes#3a');
780
802
  if (await this.passesFilters(cs, c, prep, filters, 0)) {
781
- tcount += await this.includeCodeAndDescendants(cs, c, expansion, valueSets, null, excludeInactive, vsSrc.vurl);
803
+ tcount += await this.includeCodeAndDescendants(cs, c, expansion, valueSets, null, excludeInactive, vsSrc.vurl, visited);
782
804
  }
783
805
  c = await cs.nextContext(iter);
784
806
  }
@@ -800,7 +822,7 @@ class ValueSetExpander {
800
822
  this.noTotal();
801
823
  this.worker.opContext.log('iterate filters');
802
824
  while (await cs.filterMore(ctxt, set[0])) {
803
- this.worker.deadCheck('processCodes#4');
825
+ await this.worker.checkAndYield('processCodes#4');
804
826
  const c = await cs.filterConcept(ctxt, set[0]);
805
827
  if (await this.passesFilters(cs, c, prep, set, 1)) {
806
828
  const cds = new Designations(this.worker.i18n.languageDefinitions);
@@ -830,7 +852,7 @@ class ValueSetExpander {
830
852
  const cds = new Designations(this.worker.i18n.languageDefinitions);
831
853
 
832
854
  for (const cc of cset.concept) {
833
- this.worker.deadCheck('processCodes#3');
855
+ await this.worker.checkAndYield('processCodes#3');
834
856
  cds.clear();
835
857
  Extensions.checkNoModifiers(cc, 'ValueSetExpander.processCodes', 'set concept reference', vsSrc.vurl);
836
858
  const cctxt = await cs.locate(cc.code, this.allAltCodes);
@@ -875,7 +897,7 @@ class ValueSetExpander {
875
897
  }
876
898
 
877
899
  for (let i = 0; i < fcl.length; i++) {
878
- this.worker.deadCheck('processCodes#4a');
900
+ await this.worker.checkAndYield('processCodes#4a');
879
901
  const fc = fcl[i];
880
902
  if (!fc.value) {
881
903
  throw new Issue('error', 'invalid', path + ".filter[" + i + "]", 'UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.worker.i18n.translate('UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.params.httpLanguages, [cs.system(), fc.property, fc.op]), 'vs-invalid', 400);
@@ -895,7 +917,7 @@ class ValueSetExpander {
895
917
  this.addToTotal(0);
896
918
  const cds = new Designations(this.worker.i18n.languageDefinitions);
897
919
  while (await cs.filterMore(prep, fset[0])) {
898
- this.worker.deadCheck('processCodes#5');
920
+ await this.worker.checkAndYield('processCodes#5');
899
921
  const c = await cs.filterConcept(prep, fset[0]);
900
922
  const ok = (!this.params.activeOnly || !await cs.isInactive(c)) && (await this.passesFilters(cs, c, prep, fset, 1));
901
923
  if (ok) {
@@ -944,7 +966,7 @@ class ValueSetExpander {
944
966
  }
945
967
 
946
968
  async excludeCodes(cset, path, vsSrc, filter, expansion, excludeInactive, notClosed) {
947
- this.worker.deadCheck('processCodes#1');
969
+ await this.worker.checkAndYield('processCodes#1');
948
970
  const valueSets = [];
949
971
 
950
972
  Extensions.checkNoModifiers(cset, 'ValueSetExpander.processCodes', 'set', vsSrc.vurl);
@@ -958,7 +980,7 @@ class ValueSetExpander {
958
980
  this.noTotal();
959
981
  for (const u of cset.valueSet) {
960
982
  const s = this.worker.pinValueSet(u);
961
- this.worker.deadCheck('processCodes#2');
983
+ await this.worker.checkAndYield('processCodes#2');
962
984
  let vs = await this.worker.findValueSet(s, '', vsSrc);
963
985
  const ivs = new ImportedValueSet(await this.expandValueSet(s, '', vs, filter, notClosed));
964
986
  this.checkResourceCanonicalStatus(expansion, ivs.valueSet, this.valueSet);
@@ -984,7 +1006,7 @@ class ValueSetExpander {
984
1006
  this.addParamUri(expansion, 'used-codesystem', sv);
985
1007
 
986
1008
  for (const u of cset.valueSet || []) {
987
- this.worker.deadCheck('processCodes#3');
1009
+ await this.worker.checkAndYield('processCodes#3');
988
1010
  const s = this.worker.pinValueSet(u);
989
1011
  this.worker.opContext.log('import value set ' + s);
990
1012
  let vs = await this.worker.findValueSet(s, '', vsSrc);
@@ -1007,7 +1029,7 @@ class ValueSetExpander {
1007
1029
  if (iter) {
1008
1030
  let c = await cs.nextContext(iter);
1009
1031
  while (c) {
1010
- this.worker.deadCheck('processCodes#3aa');
1032
+ await this.worker.checkAndYield('processCodes#3aa');
1011
1033
  this.excludeCode(cs, cs.system(), cs.version(), await cs.code(c), expansion, valueSets, vsSrc.url);
1012
1034
  c = await cs.nextContext(iter);
1013
1035
  }
@@ -1031,7 +1053,7 @@ class ValueSetExpander {
1031
1053
  if (iter) {
1032
1054
  let c = await cs.nextContext(iter);
1033
1055
  while (c) {
1034
- this.worker.deadCheck('processCodes#3a');
1056
+ await this.worker.checkAndYield('processCodes#3a');
1035
1057
  if (await this.passesFilters(cs, c, prep, filters, 0)) {
1036
1058
  this.excludeCode(cs, cs.system(), cs.version(), await cs.code(c), expansion, valueSets, vsSrc.url);
1037
1059
  }
@@ -1045,7 +1067,7 @@ class ValueSetExpander {
1045
1067
  this.worker.opContext.log('iterate concepts');
1046
1068
  const cds = new Designations(this.worker.i18n.languageDefinitions);
1047
1069
  for (const cc of cset.concept) {
1048
- this.worker.deadCheck('processCodes#3');
1070
+ await this.worker.checkAndYield('processCodes#3');
1049
1071
  cds.clear();
1050
1072
  Extensions.checkNoModifiers(cc, 'ValueSetExpander.processCodes', 'set concept reference', vsSrc.vurl);
1051
1073
  const cctxt = await cs.locate(cc.code, this.allAltCodes);
@@ -1076,7 +1098,7 @@ class ValueSetExpander {
1076
1098
 
1077
1099
  let first = true;
1078
1100
  for (let fc of cset.filter) {
1079
- this.worker.deadCheck('processCodes#4a');
1101
+ await this.worker.checkAndYield('processCodes#4a');
1080
1102
  Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter', vsSrc.vurl);
1081
1103
  await cs.filter(prep, first, fc.property, fc.op, fc.value, vsSrc.isCached ? fc : null);
1082
1104
  first = false;
@@ -1089,7 +1111,7 @@ class ValueSetExpander {
1089
1111
  }
1090
1112
  //let count = 0;
1091
1113
  while (await cs.filterMore(prep, fset[0])) {
1092
- this.worker.deadCheck('processCodes#5');
1114
+ await this.worker.checkAndYield('processCodes#5');
1093
1115
  const c = await cs.filterConcept(prep, fset[0]);
1094
1116
  const ok = (!this.params.activeOnly || !await cs.isInactive(c)) && (await this.passesFilters(cs, c, prep, fset, 1));
1095
1117
  if (ok) {
@@ -1105,9 +1127,30 @@ class ValueSetExpander {
1105
1127
  }
1106
1128
  }
1107
1129
 
1108
- async includeCodeAndDescendants(cs, context, expansion, imports, parent, excludeInactive, srcUrl) {
1130
+ async includeCodeAndDescendants(cs, context, expansion, imports, parent, excludeInactive, srcUrl, visited = null) {
1109
1131
  let result = 0;
1110
- this.worker.deadCheck('processCodeAndDescendants');
1132
+ await this.worker.checkAndYield('processCodeAndDescendants');
1133
+
1134
+ // In a poly-hierarchy (e.g. SNOMED CT) a concept is reachable through every
1135
+ // one of its parents; without a visited set the recursion walks every *path*
1136
+ // rather than every *node* - for the full SNOMED International edition that
1137
+ // is ~15.8M visits for ~520k concepts, which is what turned an aborted
1138
+ // expansion into a 30-second event-loop stall. Reaching a concept a second
1139
+ // time means it has multiple parents, so (as when includeCode detects a
1140
+ // duplicate) the expansion cannot be represented as a tree.
1141
+ if (visited == null) {
1142
+ visited = new Set();
1143
+ }
1144
+ // NB: contexts do not reliably carry a .code property (SnomedExpressionContext
1145
+ // does not) - cs.code(context) is the accessor. Reading context.code here
1146
+ // used to yield undefined for every SNOMED concept, collapsing the whole
1147
+ // expansion onto one map key so no size guard could ever fire.
1148
+ const code = await cs.code(context);
1149
+ if (visited.has(code)) {
1150
+ this.canBeHierarchy = false;
1151
+ return 0;
1152
+ }
1153
+ visited.add(code);
1111
1154
 
1112
1155
  if (expansion) {
1113
1156
  const vs = this.canonical(await cs.system(), await cs.version());
@@ -1123,12 +1166,12 @@ class ValueSetExpander {
1123
1166
  const cds = new Designations(this.worker.i18n.languageDefinitions);
1124
1167
  let t;
1125
1168
  if (this.noDetails) {
1126
- t = await this.includeCode(cs, null, await cs.system(), await cs.version(), context.code, await cs.isAbstract(context), await cs.isInactive(context), null, null, null,
1127
- null, expansion, imports, null, null, null, null, excludeInactive, srcUrl);
1169
+ t = await this.includeCode(cs, null, await cs.system(), await cs.version(), code, await cs.isAbstract(context), await cs.isInactive(context), null, null, null,
1170
+ null, null, expansion, imports, null, null, null, null, excludeInactive, srcUrl);
1128
1171
 
1129
1172
  } else {
1130
1173
  await this.listDisplaysFromProvider(cds, cs, context);
1131
- t = await this.includeCode(cs, parent, await cs.system(), await cs.version(), context.code, await cs.isAbstract(context), await cs.isInactive(context), await cs.isDeprecated(context), await cs.getStatus(context), cds, await cs.definition(context),
1174
+ t = await this.includeCode(cs, parent, await cs.system(), await cs.version(), code, await cs.isAbstract(context), await cs.isInactive(context), await cs.isDeprecated(context), await cs.getStatus(context), cds, await cs.definition(context),
1132
1175
  await cs.itemWeight(context), expansion, imports, await cs.extensions(context), null, await cs.properties(context), null, excludeInactive, srcUrl);
1133
1176
  }
1134
1177
  if (t != null) {
@@ -1145,16 +1188,29 @@ class ValueSetExpander {
1145
1188
  if (iter) {
1146
1189
  let c = await cs.nextContext(iter);
1147
1190
  while (c) {
1148
- this.worker.deadCheck('processCodeAndDescendants#3');
1149
- result += await this.includeCodeAndDescendants(cs, c, expansion, imports, n, excludeInactive, srcUrl);
1191
+ await this.worker.checkAndYield('processCodeAndDescendants#3');
1192
+ result += await this.includeCodeAndDescendants(cs, c, expansion, imports, n, excludeInactive, srcUrl, visited);
1150
1193
  c = await cs.nextContext(iter);
1151
1194
  }
1152
1195
  }
1153
1196
  return result;
1154
1197
  }
1155
1198
 
1156
- async excludeCodeAndDescendants(cs, context, expansion, imports, excludeInactive, srcUrl) {
1157
- this.worker.deadCheck('processCodeAndDescendants');
1199
+ async excludeCodeAndDescendants(cs, context, expansion, imports, excludeInactive, srcUrl, visited = null) {
1200
+ await this.worker.checkAndYield('processCodeAndDescendants');
1201
+
1202
+ // Same visited-set protection as includeCodeAndDescendants: walk nodes, not
1203
+ // paths (see that method for why), and same accessor fix - context.code is
1204
+ // not reliably present, and passing the raw context as the code produced
1205
+ // useless "[object Object]" exclusion keys.
1206
+ if (visited == null) {
1207
+ visited = new Set();
1208
+ }
1209
+ const code = await cs.code(context);
1210
+ if (visited.has(code)) {
1211
+ return;
1212
+ }
1213
+ visited.add(code);
1158
1214
 
1159
1215
  if (expansion) {
1160
1216
  const vs = this.canonical(await cs.system(), await cs.version());
@@ -1168,15 +1224,15 @@ class ValueSetExpander {
1168
1224
  if ((!this.params.excludeNotForUI || !await cs.isAbstract(context)) && (!this.params.activeOnly || !await cs.isInactive(context))) {
1169
1225
  const cds = new Designations(this.worker.i18n.languageDefinitions);
1170
1226
  await this.listDisplaysFromProvider(cds, cs, context);
1171
- this.worker.deadCheck('processCodeAndDescendants#2');
1172
- this.excludeCode(cs, await cs.system(), await cs.version(), context, expansion, imports, srcUrl);
1227
+ await this.worker.checkAndYield('processCodeAndDescendants#2');
1228
+ this.excludeCode(cs, await cs.system(), await cs.version(), code, expansion, imports, srcUrl);
1173
1229
  }
1174
1230
 
1175
1231
  const iter = await cs.iterator(context);
1176
1232
  let c = await cs.nextContext(iter);
1177
1233
  while (c) {
1178
- this.worker.deadCheck('processCodeAndDescendants#3');
1179
- await this.excludeCodeAndDescendants(cs, c, expansion, imports, excludeInactive, srcUrl);
1234
+ await this.worker.checkAndYield('processCodeAndDescendants#3');
1235
+ await this.excludeCodeAndDescendants(cs, c, expansion, imports, excludeInactive, srcUrl, visited);
1180
1236
  c = await cs.nextContext(iter);
1181
1237
  }
1182
1238
  }
@@ -1187,11 +1243,11 @@ class ValueSetExpander {
1187
1243
  this.doingVersion = false;
1188
1244
  const ts = new Map();
1189
1245
  for (const c of source.jsonObj.compose.include || []) {
1190
- this.worker.deadCheck('handleCompose#2');
1246
+ await this.worker.checkAndYield('handleCompose#2');
1191
1247
  await this.checkSource(c, expansion, filter, source.url, ts, vsInfo, source);
1192
1248
  }
1193
1249
  for (const c of source.jsonObj.compose.exclude || []) {
1194
- this.worker.deadCheck('handleCompose#3');
1250
+ await this.worker.checkAndYield('handleCompose#3');
1195
1251
  this.hasExclusions = true;
1196
1252
  await this.checkSource(c, expansion, filter, source.url, ts, null, source);
1197
1253
  }
@@ -1205,7 +1261,7 @@ class ValueSetExpander {
1205
1261
 
1206
1262
  let i = 0;
1207
1263
  for (const c of source.jsonObj.compose.exclude || []) {
1208
- this.worker.deadCheck('handleCompose#4');
1264
+ await this.worker.checkAndYield('handleCompose#4');
1209
1265
  await this.excludeCodes(c, "ValueSet.compose.exclude[" + i + "]", source, filter, expansion, this.excludeInactives(source), notClosed);
1210
1266
  }
1211
1267
 
@@ -1218,7 +1274,7 @@ class ValueSetExpander {
1218
1274
  return 0;
1219
1275
  });
1220
1276
  for (const c of includes) {
1221
- this.worker.deadCheck('handleCompose#5');
1277
+ await this.worker.checkAndYield('handleCompose#5');
1222
1278
  await this.includeCodes(c, "ValueSet.compose.include[" + i + "]", source, source.jsonObj.compose, filter, expansion, this.excludeInactives(source), notClosed);
1223
1279
  i++;
1224
1280
  }
@@ -1346,7 +1402,7 @@ class ValueSetExpander {
1346
1402
  }
1347
1403
 
1348
1404
  this.worker.opContext.log('start working');
1349
- this.worker.deadCheck('expand');
1405
+ await this.worker.checkAndYield('expand');
1350
1406
 
1351
1407
  let notClosed = { value : false};
1352
1408
 
@@ -1433,7 +1489,7 @@ class ValueSetExpander {
1433
1489
  let t = 0;
1434
1490
  let o = 0;
1435
1491
  for (let i = 0; i < list.length; i++) {
1436
- this.worker.deadCheck('expand#1');
1492
+ await this.worker.checkAndYield('expand#1');
1437
1493
  const c = list[i];
1438
1494
  if (this.map.has(this.keyC(c))) {
1439
1495
  o++;
@@ -75,6 +75,15 @@ class TerminologyWorker {
75
75
  this.opContext.deadCheck(place);
76
76
  }
77
77
 
78
+ /**
79
+ * deadCheck plus a cooperative event-loop yield (see OperationContext.checkAndYield).
80
+ * Use this (awaited) from long-running loops in async code.
81
+ * @param {string} place - Location identifier for debugging
82
+ */
83
+ async checkAndYield(place = 'unknown') {
84
+ await this.opContext.checkAndYield(place);
85
+ }
86
+
78
87
  /**
79
88
  * Add cost diagnostics to an error
80
89
  * @param {TooCostlyError} e - The error to enhance