Skip to content

Transient injection cache can publish incomplete state #691

Description

@jinglesthula

We've seen intermittent errors on our prod servers under load where Wirebox-injected dependencies go missing. In digging into the issue, we discovered a subtle issue with transient objects under certain conditions.

Here's the tl;dr. Everything after that is AI-generated deeper detail that may be useful during investigation.

When two cfthread or runAsync() workers in the same request resolve the same transient (NoScope) mapping at the same time, one worker can receive an object with some injected properties missing.

Cause: WireBox creates a request-level cache entry before it finishes injecting all properties into the object. The second worker sees that entry and copies the properties currently present instead of waiting or resolving the object independently. The minimal test below reproduces this with WireBox 8.1.0+34; setting transientInjectionCache=false makes both objects fully initialized.


Summary

WireBox can return a partially initialized NoScope object when sibling cfthread or runAsync() workers resolve the same mapping during one request.

The workers share the request-scoped transient injection cache. WireBox adds the target cache key before it finishes resolving all properties. A second worker can see that key, copy the properties that are available at that moment, and return from the cache-hit path.

The second object is then incomplete. It is a separate object. This is not shared object identity.

Environment

The issue was observed with:

  • ColdBox/WireBox 8.1.0+34
  • Adobe ColdFusion 2023,0,22,330928
  • transientInjectionCache = true (the default)

The reproducer needs only these items:

  • a ColdBox/WireBox injector;
  • a shared request scope; and
  • two sibling workers that resolve the same mapping at the same time.

It does not need a database, network access, application code, or external data.

Minimal reproducer

Save this component as TransientRaceTarget.cfc:

Important: The target component must be on the test classpath under the name used by the mapping. If the environment needs a qualified component name, change only TransientRaceTarget in the .to() call on line 84 of the TestBox test below.

// TransientRaceTarget.cfc

component output="false" {

	variables.instanceID = createUUID();

	public struct function snapshot() {
		return {
			instanceID = variables.instanceID,
			hasFirstDependency = variables.keyExists("firstDependency"),
			hasBlockingDependency = variables.keyExists("blockingDependency"),
			hasTrailingDependency = variables.keyExists("trailingDependency"),
			fullyInitialized = variables.keyExists("firstDependency")
				&& variables.keyExists("blockingDependency")
				&& variables.keyExists("trailingDependency")
				&& variables.firstDependency.ready
				&& variables.blockingDependency.ready
				&& variables.trailingDependency.ready
		};
	}

}

Run this TestBox test in a ColdBox environment. Each run uses unique mapping names. Earlier request-cache entries therefore cannot affect the result.

