Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## [v0.9.6] - 2026-XX-XX

### Fixed

- Replace re2-wasm with re2js in library/regex-utilities.js to eliminate the underlying WASM-heap leak

### Tx Conformance Statement

XXX

## [v0.9.5] - 2026-05-16

### Fixed

- Workaround for memory leak in re2-wasm library that reduces it's severity
- Replace re2-wasm with re2js in library/regex-utilities.js to eliminate the underlying WASM-heap leak

### Tx Conformance Statement

Expand Down
3 changes: 2 additions & 1 deletion config-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@
"enabled": true,
"database": "/absolute/path/to/publisher.db",
"sessionSecret": "change-this-to-a-secure-random-string",
"workspaceRoot": "/absolute/path/to/workspaces"
"workspaceRoot": "/absolute/path/to/workspaces",
"igPublisherTimeoutMinutes": 60
},
"npmprojector": {
"enabled": true,
Expand Down
61 changes: 49 additions & 12 deletions library/regex-utilities.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,64 @@
const { RE2 } = require('re2-wasm');
const { RE2JS } = require('re2js');

// Translate a JS-style flags string ("i", "im", "is", etc.) into the bit-flag
// integer that RE2JS.compile() expects. Only the flags actually used by callers
// (and their natural counterparts) are mapped; anything else is ignored.
function toRE2JSFlags(flags) {
if (!flags) return 0;
let bits = 0;
if (flags.includes('i')) bits |= RE2JS.CASE_INSENSITIVE;
if (flags.includes('m')) bits |= RE2JS.MULTILINE;
if (flags.includes('s')) bits |= RE2JS.DOTALL;
// The 'u' flag was a re2-wasm workaround; re2js is pure JS and handles
// unicode natively, so it's a no-op here.
return bits;
}

// Thin wrapper that exposes the only method the rest of the codebase uses
// against compiled regexes: a JS-RegExp-style `.test(input)` that returns
// true if the pattern is found anywhere in `input`.
class CompiledRegex {
constructor(pattern, flags) {
this._pattern = RE2JS.compile(pattern, toRE2JSFlags(flags));
}

test(input) {
if (input == null) return false;
return this._pattern.matcher(String(input)).find();
}
}

class RegExUtilities {

// re2js doesn't have re2-wasm's WASM-heap leak, so the cache here is
// purely a performance optimisation. We cap it with a simple LRU so a
// workload with many distinct regex patterns can't grow it without bound.
static MAX_CACHE_SIZE = 1000;

constructor() {
this._cache = new Map();
}

compile(pattern, flags) {
// re2-wasm has a fixed 16 MB WASM heap with no real free(): every
// new RE2(...) permanently consumes a few KB. Cache by (pattern, flags)
// so the same regex compiles at most once.
// TODO: replace re2-wasm with native re2 to eliminate the underlying leak.
const re2Flags = flags && flags.includes('u') ? flags : (flags || '') + 'u';
const key = pattern + '|' + re2Flags;
let compiled = this._cache.get(key);
if (!compiled) {
compiled = new RE2(pattern, re2Flags);
this._cache.set(key, compiled);
const key = pattern + '|' + (flags || '');
const cached = this._cache.get(key);
if (cached) {
// Move to most-recently-used position by re-inserting.
this._cache.delete(key);
this._cache.set(key, cached);
return cached;
}
const compiled = new CompiledRegex(pattern, flags);
if (this._cache.size >= RegExUtilities.MAX_CACHE_SIZE) {
// Evict the oldest entry. Map iteration order is insertion order, so
// the first key is the least-recently-used.
const oldest = this._cache.keys().next().value;
this._cache.delete(oldest);
}
this._cache.set(key, compiled);
return compiled;
}

}

module.exports = new RegExUtilities();

17 changes: 7 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"properties-file": "^3.6.4",
"re2-wasm": "^1.0.2",
"re2js": "^2.8.0",
"rimraf": "^5.0.10",
"sqlite3": "^5.1.7",
"tar": "^7.5.7",
Expand Down
7 changes: 4 additions & 3 deletions publisher/publisher.js
Original file line number Diff line number Diff line change
Expand Up @@ -649,13 +649,14 @@ class PublisherModule {
reject(error);
});

// Timeout after 30 minutes
// Timeout configurable via publisher.igPublisherTimeoutMinutes (default: 60 minutes)
const timeoutMinutes = this.config.igPublisherTimeoutMinutes || 60;
const timeout = setTimeout(async () => {
java.kill();
logStream.end();
await this.logTaskMessage(taskId, 'error', 'IG Publisher timed out after 30 minutes');
await this.logTaskMessage(taskId, 'error', 'IG Publisher timed out after ' + timeoutMinutes + ' minutes');
reject(new Error('IG Publisher timed out'));
}, 30 * 60 * 1000);
}, timeoutMinutes * 60 * 1000);

