microsoft / microsoft/tsyringe

disposables set is append-only: dispose() re-runs callbacks, reset()/clearInstances() leave stale entries, transients leak

Open
#285 0 comments 0 reactions 0 assignees View on GitHub

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

  1. Double dispose: calling dispose() twice re-invokes every disposable callback. Non-idempotent teardowns (closing connections, flushing sinks) run twice with no guard.
  2. Stale disposal after reset(): reset() clears registrations but keeps the old instances in disposables. Re-register, re-resolve, then dispose() tears down objects from the pre-reset generation that the container no longer owns — objects the application may already have torn down itself.
  3. Stale disposal after clearInstances(): same shape — "cleared" instances are still disposed later, contradicting the method's promise.
  4. 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):

  1. Register a disposable class, resolve it, call await container.dispose() twice → the instance's dispose() runs twice.
  2. Resolve, call container.reset(), re-register/re-resolve, call dispose() → the pre-reset instance's dispose() still runs.
  3. Resolve N transient disposables in a long-lived process → disposables holds 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 disposables in dispose() with an early return when already disposed; remove entries whose registrations are dropped in reset()/clearInstances() (or document that those methods intentionally leave disposal to the caller — but then dispose() must not silently do it later). No API shape change required.

Evidence

  • Source via API: dependency-container.ts shows disposables with exactly one writer (.add in construct) and one reader (.forEach in dispose), and reset/clearInstances bodies that never touch it.
  • Duplicate check: issue search for dispose twice idempotent returns total_count: 0; the open dispose issues 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.