### Summary
In Meteor 3.4.1 dev mode, the **`meteor` build-tool process leaks t…he rspack-generated server bundle and its source-map `SourceNode` graph on every incremental rebuild** ("`=> Server modified -- restarting...`"). Nothing from the previous compilation is released, so the tool process grows ~linearly with the number of rebuilds and eventually dies with:
```
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
```
This is the **meteor-tool** process (the one launched at `~/.meteor/meteor` with `--max-old-space-size=4096`), not the application server — confirmed by the OOM crash header naming that PID and by an external RSS sampler showing the tool climbing while the app-server stays flat. It is dev-only (production runs the prebuilt bundle), but it makes long dev sessions with frequent saves unusable.
### Environment
| | |
|---|---|
| Meteor | 3.4.1 |
| `@meteorjs/rspack` | 2.0.1 |
| `@rspack/core` | 2.0.4 |
| `source-map` (SourceNode owner) | 0.7.6 |
| Node (meteor dev_bundle) | v22.22.1 |
| OS | macOS 15 (darwin 25.5.0), arm64 |
### Reproduction
1. Run a non-trivial Meteor 3.4 app in dev (`meteor run`).
2. Repeatedly trigger server rebuilds — save/modify any watched `server/*.js` file ~10–15 times (e.g. lint-on-save churn). Each produces `=> Server modified -- restarting...` / `[server-rspack] compiled successfully`.
3. Watch the **meteor-tool** process RSS (not the app server). It climbs ~one server-bundle's worth per rebuild and OOMs at the 4 GB tool heap limit after ~15–45 min of churn.
Measured climb across 4 rebuilds (15 s apart): **742 → 1242 → 1516 → 2116 MB** — i.e. ~**+450 MB per rebuild** and never released.
### Evidence — heap snapshot of the tool at ~2 GB
Captured via `TOOL_NODE_FLAGS=--heapsnapshot-signal=SIGUSR2`, signalled mid-churn. Top retainers (self-size) of the 1 GB heap:
| Retainer | Size | Count | Notes |
|---|---|---|---|
| `string` (server bundle source) | 577 MB | 3.77 M | bundle/module sources |
| `array:(object elements)` | 249 MB | 1.44 M | source-map mapping arrays |
| **`object:SourceNode`** | **102 MB** | **1,340,051** | **`source-map@0.7.6` SourceNode — source-map generation artifacts** |
| `concatenated string` | 84 MB | 2.76 M | |
The single most telling bucket: **4 identical retained copies of the `server-rspack.js` bundle**, 62.8 MB each = **251 MB**:
```
251.3 MB 4x | /*! * /** * * @file server-rspack.js * * @description No code generated ...
```
4 retained copies ↔ 4 rebuilds performed before the snapshot = **exactly one full server bundle + ~335 k `SourceNode`s leaked per rebuild**. Prior compilations' assets are never GC'd.
### Suspected area
The `@meteorjs/rspack` config uses `devtool: "source-map"` in dev (full source maps generated each build → the `SourceNode` mass) together with an in-memory `cache` layer. The retention pattern (N stale full bundles + their source-map graphs held simultaneously) points at the previous compilation's assets/source-maps being held by the in-memory cache or a watcher/compiler reference that isn't cleared on incremental rebuild. (Symptom is measured and reproducible; the exact retaining edge in the cache/compiler I have not traced.)
### Impact / workaround
- Dev-only; production (`node main.js`) is unaffected.
- Workaround: raise the tool heap (`TOOL_NODE_FLAGS=--max-old-space-size=8192`) and/or restart `meteor` periodically — neither fixes the leak.
Happy to share the full heap snapshot or run additional instrumentation.
### Reproduction harness
Two self-contained scripts used to capture and analyze the leak, so this can be repeated on any Meteor 3.4 app.
**1. `capture-leak.sh`** — drives rebuilds and captures a complete tool heap snapshot via signal. Launch the dev server first with `TOOL_NODE_FLAGS="--heapsnapshot-signal=SIGUSR2" meteor run ...`, then run `./capture-leak.sh /path/to/app`.
```bash
#!/bin/bash
# Reproduce + capture the Meteor 3.4 rspack dev-tool memory leak.
# Prereq: TOOL_NODE_FLAGS="--heapsnapshot-signal=SIGUSR2" meteor run --settings ...
# Capturing at the OOM cliff via --heapsnapshot-near-heap-limit yields 0-byte files
# (heap too full to serialize); signalling mid-churn writes a complete snapshot.
# Usage: ./capture-leak.sh /path/to/meteor/app [server/main.js] [thresholdMB]
set -u
APP="${1:?usage: capture-leak.sh <appDir> [watchedFile] [thresholdMB]}"
WATCH="${2:-server/main.js}"; THRESH_MB="${3:-2000}"; TARGET="$APP/$WATCH"
BACKUP="$(mktemp)"; cp "$TARGET" "$BACKUP"; MARK="$(mktemp)"
restore() { cp "$BACKUP" "$TARGET"; rm -f "$BACKUP" "$MARK"; }
trap restore EXIT INT TERM
size() { stat -f%z "$1" 2>/dev/null || stat -c%s "$1" 2>/dev/null; }
tool_pid() { ps -axo pid,command | grep "heapsnapshot-signal=SIGUSR2" | grep -v grep | awk '{print $1}' | head -1; }
find_snap() { find "$APP" -maxdepth 2 -name '*.heapsnapshot' -newer "$MARK" 2>/dev/null | head -1; }
echo "waiting for a meteor tool launched with --heapsnapshot-signal=SIGUSR2 ..."
tp=""; for i in $(seq 1 200); do tp=$(tool_pid); [ -n "$tp" ] && break; sleep 3; done
[ -z "$tp" ] && { echo "ERROR: no tool with --heapsnapshot-signal=SIGUSR2"; exit 2; }
echo "tool pid=$tp; churning '$WATCH' every 12s until RSS >= ${THRESH_MB}MB"
n=0
while true; do
tp=$(tool_pid); [ -z "$tp" ] && { sleep 3; continue; }
mb=$(( $(ps -o rss= -p "$tp" | tr -d ' ') / 1024 ))
echo "rebuild #$n tool=$tp RSS=${mb}MB"
if [ "$mb" -ge "$THRESH_MB" ]; then
echo "threshold hit — sending SIGUSR2 to $tp"; kill -USR2 "$tp"
for w in $(seq 1 40); do
snap=$(find_snap)
if [ -n "$snap" ]; then
s1=$(size "$snap"); sleep 3; s2=$(size "$snap")
[ -n "$s2" ] && [ "$s1" = "$s2" ] && [ "$s2" -gt 1000000 ] && { echo "SNAPSHOT: $snap ($((s2/1048576)) MB)"; exit 0; }
fi
sleep 2
done
echo "snapshot did not stabilize; resuming churn"
fi
n=$((n+1)); { cat "$BACKUP"; printf '\n// leak-repro rebuild trigger #%d\n' "$n"; } > "$TARGET"; sleep 12
done
```
**2. `analyze-heapsnapshot.js`** — streaming parser (the snapshot is >512 MB so it cannot be `JSON.parse`d as one string). Aggregates self-size by type/constructor and surfaces the largest retained strings. Run: `node --max-old-space-size=6144 analyze-heapsnapshot.js Heap.*.heapsnapshot`.
```js
'use strict';
const fs = require('fs');
const FILE = process.argv[2];
const FC = 7, T = 0, NAME = 1, SELF = 3, PREFIX = 180;
const TYPES = ["hidden","array","string","object","code","closure","regexp","number","native","synthetic","concatenated string","sliced string","symbol","bigint","object shape"];
function makeSeeker(target){const t=Buffer.from(target);let m=0;return b=>{if(b===t[m]){m++;if(m===t.length){m=0;return true;}}else{m=(b===t[0])?1:0;}return false;};}
const fd=fs.openSync(FILE,'r'); const CHUNK=1<<24; const buf=Buffer.allocUnsafe(CHUNK);
let mode='seek_nodes'; const seekNodes=makeSeeker('"nodes":['); const seekStrings=makeSeeker('"strings":[');
const typeA=[],nameA=[],selfA=[]; let field=0,num=0,inNum=false;
const strings=[]; let inStr=false,esc=false,tok=[],tokLen=0; let read;
while((read=fs.readSync(fd,buf,0,CHUNK,null))>0){
for(let i=0;i<read;i++){const b=buf[i];
if(mode==='seek_nodes'){if(seekNodes(b)){mode='nodes';field=0;num=0;inNum=false;}continue;}
if(mode==='nodes'){
if(b>=48&&b<=57){num=num*10+(b-48);inNum=true;}
else if(b===44||b===93){if(inNum){if(field===T)typeA.push(num);else if(field===NAME)nameA.push(num);else if(field===SELF)selfA.push(num);field=(field+1)%FC;num=0;inNum=false;}if(b===93)mode='seek_strings';}
continue;}
if(mode==='seek_strings'){if(seekStrings(b)){mode='strings';inStr=false;}continue;}
if(mode==='strings'){
if(!inStr){if(b===34){inStr=true;esc=false;tok=[];tokLen=0;}else if(b===93){mode='done';break;}}
else{if(esc){if(tokLen<PREFIX)tok.push(b);tokLen++;esc=false;}
else if(b===92){if(tokLen<PREFIX)tok.push(b);tokLen++;esc=true;}
else if(b===34){let dec;try{dec=JSON.parse('"'+Buffer.from(tok).toString('utf8')+'"');}catch(e){dec=Buffer.from(tok).toString('utf8');}strings.push(dec);inStr=false;}
else{if(tokLen<PREFIX)tok.push(b);tokLen++;}}
continue;}
if(mode==='done')break;}
if(mode==='done')break;}
fs.closeSync(fd);
const N=selfA.length; let total=0; const byType=new Map(),byName=new Map(),strBucket=new Map(); const big=[];
for(let i=0;i<N;i++){const t=TYPES[typeA[i]]||('t'+typeA[i]); const self=selfA[i]; total+=self;
let a=byType.get(t);if(!a)byType.set(t,a={size:0,count:0});a.size+=self;a.count++;
const name=strings[nameA[i]]!==undefined?strings[nameA[i]]:'?';
const key=(t==='string'||t==='concatenated string'||t==='sliced string')?t+':(str)':t+':'+name;
let bb=byName.get(key);if(!bb)byName.set(key,bb={size:0,count:0});bb.size+=self;bb.count++;
if((t==='string'||t==='concatenated string')&&self>20000){big.push({size:self,s:name});
const sig=String(name).slice(0,70).replace(/\s+/g,' ');let c=strBucket.get(sig);if(!c)strBucket.set(sig,c={size:0,count:0});c.size+=self;c.count++;}}
const MB=x=>(x/1048576).toFixed(1);
console.log('TOTAL',MB(total),'MB,',N,'nodes');
console.log('\nBY TYPE');[...byType].sort((a,b)=>b[1].size-a[1].size).slice(0,12).forEach(([t,v])=>console.log(MB(v.size).padStart(9),'MB',String(v.count).padStart(9),t));
console.log('\nBY CONSTRUCTOR');[...byName].sort((a,b)=>b[1].size-a[1].size).slice(0,28).forEach(([k,v])=>console.log(MB(v.size).padStart(9),'MB',String(v.count).padStart(8)+'x',k.slice(0,78)));
console.log('\nLARGEST STRINGS');big.sort((a,b)=>b.size-a.size).slice(0,22).forEach(s=>console.log(MB(s.size).padStart(8),'MB |',String(s.s).slice(0,130).replace(/\n/g,'\\n')));
console.log('\nSTRING BUCKETS');[...strBucket].sort((a,b)=>b[1].size-a[1].size).slice(0,22).forEach(([sig,v])=>console.log(MB(v.size).padStart(8),'MB',String(v.count).padStart(6)+'x |',sig));
```