java.on('close', () => {
clearTimeout(timeout);
Expand Down
13 changes: 9 additions & 4 deletions publisher/task-draft.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ class DraftTaskProcessor {
stdio: ['pipe', 'pipe', 'pipe']
});

// eslint-disable-next-line no-unused-vars
let stdout = '';
let stderr = '';

Expand Down Expand Up @@ -255,6 +256,7 @@ class DraftTaskProcessor {
stdio: ['pipe', 'pipe', 'pipe']
});

// eslint-disable-next-line no-unused-vars
let stdout = '';
let stderr = '';

Expand Down Expand Up @@ -316,6 +318,7 @@ class DraftTaskProcessor {
});

let stdout = '';
// eslint-disable-next-line no-unused-vars
let stderr = '';

sushi.stdout.on('data', (data) => {
Expand Down Expand Up @@ -382,6 +385,7 @@ class DraftTaskProcessor {
logStream.write('Working Directory: ' + draftDir + '\n');
logStream.write('=====================================\n\n');

// eslint-disable-next-line no-unused-vars
let hasOutput = false;
let lastProgressUpdate = Date.now();

Expand Down Expand Up @@ -433,7 +437,8 @@ class DraftTaskProcessor {
reject(error);
});

// Timeout after 30 minutes
// Timeout configurable via publisher.igPublisherTimeoutMinutes (default: 60 minutes)
const timeoutMinutes = this.config.igPublisherTimeoutMinutes || 60;
const timeout = setTimeout(async () => {
java.kill('SIGTERM'); // Try graceful shutdown first

Expand All @@ -442,11 +447,11 @@ class DraftTaskProcessor {
java.kill('SIGKILL');
}, 10000);

logStream.write('\nTIMEOUT: Process killed after 30 minutes\n');
logStream.write('\nTIMEOUT: Process killed after ' + timeoutMinutes + ' minutes\n');
logStream.end();
await this.logTaskMessage(taskId, 'error', 'IG Publisher timed out after 30 minutes');
await this.logTaskMessage(taskId, 'error', 'IG Publisher timed out after ' + timeoutMinutes + ' minutes');
reject(new Error('IG Publisher timed out'));
}, 30 * 60 * 1000);
}, timeoutMinutes * 60 * 1000);

