diff --git a/dc-polyfill.js b/dc-polyfill.js index 6c3f130..ea4cf07 100644 --- a/dc-polyfill.js +++ b/dc-polyfill.js @@ -24,6 +24,11 @@ if (!checks.hasChannelStoreMethods()) { if (!checks.hasTracingChannel()) { dc = require('./patch-tracing-channel.js')(dc); +} else if (checks.hasGarbageCollectionBug()) { + // Native tracingChannel exists but the underlying channel() registry is + // unstable; memoize tracingChannel by name so its sub-channel identities stay + // stable across calls. + dc = require('./patch-tracing-channel-stable-identity.js')(dc); } if (checks.hasSyncUnsubscribeBug()) { diff --git a/patch-tracing-channel-stable-identity.js b/patch-tracing-channel-stable-identity.js new file mode 100644 index 0000000..8285973 --- /dev/null +++ b/patch-tracing-channel-stable-identity.js @@ -0,0 +1,36 @@ +// Make dc.tracingChannel(name) return a stable TracingChannel object per name +// on Node versions that have a native tracingChannel BUT also have the +// channel-registry GC bug (Node 16.17–16.x, 18.7–18.x, 20.0–20.5). +// +// On those versions the native tracingChannel(name) constructor calls Node's +// internal channel() to build its five sub-channels (start, end, asyncStart, +// asyncEnd, error). Because the underlying registry can return a brand-new +// Channel object for the same name on a subsequent call, two `tracingChannel(name)` +// invocations can end up with different sub-channel identities. Subscribers +// attached to one TracingChannel's sub-channels then miss publishes from +// another TracingChannel's sub-channels for the same name. +// +// patch-garbage-collection-bug.js memoizes `dc.channel(name)`, but +// native tracingChannel bypasses that wrapper — it goes through Node's +// internal channel() directly. Memoizing the TracingChannel by name closes +// the gap: same name returns the same TC (and therefore the same sub-channels) +// for the lifetime of the process. + +module.exports = function (unpatched) { + const dc = { ...unpatched }; + const original = dc.tracingChannel; + const byName = new Map(); + + dc.tracingChannel = function (nameOrChannels) { + if (typeof nameOrChannels !== 'string') { + // Object form — caller is providing explicit sub-channels; passthrough. + return original.call(this, nameOrChannels); + } + if (byName.has(nameOrChannels)) return byName.get(nameOrChannels); + const tc = original.call(this, nameOrChannels); + byName.set(nameOrChannels, tc); + return tc; + }; + + return dc; +}; diff --git a/test/tracing-channel-stable-identity.spec.js b/test/tracing-channel-stable-identity.spec.js new file mode 100644 index 0000000..73777d0 --- /dev/null +++ b/test/tracing-channel-stable-identity.spec.js @@ -0,0 +1,90 @@ +const test = require('tape'); +const patch = require('../patch-tracing-channel-stable-identity.js'); + +// Simulate the failure mode this patch addresses: native tracingChannel +// returns a fresh TracingChannel object on every string-form call (because +// Node's underlying channel() registry returned different sub-channel +// objects). The patch's job is to memoize TracingChannel objects by name +// so callers see a stable identity per name. +function mockUnpatched() { + const calls = { count: 0 }; + function tracingChannel(nameOrChannels) { + calls.count++; + if (typeof nameOrChannels === 'string') { + // Mimic the native behavior of building fresh sub-channels per call, + // each with their own identity. + return { + _name: nameOrChannels, + _instanceId: calls.count, + start: { _name: `${nameOrChannels}:start`, _instanceId: calls.count }, + end: { _name: `${nameOrChannels}:end`, _instanceId: calls.count }, + asyncStart: { _name: `${nameOrChannels}:asyncStart`, _instanceId: calls.count }, + asyncEnd: { _name: `${nameOrChannels}:asyncEnd`, _instanceId: calls.count }, + error: { _name: `${nameOrChannels}:error`, _instanceId: calls.count }, + }; + } + return { _passthrough: true, channels: nameOrChannels }; + } + return { tracingChannel, calls }; +} + +test('tracing-channel stable-identity patch: tracingChannel(name) returns same TracingChannel across calls', t => { + const { tracingChannel, calls } = mockUnpatched(); + const dc = patch({ tracingChannel }); + + const a = dc.tracingChannel('foo'); + const b = dc.tracingChannel('foo'); + const c = dc.tracingChannel('foo'); + + t.strictEqual(a, b, 'second call returns same TracingChannel'); + t.strictEqual(b, c, 'third call returns same TracingChannel'); + t.strictEqual(a.start, b.start, 'sub-channel start identity stable'); + t.strictEqual(a.end, b.end, 'sub-channel end identity stable'); + t.strictEqual(a.asyncStart, b.asyncStart, 'sub-channel asyncStart identity stable'); + t.strictEqual(a.asyncEnd, b.asyncEnd, 'sub-channel asyncEnd identity stable'); + t.strictEqual(a.error, b.error, 'sub-channel error identity stable'); + + const callsAfterMemoization = calls.count; + dc.tracingChannel('foo'); + dc.tracingChannel('foo'); + t.equal(calls.count, callsAfterMemoization, + 'memoized lookups do not re-invoke the underlying tracingChannel()'); + + t.end(); +}); + +test('tracing-channel stable-identity patch: distinct names get distinct TracingChannels', t => { + const { tracingChannel } = mockUnpatched(); + const dc = patch({ tracingChannel }); + + const foo = dc.tracingChannel('foo'); + const bar = dc.tracingChannel('bar'); + + t.notStrictEqual(foo, bar, 'different names return different TracingChannels'); + t.notStrictEqual(foo.start, bar.start, 'different names have different sub-channels'); + t.strictEqual(dc.tracingChannel('foo'), foo, 'foo memoization holds'); + t.strictEqual(dc.tracingChannel('bar'), bar, 'bar memoization holds'); + t.end(); +}); + +test('tracing-channel stable-identity patch: object form is passthrough (not memoized)', t => { + const { tracingChannel } = mockUnpatched(); + const dc = patch({ tracingChannel }); + + const channels = { + start: { _name: 'custom:start' }, + end: { _name: 'custom:end' }, + asyncStart: { _name: 'custom:asyncStart' }, + asyncEnd: { _name: 'custom:asyncEnd' }, + error: { _name: 'custom:error' }, + }; + + const a = dc.tracingChannel(channels); + const b = dc.tracingChannel(channels); + + // Object form bypasses memoization — caller is providing explicit channels. + t.notStrictEqual(a, b, 'object form returns a fresh wrapper each time'); + t.ok(a._passthrough, 'first call passes through'); + t.ok(b._passthrough, 'second call passes through'); + t.end(); +});