Skip to content

Commit 142e6ea

Browse files
authored
Memory Diagnotics feature: track the allocation of XmlDisposable (#16)
1 parent 7ef149f commit 142e6ea

File tree

3 files changed

+181
-2
lines changed

3 files changed

+181
-2
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"link": "npm run bind && npm run dist",
1414
"wasm": "npm run clean && npm run libxml && npm run link",
1515
"unit": "mocha --v8-expose-gc",
16-
"integ": "TS_NODE_PROJECT=tsconfig.integ.json mocha test/crossplatform",
16+
"integ": "TS_NODE_PROJECT=tsconfig.integ.json mocha --v8-expose-gc test/crossplatform",
1717
"test": "npm run cov && npm run lint",
1818
"cov": "c8 npm run unit",
1919
"build": "npm run wasm && tsc -p tsconfig.prod.json --declaration",

src/disposable.mts

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,136 @@ import type { Pointer } from './libxml2raw';
22
import './disposeShim.mjs';
33
import './metadataShim.mjs';
44

5+
interface MemTracker {
6+
trackAllocate(obj: XmlDisposable): void;
7+
trackDeallocate(obj: XmlDisposable): void;
8+
report(): any;
9+
}
10+
11+
const noopTracker: MemTracker = {
12+
trackAllocate(): void {
13+
},
14+
15+
trackDeallocate(): void {
16+
},
17+
18+
report(): any {
19+
},
20+
};
21+
22+
interface MemTrackingInfo {
23+
object: WeakRef<XmlDisposable>;
24+
classname: string;
25+
}
26+
27+
class MemTrackerImpl implements MemTracker {
28+
// js/ts doesn't have a builtin universal id for objects as in python,
29+
// we build a similar thing
30+
idCounter: number;
31+
32+
// from object to id
33+
disposableId: WeakMap<XmlDisposable, number>;
34+
35+
// from id to tracking info
36+
disposableInfo: Map<number, MemTrackingInfo>;
37+
38+
constructor() {
39+
this.idCounter = 0;
40+
this.disposableId = new WeakMap<XmlDisposable, number>();
41+
this.disposableInfo = new Map<number, MemTrackingInfo>();
42+
}
43+
44+
trackAllocate(obj: XmlDisposable): void {
45+
this.idCounter += 1;
46+
this.disposableId.set(obj, this.idCounter);
47+
this.disposableInfo.set(this.idCounter, {
48+
object: new WeakRef(obj),
49+
classname: obj.constructor.name,
50+
});
51+
}
52+
53+
trackDeallocate(obj: XmlDisposable): void {
54+
const id = this.disposableId.get(obj);
55+
if (id) { // the object may be created before the diagnosis enabled
56+
this.disposableInfo.delete(id);
57+
this.disposableId.delete(obj);
58+
}
59+
}
60+
61+
report(): any {
62+
const report: any = {};
63+
this.disposableInfo.forEach((info) => {
64+
const classReport = report[info.classname] ||= { // eslint-disable-line no-multi-assign
65+
garbageCollected: 0,
66+
totalInstances: 0,
67+
instances: [],
68+
};
69+
classReport.totalInstances += 1;
70+
const obj = info.object.deref();
71+
if (obj != null) {
72+
classReport.instances.push({ instance: obj });
73+
} else {
74+
classReport.garbageCollected += 1;
75+
}
76+
});
77+
return report;
78+
}
79+
}
80+
81+
/**
82+
* Memory Diagnostic options.
83+
*/
84+
export interface MemDiagOptions {
85+
/**
86+
* Enabling the memory diagnostics.
87+
* Note the tracking information will be lost when it is disabled.
88+
*/
89+
enabled: boolean;
90+
}
91+
92+
/**
93+
* Set up memory diagnostic helpers.
94+
*
95+
* When enabled, it will record allocated {@link XmlDisposable} objects
96+
* (and its subclass objects) and track if
97+
* {@link XmlDisposable#dispose} is called.
98+
*
99+
* Note that the allocation will not be monitored before memory diagnostics is enabled.
100+
*
101+
* @param options
102+
* @see {@link memReport}
103+
*/
104+
export function memDiag(options: MemDiagOptions) {
105+
if (options.enabled) {
106+
tracker = new MemTrackerImpl();
107+
} else {
108+
tracker = noopTracker;
109+
}
110+
}
111+
112+
/**
113+
* Get the report of un-disposed objects.
114+
* @returns The report (JSON) object, whose format may vary according to the settings,
115+
* and is subject to change.
116+
* Returns undefined if memory diagnostic is not enabled.
117+
* @see {@link memDiag}
118+
*/
119+
export function memReport(): any {
120+
return tracker.report();
121+
}
122+
123+
let tracker: MemTracker = noopTracker;
124+
5125
/**
6126
* Base implementation of interface Disposable to handle wasm memory.
7127
*
8128
* Remember to call `dispose()` for any subclass object.
9129
*/
10130
export abstract class XmlDisposable implements Disposable {
131+
protected constructor() {
132+
tracker.trackAllocate(this);
133+
}
134+
11135
/**
12136
* Alias of {@link "[dispose]"}.
13137
*
@@ -18,6 +142,7 @@ export abstract class XmlDisposable implements Disposable {
18142
const metadata = (this.constructor as any)[Symbol.metadata];
19143
const propsToRelease = metadata[Symbol.dispose] as Array<string | symbol>;
20144
propsToRelease.forEach((prop) => { (this as any)[prop] = 0; });
145+
tracker.trackDeallocate(this);
21146
}
22147

23148
/**
@@ -44,7 +169,7 @@ export abstract class XmlDisposable implements Disposable {
44169
* @param free function to release the managed wasm resource
45170
* @returns the decorator
46171
*/
47-
export function disposeBy<This extends object>(free: (value: Pointer) => void) {
172+
export function disposeBy<This extends XmlDisposable>(free: (value: Pointer) => void) {
48173
return function decorator(
49174
target: ClassAccessorDecoratorTarget<This, Pointer>,
50175
ctx: ClassAccessorDecoratorContext<This, Pointer>,

test/crossplatform/diag.spec.mts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { expect } from 'chai';
2+
import { disposable, XmlDocument } from '@libxml2-wasm/lib/index.mjs';
3+
4+
describe('memDiag', () => {
5+
it('should report remaining objects', () => {
6+
disposable.memDiag({ enabled: true });
7+
const xml1 = XmlDocument.fromString('<doc/>');
8+
const xml2 = XmlDocument.fromString('<doc/>');
9+
10+
xml1.dispose();
11+
const report = disposable.memReport();
12+
13+
expect(report.XmlDocument).is.not.null;
14+
expect(report.XmlDocument.totalInstances).to.equal(1);
15+
expect(report.XmlDocument.garbageCollected).to.equal(0);
16+
expect(report.XmlDocument.instances[0].instance).to.equal(xml2);
17+
xml2.dispose();
18+
disposable.memDiag({ enabled: false });
19+
});
20+
21+
it('should report GC\'ed objects', async () => {
22+
disposable.memDiag({ enabled: true });
23+
const xml1 = XmlDocument.fromString('<doc/>');
24+
XmlDocument.fromString('<doc/>'); // to be GC'ed
25+
26+
// allow finalizer to run
27+
await new Promise((resolve) => { setTimeout(resolve, 0); });
28+
(global as any).gc();
29+
const report = disposable.memReport();
30+
31+
expect(report.XmlDocument).is.not.null;
32+
expect(report.XmlDocument.totalInstances).to.equal(2);
33+
expect(report.XmlDocument.garbageCollected).to.equal(1);
34+
expect(report.XmlDocument.instances[0].instance).to.equal(xml1);
35+
xml1.dispose();
36+
disposable.memDiag({ enabled: false });
37+
});
38+
39+
it('will not track allocation before enabled', () => {
40+
const xml1 = XmlDocument.fromString('<doc/>');
41+
disposable.memDiag({ enabled: true });
42+
const xml2 = XmlDocument.fromString('<doc/>');
43+
44+
const report = disposable.memReport();
45+
46+
expect(report.XmlDocument).is.not.null;
47+
expect(report.XmlDocument.totalInstances).to.equal(1);
48+
expect(report.XmlDocument.garbageCollected).to.equal(0);
49+
expect(report.XmlDocument.instances[0].instance).to.equal(xml2);
50+
xml2.dispose();
51+
xml1.dispose();
52+
disposable.memDiag({ enabled: false });
53+
});
54+
});

0 commit comments

Comments
 (0)