microsoft / microsoft/tsyringe
disposables set is append-only: dispose() re-runs callbacks, reset()/clearInstances() leave stale entries, transients leak
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 6k
- Forks
- 184
- Avg merge
- 3m
- Merged PRs (30d)
- 1
Description
Summary
InternalDependencyContainer adds every constructed disposable instance to a private disposables set that is never pruned: reset() and clearInstances() leave stale entries behind, dispose() never clears the set and has no run-once guard, and transient resolutions accumulate forever. The result is double-dispose callbacks, disposal of instances the container no longer owns, and unbounded retention.
Location
- File:
src/dependency-container.ts - Field:
private disposables = new Set<Disposable>()(line ~50) - Writers/readers:
construct()→this.disposables.add(instance)(only writer);dispose()→this.disposables.forEach(...)(only reader)
Relevant code path (static analysis of current main):
public reset(): void {
this.ensureNotDisposed();
this._registry.clear();
this.interceptors.preResolution.clear();
this.interceptors.postResolution.clear();
// disposables untouched
}
public clearInstances(): void {
...
// rewrites registrations, drops instances — disposables untouched
}
public async dispose(): Promise<void> {
this.disposed = true;
const promises: Promise<unknown>[] = [];
this.disposables.forEach(disposable => { ... }); // no clear, no run-once guard
await Promise.all(promises);
}
Problem
- Double dispose: calling
dispose()twice re-invokes every disposable callback. Non-idempotent teardowns (closing connections, flushing sinks) run twice with no guard. - Stale disposal after
reset():reset()clears registrations but keeps the old instances indisposables. Re-register, re-resolve, thendispose()tears down objects from the pre-reset generation that the container no longer owns — objects the application may already have torn down itself. - Stale disposal after
clearInstances(): same shape — "cleared" instances are still disposed later, contradicting the method's promise. - Unbounded retention:
construct()adds transients too, so every transient disposable ever resolved is retained for the container's lifetime even though transients are never reused — a slow leak in long-lived containers.
Trigger / Reproduction
Based on static analysis (no execution performed):
- Register a disposable class, resolve it, call
await container.dispose()twice → the instance'sdispose()runs twice. - Resolve, call
container.reset(), re-register/re-resolve, calldispose()→ the pre-reset instance'sdispose()still runs. - Resolve N transient disposables in a long-lived process →
disposablesholds all N forever.
Note: this is a static-analysis finding; I did not execute a reproduction script.
Expected Behavior
dispose() is run-once (second call is a no-op) and drains the set; reset()/clearInstances() prune entries for instances they drop, so dispose() only ever tears down currently-owned instances and the container retains nothing it cannot reach.
Actual Behavior
The set only grows; lifecycle methods disagree about ownership, and callbacks can fire multiple times or for dead generations.
Impact
- Double resource teardown (errors or duplicated side effects in non-idempotent
dispose()implementations). - Use-after-reset disposal of objects the app owns again — or leaks, for transients.
- Related to (but distinct from) the open disposal-ownership issues: those concern which container disposes; this concerns the set being append-only with no run-once semantics.
Suggested Direction
- Clear/drain
disposablesindispose()with an early return when already disposed; remove entries whose registrations are dropped inreset()/clearInstances()(or document that those methods intentionally leave disposal to the caller — but thendispose()must not silently do it later). No API shape change required.
Evidence
- Source via API:
dependency-container.tsshowsdisposableswith exactly one writer (.addinconstruct) and one reader (.forEachindispose), andreset/clearInstancesbodies that never touch it. - Duplicate check: issue search for
dispose twice idempotentreturnstotal_count: 0; the opendisposeissues cover cross-container ownership and provider coverage, not set lifecycle — no apparent duplicate. Non-security correctness finding.
Classification
- FACT: the set is append-only with no guard or pruning (verified in source via API).
- INFERENCE: double/stale disposal and transient retention follow directly.
- HYPOTHESIS: drain-on-dispose plus pruning on reset/clear restores ownership semantics with no API change.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/dependency-container.ts and trace construct(), reset(), clearInstances(), and dispose() alongside the disposables set. Verify the double-dispose, reset, clearInstances, and transient scenarios described in the issue. Done means disposal is run once, the set is drained, dropped instances are pruned, and no stale or transient entries remain retained.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 62/100