java.on('close', () => {
clearTimeout(timeout);
Expand Down
15 changes: 8 additions & 7 deletions test-scripts/repro-re2-wasm-leak.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
// Manual reproducer for the re2-wasm WASM heap leak.
// See library/regex-utilities.js for the workaround.
// Historical reproducer for the re2-wasm WASM heap leak.
// regex-utilities.js now sits on top of re2js (a pure-JS RE2 port), so the
// underlying leak is gone. Kept as a stress test for the compile cache:
//
// node test-scripts/repro-re2-wasm-leak.js # same-pattern stress
// node test-scripts/repro-re2-wasm-leak.js --unique # unique-pattern stress
//
// Without the cache, same-pattern OOMs at ~2965 iterations.
// With the cache, same-pattern runs indefinitely.
// Unique-pattern still OOMs (each pattern is a real compile and the underlying
// re2-wasm heap leak still applies). A proper fix is to replace re2-wasm with
// the native `re2` package.
// Background (re2-wasm era): without the cache, same-pattern OOMed at ~2965
// iterations; with the cache, same-pattern ran indefinitely but unique-pattern
// still OOMed because every distinct pattern was a real compile and re2-wasm
// could not free its WASM heap. Under re2js both modes now run indefinitely
// (unique-pattern is bounded only by ordinary V8 heap growth from the cache).

const re = require('../library/regex-utilities');

Expand Down
2 changes: 1 addition & 1 deletion tests/cs/cs-cs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1520,7 +1520,7 @@ describe('FHIR CodeSystem Provider', () => {
test('should handle invalid regex gracefully', async () => {
await expect(
simpleProvider.filter(filterContext, true, 'code', 'regex', '[invalid')
).rejects.toThrow('The regex \'[invalid\' is not valid: Invalid regular expression: /^[invalid$/u: missing ]: [invalid$');
).rejects.toThrow('The regex \'[invalid\' is not valid: error parsing regexp: missing closing ]: `[invalid$`');
});
});

Expand Down
4 changes: 2 additions & 2 deletions tests/cs/cs-omop.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ describe('OMOP Provider', () => {
expect(provider).toBeInstanceOf(OMOPServices);
});

test('should check database status', () => {
const status = OMOPServicesFactory.checkDB(testDbPath);
test('should check database status', async () => {
const status = await OMOPServicesFactory.checkDB(testDbPath);
expect(status).toContain('OK');
});

Expand Down
1 change: 1 addition & 0 deletions tests/data/UcumFunctionalTests.xml
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@
<case id="k=1=054" unit="meq/(kg.h)" valid="true"/>
<case id="k=1=055" unit="osm/L" valid="true"/>
<case id="k=1=056" unit="pH" valid="true"/>
<case id="k=1=056" unit="[pH]" valid="true"/>
<case id="k=1=057" unit="g/(8.h)" valid="true"/>
<case id="k=1=058" unit="meq/h" valid="true"/>
<case id="k=1=059" unit="pA" valid="true"/>
Expand Down
24 changes: 12 additions & 12 deletions tx/cs/cs-loinc.js
Original file line number Diff line number Diff line change
Expand Up @@ -1429,8 +1429,8 @@ class LoincServicesFactory extends CodeSystemFactoryProvider {
* @returns {Promise<Array>} Array of {code, display} objects
*/
async #getAnswerListConcepts(sourceKey) {
return new Promise((resolve, reject) => {
let db = new sqlite3.Database(this.dbPath);
const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY);
try {
const sql = `
SELECT Code, Description
FROM Relationships, Codes
Expand All @@ -1439,17 +1439,17 @@ class LoincServicesFactory extends CodeSystemFactoryProvider {
AND Relationships.TargetKey = Codes.CodeKey
`;

db.all(sql, [sourceKey], (err, rows) => {
if (err) {
reject(err);
} else {
const concepts = rows.map(row => ({
code: row.Code
}));
resolve(concepts);
}
const rows = await new Promise((resolve, reject) => {
db.all(sql, [sourceKey], (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
});

return rows.map(row => ({ code: row.Code }));
} finally {
await new Promise((resolve) => db.close(() => resolve()));
}
}

id() {
Expand Down
47 changes: 24 additions & 23 deletions tx/cs/cs-omop.js
Original file line number Diff line number Diff line change
Expand Up @@ -906,41 +906,42 @@ class OMOPServicesFactory extends CodeSystemFactoryProvider {
return new OMOPServices(opContext, supplements, db, this._sharedData);
}

static checkDB(dbPath) {
static async checkDB(dbPath) {
const fs = require('fs');
try {
const fs = require('fs');

// Check if file exists
if (!fs.existsSync(dbPath)) {
return 'Database file not found';
}

// Check file size
const stats = fs.statSync(dbPath);
if (stats.size < 1024) {
return 'Database file too small';
}
} catch (e) {
return `Database error: ${e.message}`;
}

// Try to open database and check for required tables
const db = new sqlite3.Database(dbPath);

try {
// Simple count query to verify database integrity
db.get('SELECT COUNT(*) as count FROM Concepts', (err) => {
if (err) {
db.close();
return 'Missing Tables - needs re-importing (by java)';
}
});

db.close();
return 'OK (check via provider for count)';
} catch (e) {
return 'Missing Tables - needs re-importing (by java)';
}
let db;
try {
db = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY);
} catch (e) {
return `Database error: ${e.message}`;
}

try {
// Simple count query to verify database integrity. If the Concepts
// table is missing, db.get rejects and we fall through to the catch.
const row = await new Promise((resolve, reject) => {
db.get('SELECT COUNT(*) as count FROM Concepts', (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
return `OK (${row && row.count != null ? row.count : 0} Concepts)`;
} catch (_e) {
return 'Missing Tables - needs re-importing (by java)';
} finally {
await new Promise((resolve) => db.close(() => resolve()));
}
}


Expand Down
Loading
Loading