component extends="testbox.system.compat.framework.TestCase" output="false" {

	public void function testCacheCanReturnPartiallyInitializedNoScopeTarget() {
		var result = runRace(true);
		var first = result.first.snapshot();
		var second = result.second.snapshot();

		assertTrue(first.fullyInitialized);
		assertTrue(second.hasFirstDependency);
		assertFalse(second.hasBlockingDependency);
		assertFalse(second.hasTrailingDependency);
		assertFalse(second.fullyInitialized);
		assertNotEquals(first.instanceID, second.instanceID);
		assertEquals(1, result.blockingDependencyCalls.get());
		assertEquals(1, result.trailingDependencyCalls.get());
		assertEquals(0, result.firstWorkerEntered.getCount());
		assertEquals(0, result.releaseFirstWorker.getCount());
	}

	public void function testCacheDisabledBuildsTwoCompleteNoScopeTargets() {
		var result = runRace(false);
		var first = result.first.snapshot();
		var second = result.second.snapshot();

		assertTrue(first.fullyInitialized);
		assertTrue(second.fullyInitialized);
		assertNotEquals(first.instanceID, second.instanceID);
		assertEquals(2, result.blockingDependencyCalls.get());
		assertEquals(2, result.trailingDependencyCalls.get());
		assertEquals(0, result.firstWorkerEntered.getCount());
		assertEquals(0, result.releaseFirstWorker.getCount());
	}

	private struct function runRace(required boolean transientInjectionCache) {
		var timeUnit = createObject("java", "java.util.concurrent.TimeUnit");
		var coordinator = {
			blockingDependencyCalls = createObject("java", "java.util.concurrent.atomic.AtomicInteger").init(0),
			trailingDependencyCalls = createObject("java", "java.util.concurrent.atomic.AtomicInteger").init(0),
			firstWorkerEntered = createObject("java", "java.util.concurrent.CountDownLatch").init(1),
			releaseFirstWorker = createObject("java", "java.util.concurrent.CountDownLatch").init(1),
			timeUnit = timeUnit
		};
		var injector = new coldbox.system.ioc.Injector({
			scopeRegistration = { enabled = false },
			transientInjectionCache = arguments.transientInjectionCache
		});
		var binder = injector.getBinder();
		var suffix = replace(createUUID(), "-", "", "all");
		var firstName = "raceFirst#suffix#";
		var blockingName = "raceBlocking#suffix#";
		var trailingName = "raceTrailing#suffix#";
		var targetName = "raceTarget#suffix#";
		var raceInjector = injector;
		var raceTargetName = targetName;
		var raceCoordinator = coordinator;

		binder.map(firstName)
			.toProvider(function() { return { ready = true }; })
			.into("NoScope");
		binder.map(blockingName)
			.toProvider(function() {
				var callNumber = raceCoordinator.blockingDependencyCalls.incrementAndGet();
				if (callNumber == 1) {
					raceCoordinator.firstWorkerEntered.countDown();
					if (!raceCoordinator.releaseFirstWorker.await(10, raceCoordinator.timeUnit.SECONDS)) {
						throw(message = "Timed out waiting to release first dependency resolution.");
					}
				}
				return { ready = true, callNumber = callNumber };
			})
			.into("NoScope");
		binder.map(trailingName)
			.toProvider(function() {
				raceCoordinator.trailingDependencyCalls.incrementAndGet();
				return { ready = true };
			})
			.into("NoScope");
		binder.map(targetName)
			.to("TransientRaceTarget")
			.into("NoScope")
			.property(name = "firstDependency", ref = firstName)
			.property(name = "blockingDependency", ref = blockingName)
			.property(name = "trailingDependency", ref = trailingName);

		var resolveTarget = function() {
			return raceInjector.getInstance(raceTargetName);
		};
		var firstFuture = runAsync(resolveTarget);

		if (!coordinator.firstWorkerEntered.await(10, timeUnit.SECONDS)) {
			coordinator.releaseFirstWorker.countDown();
			throw(message = "First worker did not reach blocking dependency resolution.");
		}

		var secondFuture = runAsync(resolveTarget);
		try {
			var second = secondFuture.get();
		} finally {
			coordinator.releaseFirstWorker.countDown();
		}

		return {
			first = firstFuture.get(),
			second = second,
			blockingDependencyCalls = coordinator.blockingDependencyCalls,
			trailingDependencyCalls = coordinator.trailingDependencyCalls,
			firstWorkerEntered = coordinator.firstWorkerEntered,
			releaseFirstWorker = coordinator.releaseFirstWorker
		};
	}

}

Reproducer schedule

The latch fixes the order of events:

  1. Worker A resolves and injects the first dependency.
  2. Worker A starts the blocking dependency and waits on the latch.
  3. Worker B resolves the same target mapping.
  4. With the cache enabled, B sees the target key and copies the published first dependency.
  5. B returns without resolving the blocking or trailing dependency.
  6. The test releases A. A finishes normally.

The provider counters show whether B resolved each dependency independently.

Observed and expected behavior

With transientInjectionCache = true:

  • A is fully initialized.
  • B is a different object.
  • B contains firstDependency only.
  • B does not contain blockingDependency or trailingDependency.
  • The blocking and trailing providers each run once.

With transientInjectionCache = false:

  • A and B are different, fully initialized objects.
  • The blocking and trailing providers each run twice.

Expected behavior:

A cache hit must not expose an injection or delegation entry while WireBox is still building that entry. A cache hit must see a complete entry, or the worker must build its own entry.

Relevant implementation behavior

The failure needs this sequence:

cache miss
  create or publish target cache entry
  resolve dependency 1
  publish dependency 1 into nested entry
  resolve dependency 2

cache hit from sibling worker
  find target key
  copy currently visible nested entries
  return without resolving missing definitions

An outer concurrent map does not make ordinary nested CFML structs safe as a publication protocol. The presence of the target key must not mean that injection is complete.

Request to vendor

Please:

  1. Confirm whether this publication/read race exists in the affected ColdBox/WireBox versions.
  2. Identify the correct publication boundary for transient injection and delegation state.
  3. Review setter injection, delegation, callbacks, lazy properties, mixins, and related autowire lifecycle behavior.
  4. Provide a supported upgrade, patch, or configuration mitigation.
  5. Confirm whether disabling transientInjectionCache is a supported temporary mitigation.
  6. Add, or recommend, a regression test with two sibling workers. Both workers must resolve the same normal NoScope mapping while dependency resolution overlaps.

Publishing the entry only after a private build completes is one possible design direction. The framework maintainers should choose the fix and verify its lifecycle behavior.

Scope and limitations

This report demonstrates a race between multiple workers in one request.

Independent HTTP requests normally have separate request scopes. They do not reproduce this exact condition.

The report does not require, and does not claim, shared target-object identity. The failure is incomplete initialization of two distinct NoScope instances.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions