Skip to content

The Drift Log · Model independence and repeatable builds

Pinning Socket Timeouts and Clocks in Test Environments

26 September 2026 · 3 min read · 568 words · established

Two identical crystal prisms casting exactly the same refraction

Eliminate flaky CI runs by replacing ambient wall clocks and real kernel sockets with explicit clock injection and in-memory duplex streams.

A test suite fails on run 42 out of 50. Nothing changed in the git tree. A developer clicks "Re-run failed jobs," the run passes green, and the pull request gets merged.

Over time, this pattern destroys the signal of your continuous integration pipeline. When a suite permits flaky tests to pass on retry, developers stop treating red builds as software regressions. They treat them as weather.

Flakiness is rarely an erratic algorithm. It is ambient state leaking into code that assumed it was isolated. The two most frequent offenders in backend suites are the operating system wall clock and real network sockets.

The Ambient Variance Leak

Consider a component that reads from a socket with a 50ms timeout. On a developer workstation, loopback I/O returns in under a millisecond. On a shared CI runner under high memory pressure or during a garbage collection pause, a thread context switch can easily stall execution for 60ms. The socket driver raises a timeout error, an assertion fails, and the runner reports a broken build.

The inverse failure is just as common: a test uses an arbitrary sleep(100) to ensure an asynchronous worker has processed a message. On a noisy VM, that worker takes 105ms. The assertion executes before the work completes, and the test fails.

System clocks introduce identical failure modes. Code that computes expiration or TTL windows using unpinned wall time (Date.now(), std::time::Instant, or time.Now()) behaves differently depending on whether a run crosses a second boundary, whether sub-millisecond clock resolution is available on the target architecture, or whether an NTP sync steps the local clock backwards mid-run.

When tests depend on physical elapsed time and OS network scheduling, they are no longer testing your state machine. They are testing the momentary load of the CI host.

Replacing Sockets with In-Memory Transports

The standard reaction to socket timeouts in CI is to increase the timeout limit from 50ms to 5,000ms. This is an anti-pattern. Lengthening timeouts only narrows the race window while making the entire test suite painfully slow.

The correct posture is to remove kernel socket interaction from unit and integration verification entirely.

// Anti-pattern: binding a real TCP port introduces OS scheduling variance
const server = net.createServer().listen(0);

// Correct posture: pipe an in-memory duplex stream
const clientTransport = new PassThrough();
const serverTransport = new PassThrough();
const session = new ProtocolSession({
  reader: serverTransport,
  writer: clientTransport,
});

If you are testing wire framing, serialization, or state transitions, execute the exchange over in-memory pipe buffers (such as Node's PassThrough stream or Go's net.Pipe()). An in-memory stream provides ordered, synchronous byte delivery without kernel buffer limits, port exhaustion, or OS network stack overhead.

If you need to test connection drops or mid-stream resets, trigger them explicitly via your transport mock rather than attempting to induce them with socket flags or thread termination.

Decoupling Logic from the System Clock

Production code should never reach out to ambient wall time directly. Pass a clock interface or a timestamp provider into your domain logic and state engines.

interface Clock {
  now(): number;
}

class ManualClock implements Clock {
  private current: number;

  constructor(initial = 0) {
    this.current = initial;
  }

  now(): number {
    return this.current;
  }

  advance(ms: number): void {
    if (ms < 0) {
      throw new Error("Time cannot flow backwards");
    }
    this.current += ms;
  }
}

class ExpiringCache<T> {
  private store = new Map<string, { value: T; expiresAt: number }>();

  constructor(
    private clock: Clock,
    private ttlMs: number,
  ) {}

  set(key: string, value: T): void {
    this.store.set(key, {
      value,
      expiresAt: this.clock.now() + this.ttlMs,
    });
  }

  get(key: string): T | undefined {
    const entry = this.store.get(key);
    if (!entry) return undefined;
    if (this.clock.now() >= entry.expiresAt) {
      this.store.delete(key);
      return undefined;
    }
    return entry.value;
  }
}

This pattern eliminates every sleep() call used to test expiration logic.

// In your test suite:
const clock = new ManualClock(1_000);
const cache = new ExpiringCache<string>(clock, 500);

cache.set("session_123", "active");
assert.equal(cache.get("session_123"), "active");

// Move time forward explicitly; no actual milliseconds elapse
clock.advance(500);
assert.equal(cache.get("session_123"), undefined);

The test runs synchronously. It executes in microseconds, produces identical results across heterogeneous CPU architectures, and cannot flake under VM scheduler latency.

Isolating the Boundaries

The goal is not to eliminate all real I/O from your testing lifecycle. You still need end-to-end smoke tests against live infrastructure to verify deployment topologies and network drivers.

However, the bulk of your test suite should exercise contracts and state transitions against explicit abstractions. When time and I/O are injected parameters rather than ambient dependencies, flaky test runs cease to exist. A test fails only when the code is broken.

Keep reading

Next in the log

The Strategic Master Library · written and reviewed under the house's own epistemic rules: nothing claimed that we cannot show.