# We Found a Ticking Time Bomb in macOS TCP Networking - It Detonates After Exactly 49 Days - Photon Blog

![](https://framerusercontent.com/images/s9SiRkag2FvqBU71ZkP0m3zZZQ.png?width=2400&height=1260) ![](https://framerusercontent.com/images/8q1bjcCk4MogVeeZ2qtVTkEnew.png?width=1556&height=1024)

Preface

Every Mac has a hidden expiration date. After exactly 49 days, 17 hours, 2 minutes, and 47 seconds of continuous uptime, a 32-bit unsigned integer overflow in Apple's XNU kernel freezes the internal TCP timestamp clock. Once frozen, TIME\_WAIT connections never expire, ephemeral ports slowly exhaust, and eventually no new TCP connections can be established at all. ICMP (ping) keeps working. Everything else dies. The only fix most people know is a reboot. We discovered this bug on our iMessage service monitoring fleet, reproduced it live on two machines, and traced the root cause to a single comparison in the XNU kernel source. This is the full story.

## Background: The Concepts You Need

Before diving into the bug, here is a quick primer on the building blocks. If you already know what `TIME_WAIT`, MSL, and integer overflow mean, skip ahead to the discovery.

### What Is TIME\_WAIT?

When a TCP connection closes, it does not disappear immediately. The side that initiates the close enters a state called **TIME\_WAIT**. In this state, the connection is functionally dead — no data flows — but the operating system keeps it around for a short period.

Why? Two reasons:

1. **Late packets.** The internet does not guarantee packet ordering. A packet from the old connection could still be bouncing through routers. If the OS immediately reused the same source port and destination for a new connection, that stale packet could be misinterpreted as belonging to the new connection, corrupting data.
2. **Reliable close.** TCP's four-way close handshake ends with a final ACK from the active closer. If that ACK gets lost, the other side will retransmit its FIN. The TIME\_WAIT state keeps the connection alive long enough to handle that retransmission.

The duration of TIME\_WAIT is defined as **2 × MSL** (Maximum Segment Lifetime). Once it expires, the OS reclaims the connection's resources — including the ephemeral port it occupied.

### What Is MSL?

**MSL (Maximum Segment Lifetime)** is the longest time a TCP segment is expected to survive in the network before being discarded. RFC 793, the original TCP specification from 1981, set MSL at **2 minutes**, making TIME\_WAIT = 4 minutes.

In practice, modern operating systems use much shorter values:

| Operating System | MSL | TIME\_WAIT duration (2×MSL) |
| --- | --- | --- |
| Linux | 30 seconds | 60 seconds |
| macOS / XNU | 15 seconds | 30 seconds |
| Windows | 120 seconds (default) | 240 seconds |

On macOS, a closed TCP connection sits in TIME\_WAIT for just **30 seconds** before being cleaned up. That is fast — unless the cleanup mechanism itself breaks.

### What Is a 32-Bit Unsigned Integer Wraparound?

A `uint32_t` in C can hold values from 0 to 4,294,967,295 (2³² − 1). When you try to store a value larger than that maximum, the number **wraps around** back to zero — like an odometer rolling over from 999,999 to 000,000.

This is not a crash or an error. It is defined behavior for unsigned integers in C. The danger is when code **assumes the counter only goes up** and does not account for the wraparound.

Famous examples of this class of bug:

- **Windows 95/98's 49.7-day crash** — The kernel's 32-bit millisecond tick counter overflowed, and internal components did not handle the wraparound, causing the system to hang.
- **The Year 2038 Problem (Y2K38)** — Unix systems that store time as a signed 32-bit integer (seconds since 1970) will overflow on January 19, 2038.
- **GPS Week Number Rollover** — GPS uses a 10-bit week counter that overflows every 1,024 weeks (~19.7 years), causing some receivers to report incorrect dates.
- **Pac-Man's kill screen at level 256** — An 8-bit integer overflow made the game unwinnable past level 255.

The bug we found in macOS belongs to this exact family. The XNU kernel stores its TCP timestamp as a `uint32_t` counting **milliseconds** since boot. 2³² milliseconds = **49 days, 17 hours, 2 minutes, and 47.296 seconds**. After that, the counter wraps back to zero. What happens next is the subject of this post.

## The Discovery: A Ticking Clock We Didn't Know About

At Photon, we run a fleet of Mac machines to monitor our iMessage service health. Multiple iMessage services run on these machines, and a central controller continuously sends ping/pong messages to measure round-trip latency. These machines run **24/7** and are only rebooted when absolutely necessary.

On March 30, 2026 — exactly 49.7 days after the last round of reboots — several machines in the fleet silently stopped establishing new TCP connections. Pings still worked. Existing connections stayed alive. But anything that needed a *new* TCP socket simply failed. The pattern was unmistakable: the XNU kernel's TCP timestamp counter had wrapped, and a monotonicity guard prevented it from updating past the overflow. The internal TCP clock **froze**. TIME\_WAIT connections stopped expiring. Ephemeral ports began piling up with no way to reclaim them. The only recovery was a reboot — which just restarted the 49.7-day countdown.

After rebooting the affected machines to restore service, we checked the rest of the fleet and noticed that a few more machines were approaching the same threshold — they would hit 49.7 days of uptime on April 1.

We decided to run a live experiment.

The boot times of two machines (Machine A and Machine B):

$ sysctl kern.boottime

Machine A: { sec = 1770762587 } Tue Feb 10 14:29:47 2026

Machine B: { sec = 1770762608 } Tue Feb 10 14:30:08 2026

Both had been running for **49 days and 16 hours**. The precise overflow timestamps:

Machine A overflow: 2026-04-01 08:32:34 PDT (~36 minutes remaining)

Machine B overflow: 2026-04-01 08:32:55 PDT (~36 minutes remaining)

We had just over half an hour to set up the experiment. Enough.

## Experiment Design: Manufacturing TCP Connections Across the Overflow Window

The hypothesis was simple: if the 49.7-day overflow truly breaks TIME\_WAIT garbage collection, then creating a burst of short-lived TCP connections before and after the overflow should produce a clear behavioral difference:

- **Before overflow:** TIME\_WAIT connections expire normally after ~30 seconds
- **After overflow:** TIME\_WAIT connections stick around forever

We wrote a test script with three phases:

1. **Monitoring phase** (overflow − 35 min to overflow − 5 min): Record TIME\_WAIT count every 10 seconds. No active connection creation.
2. **Blast phase** (overflow − 5 min to overflow + 5 min): Every 2 seconds, initiate ~15 short TCP connections to public endpoints (`8.8.8.8:443`, `1.1.1.1:443`, etc.) — TLS handshake then immediate close.
3. **Observation phase**: Stop creating connections. Continue monitoring TIME\_WAIT counts.

The script was deployed to both machines at 07:58 and started simultaneously.

## Results

### Before Overflow: Normal TIME\_WAIT Recycling

During the monitoring phase, both machines showed completely healthy TIME\_WAIT behavior:

\[07:58:09\] PHASE=wait | remain=2065s | TIME\_WAIT=0 | ESTABLISHED=35

\[07:58:39\] PHASE=wait | remain=2035s | TIME\_WAIT=5 | ESTABLISHED=38

\[07:59:09\] PHASE=wait | remain=2005s | TIME\_WAIT=2 | ESTABLISHED=36

\[07:59:19\] PHASE=wait | remain=1995s | TIME\_WAIT=0 | ESTABLISHED=36

...

\[08:27:28\] PHASE=wait | remain=306s | TIME\_WAIT=0 | ESTABLISHED=41

The system's own background connections produced a handful of TIME\_WAIT entries (0–13) that expired within seconds. **This is normal behavior.**

### Blast Phase: Dynamic Equilibrium Before Overflow

At 08:27:38, the script began creating connections. Within 30 seconds, TIME\_WAIT climbed from 0 to ~200 and then plateaued:

\[08:27:38\] PHASE=blast | remain=296s | TIME\_WAIT=5 ← blast starts

\[08:27:44\] PHASE=blast | remain=290s | TIME\_WAIT=48

\[08:27:51\] PHASE=blast | remain=283s | TIME\_WAIT=90

\[08:27:59\] PHASE=blast | remain=275s | TIME\_WAIT=146

\[08:28:08\] PHASE=blast | remain=266s | TIME\_WAIT=197 ← steady state

\[08:28:37\] PHASE=blast | remain=237s | TIME\_WAIT=196

\[08:29:37\] PHASE=blast | remain=177s | TIME\_WAIT=200

\[08:30:37\] PHASE=blast | remain=117s | TIME\_WAIT=198

\[08:31:37\] PHASE=blast | remain=57s | TIME\_WAIT=192

The script created ~15 connections every 2 seconds (~450/min), but each TIME\_WAIT only lived 30 seconds before being reclaimed. After ~30 seconds the system reached dynamic equilibrium: **TIME\_WAIT steady at ~200** (theoretical: 7.5/sec × 30s = 225; slightly lower due to some failed connections). Creation and recycling in perfect balance. **This is the healthy pre-overflow state.**

### The Overflow Moment

\[08:32:30\] PHASE=blast | remain=4s | TIME\_WAIT=368

\[08:32:32\] PHASE=blast | remain=2s | TIME\_WAIT=383

\[08:32:34\] PHASE=blast | remain=0s | TIME\_WAIT=399

\[08:32:36\] PHASE=blast | remain=-2s | TIME\_WAIT=412

\[08:32:39\] PHASE=blast | remain=-5s | TIME\_WAIT=428

The script estimated the overflow countdown using wall-clock time (`date +%s`), but the kernel's `microuptime()` is a monotonic clock. Over 49.7 days, the two diverge by tens of seconds. From the full log, TIME\_WAIT actually began its monotonic climb around remain≈28s (~08:32:06) — that is when recycling truly stopped. Connections kept being created at the same rate, but **not a single one was reclaimed**.

### After Overflow: TIME\_WAIT Only Goes Up

Machine A's script stopped ~50 seconds after overflow. Machine B continued for 5 more minutes. Both machines' monitoring ran until manually terminated.

**Machine B's critical data** (script stopped creating connections at 08:37:55):

| Time | Since script stop | TIME\_WAIT | Note |
| --- | --- | --- | --- |
| 08:37:55 | 0s | 2,828 | Script ends |
| 08:39:19 | +84s | 2,837 | Should be zero — actually increased |
| 08:40:46 | +171s | 2,852 | Nearly 3 min later, still growing |

**This is the decisive evidence.** macOS TIME\_WAIT timeout is 2 × MSL = 30 seconds. 84 seconds after the script stopped, all 2,828 TIME\_WAIT connections should have **expired to zero**. Instead, not a single one was reclaimed — the count actually increased slightly as the system's own normal connections also began piling up.

**Machine A** (script already stopped, checked manually at 08:50):

| Checkpoint | TIME\_WAIT |
| --- | --- |
| Pre-overflow (08:27) | 0 |
| At overflow (08:32:34) | 399 |
| Overflow + 50s (08:33:23) | 723 |
| Overflow + 18 min (08:50:24) | 871 |

Monotonically increasing. No recovery.

### Side-by-Side: Before vs. After

Before overflow (normal):

TIME\_WAIT appears → expires after ~30s → returns to 0

Observed: 0, 5, 7, 2, 0, 0, 3, 3, 0, 0... (low-level oscillation)

After overflow (broken):

TIME\_WAIT appears → never expires → accumulates forever

Observed: 399, 412, 428, 443, 458, 473, 487, 502... (monotonic increase)

## Root Cause: 32-Bit Overflow of tcp\_now in the XNU Kernel

Now for the part that explains exactly why this happens, line by line in Apple's kernel source code.

### Bug Classification

This is a **32-bit unsigned integer timer wraparound** bug in the TCP subsystem, specifically a **TCP timestamp counter overflow**. The counter in question, `tcp_now`, is the kernel's internal TCP clock. When it stops ticking, every timer in the TCP stack that depends on it stops working.

### tcp\_now: A Counter Destined to Overflow

In the XNU kernel (Apple's open-source project [apple-oss-distributions/xnu](https://github.com/apple-oss-distributions/xnu)), `tcp_now` is defined in `bsd/netinet/tcp_var.h`:

extern uint32\_t tcp\_now;

#define TCP\_RETRANSHZ 1000

A **32-bit unsigned integer**, incremented at **millisecond granularity**, tracking time since boot. Every time the TCP subsystem needs a current timestamp, it calls `calculate_tcp_clock()` (based on XNU kernel source analysis):

void calculate\_tcp\_clock(void)

{

uint32\_t current\_tcp\_now;

struct timeval now;

microuptime(&now);

current\_tcp\_now = (uint32\_t)now.tv\_sec \* 1000 + now.tv\_usec / TCP\_RETRANSHZ\_TO\_USEC;

uint32\_t tmp = os\_atomic\_load(&tcp\_now, relaxed);

if (tmp < current\_tcp\_now) {

os\_atomic\_cmpxchg(&tcp\_now, tmp, current\_tcp\_now, relaxed);

}

}

**The critical line:** `(uint32_t)now.tv_sec * 1000`. After the system has been running for 4,294,967 seconds (~49.7 days), this multiplication exceeds `uint32_t` 's maximum value of 4,294,967,295. The cast to `uint32_t` causes unsigned integer wraparound — the value jumps from near the maximum back down to near zero.

### Why tcp\_now Freezes After Overflow

The bug lives in this guard:

if (tmp < current\_tcp\_now) {

os\_atomic\_cmpxchg(&tcp\_now, tmp, current\_tcp\_now, relaxed);

}

The intent is straightforward: " `tcp_now` must only move forward." Under normal operation, this works perfectly. But at the moment of overflow:

Before overflow: tmp = 4,294,960,000 (near uint32 max)

After overflow: current\_tcp\_now = 5,000 (wrapped back near zero)

Comparison: 4,294,960,000 < 5,000? → false!

`tmp` (the old value, near the maximum) is **greater than** `current_tcp_now` (the new value, wrapped near zero). The `cmpxchg` **never executes**. `tcp_now` is locked at its pre-overflow value and **never updates again**.

The kernel's TCP clock has stopped.

### How TIME\_WAIT Expiry Checks Fail

When a TCP connection enters TIME\_WAIT, the kernel records an absolute expiration time. In `bsd/netinet/tcp_timer.c`, the function `add_to_time_wait_locked()` does this:

static void add\_to\_time\_wait\_locked(struct tcpcb \*tp, uint32\_t delay)

{

uint32\_t timer = tcp\_now + delay;

tp->t\_timer\[TCPT\_2MSL\] = timer;

TAILQ\_INSERT\_TAIL(&tcp\_tw\_tailq, tp, t\_twentry);

}

Here, `delay = 2 * TCPTV_MSL = 2 * 15000 = 30000` milliseconds.

The kernel's garbage collector `tcp_gc()` periodically scans the TIME\_WAIT queue:

TAILQ\_FOREACH\_SAFE(tw\_tp, &tcp\_tw\_tailq, t\_twentry, tw\_ntp) {

if (TSTMP\_GEQ(tcp\_now, tw\_tp->t\_timer\[TCPT\_2MSL\])) {

tcp\_close(tw\_tp);

}

}

`TSTMP_GEQ` is defined in `bsd/netinet/tcp_seq.h`:

#define TSTMP\_GEQ(a, b) ((int)((a)-(b)) >= 0)

This is a standard **signed modular arithmetic comparison**, designed to handle sequence number wraparound. Normally (with `tcp_now` advancing), when `tcp_now >= timer` it returns true and the connection gets cleaned up.

But with `tcp_now` frozen:

tcp\_now = 4,294,960,000 (frozen at pre-overflow value)

timer = 4,294,960,000 + 30,000 = 4,294,990,000

(exceeds uint32 max → wraps to a small number)

TSTMP\_GEQ(4294960000, 4294990000)

\= (int)(4294960000 - 4294990000)

\= (int)(-30000)

\= -30000 >= 0? → false!

Always false. The connection **never gets reclaimed**.

### The Complete Causal Chain

System uptime reaches 49 days 17 hours 2 minutes 47 seconds

↓

microuptime() returns a millisecond value exceeding 2³²

↓

(uint32\_t) cast causes the value to wrap around to near 0

↓

calculate\_tcp\_clock(): if (tmp < current\_tcp\_now) evaluates to false

↓

tcp\_now stops updating — frozen at its last pre-overflow value

↓

TSTMP\_GEQ(tcp\_now, timer) is permanently false for new TIME\_WAIT entries

↓

TIME\_WAIT connections never expire — they accumulate indefinitely

↓

Ephemeral ports gradually exhaust

↓

New TCP connections fail at SYN\_SENT (no ports available)

↓

Application-layer timeouts, services become unreachable

## The Cascade: From Frozen Clock to Total TCP Failure

What makes this bug especially dangerous is that it does not fail loudly. There is no kernel panic, no error log, no crash report. The system looks perfectly healthy — until TCP stops working.

Here is the progression:

1. **Minutes after overflow:** TIME\_WAIT connections stop expiring. If your workload creates few short-lived connections, you might not notice for hours.
2. **Hours after overflow:** TIME\_WAIT accumulates into the thousands. Ephemeral ports (typically 16,384 ports in the range 49152–65535 on macOS) start running out.
3. **Port exhaustion:** New outbound connections cannot bind to a local port. They enter SYN\_SENT and fail. Existing long-lived connections (ESTABLISHED) continue working because they already have ports.
4. **System load spikes:** The kernel spends increasing CPU time scanning a massive TIME\_WAIT queue that never shrinks. Applications retry failed connections, creating more load.
5. **TCP is effectively dead.** Only ICMP (ping) still works because it does not use TCP ports or the TCP timer subsystem.

The only recovery is a **reboot** — which resets `tcp_now` to zero and restarts the 49.7-day countdown.

## Corroborating Evidence

RFC 7323 (TCP Extensions for High Performance), Section 5.4 (Timestamp Clock), notes that a 32-bit timestamp at 1ms granularity has its sign bit wrap after approximately **24.8 days** (2³¹ ms). Section 5.5 (Outdated Timestamps) requires PAWS implementations to invalidate cached timestamps after connections idle for more than 24 days.

Our observed overflow period is **49.7 days** — the **full unsigned wraparound** (2³² ms), exactly twice the RFC's sign-bit wraparound period. The RFC discusses wraparound of the remote TCP timestamp option in transit, not the local kernel's own timer variable — the latter is an XNU implementation defect.

### Consistent Symptom Reports in the Community

Multiple reports on Apple's community forums and open-source projects describe symptoms that match this bug precisely:

- **Apple Community #250867747:** macOS Catalina — "New TCP connections can not establish." New connections enter SYN\_SENT then immediately close. Existing connections unaffected. Only a reboot fixes it.
- **Apple Community #252991075:** "Mac Pro TCP/IP stops working." TCP completely fails, but ping (ICMP) works normally.
- **Podman issue #12495:** "podman machine network connectivity stalls after some uptime" on macOS 12. The VM running on macOS shows outbound TCP failure with ICMP still functional, occurring after running for multiple weeks.

The common pattern: **TCP fails but ICMP works, only a reboot fixes it, happens after weeks of uptime.** This matches the predicted symptoms of `tcp_now` overflow exactly. ICMP does not use the TCP timer subsystem and is unaffected.

## Impact Assessment: Who Is Affected?

Any macOS system that meets both conditions:

1. **Continuous uptime exceeding 49 days 17 hours** without a reboot
2. **Any TCP network activity** (essentially every networked Mac)

Most consumer Macs reboot within 49 days due to system updates, so typical users rarely trigger this. But these scenarios are high-risk:

- **Long-running server fleets** (like our iMessage monitoring setup)
- **macOS CI/CD build servers** (Jenkins, GitHub Actions self-hosted runners)
- **Mac Pro workstations** (long-running renders, compiles, or simulations)
- **Colocated Macs** (remotely managed, rarely rebooted)
- **Mac mini clusters** used as build farms or test infrastructure

## Reproduction Guide

Want to verify this bug on your own macOS machine? Four steps.

### Step 1: Calculate Your Overflow Time

boot\_sec='sec = \[0-9\]\*''{print $3}'

now\_sec=

remain=

echo "Time until overflow: h m s"

### Step 2: Monitor TIME\_WAIT Before and After Overflow

while true; do

tw=

echo " TIME\_WAIT=$tw"

sleep 5

done

### Step 3: Generate Connections During the Overflow Window

for i in; do

curl -s -o /dev/null --connect-timeout 2 --max-time 2 "<https://1.1.1.1>" &

done

### Step 4: Observe

Stop generating connections and wait 2 minutes. If the TIME\_WAIT count does not drop, the bug has been reproduced.

## 9.5 Hours Later: Watching the System Die

We did not reboot after the overflow. Instead, we let both machines continue running to observe the natural progression of the bug.

### System State at Overflow + 9.5 Hours (18:02 PDT)

Machine A (uptime: 50 days, 2:33):

TIME\_WAIT: 4,888

SYN\_SENT: 3,044

ESTABLISHED: 37

FIN\_WAIT\_1: 9

LAST\_ACK: 3

Load: 1.62

Machine B (uptime: 50 days, 2:33):

TIME\_WAIT: 8,217

SYN\_SENT: 3,315

ESTABLISHED: 38

FIN\_WAIT\_1: 9

LAST\_ACK: 23

CLOSING: 2

Load: 49.74

### TIME\_WAIT Accumulation Curve

| Time | Since overflow | Machine A TIME\_WAIT | Machine B TIME\_WAIT |
| --- | --- | --- | --- |
| 08:32 | 0 min | 399 | 801 |
| 08:37 | +5 min | ~723 (script stopped) | 2,828 |
| 08:50 | +18 min | 871 | 2,939 |
| 18:02 | +9.5 hours | **4,888** | **8,217** |

Not a single TIME\_WAIT connection was ever reclaimed. Only up, never down.

### SYN\_SENT Pileup: New Connections Failing

9.5 hours post-overflow, both machines had accumulated **3,000+** SYN\_SENT connections — the classic signal of TCP port exhaustion:

- Outbound connections stuck at the first step of the three-way handshake, unable to acquire a port
- Ephemeral ports consumed by undying TIME\_WAIT entries
- Only 37–38 ESTABLISHED connections remaining — existing long-lived connections still worked, but creating new ones was nearly impossible
- Machine B's load average spiked to **49.74** as the kernel burned CPU scanning the ever-growing TIME\_WAIT queue

This matches the predicted progression exactly:

TIME\_WAIT frozen (confirmed)

→ Ports gradually exhaust (TIME\_WAIT: 4,888–8,217)

→ SYN\_SENT pileup (3,000+, new connections failing)

→ System load spikes (49.74)

→ TCP effectively paralyzed, only ICMP works

→ Only recovery: reboot

## Conclusion

One 32-bit integer. One seemingly harmless `if (tmp < current_tcp_now)` guard. 49.7 days of patience. That is all it takes to build a time bomb.

This class of bug is insidious because it evades every layer of defense. It will not be caught in development testing — who runs a test for 50 days? It will not be flagged in code review — the logic looks perfectly reasonable. It can even be misdiagnosed in production as a network issue or hardware failure. Only when you happen to be staring at a machine that has been running for 49 days, and you happen to know that 2³² milliseconds equals 49.7 days, does the puzzle come together.

We reproduced this on multiple servers in our fleet. The evidence is conclusive: before the overflow, TIME\_WAIT expires normally (0–13 entries); after the overflow, TIME\_WAIT never reclaims (accumulating to thousands). `tcp_now` froze. The kernel's TCP clock stopped. Everything else looked fine — until the ports ran out.

If you manage long-running macOS machines, remember this number: **49 days, 17 hours, 2 minutes, 47 seconds**.

We are actively working on a fix that is better than rebooting — a targeted workaround that addresses the frozen `tcp_now` without requiring a full system restart. Until then, schedule your reboots before the clock runs out.