Fixing the Silent Hang in Drizzle Kit Push on ChromeOS & Serverless Postgres (Neon)
If you are developing inside a ChromeOS Linux Container (Crostini) and using modern versions of Node.js with Drizzle ORM and a serverless Postgres provider like Neon, you may run into a frustrating issue: running pnpm drizzle-kit push causes the CLI spinner to hang indefinitely at [⣷] Pulling schema from database... with no timeout, no error code, and no system messages.
This post breaks down the hidden architectural conflict causing this behavior, why standard global network configurations fail, and how to elegantly resolve it at a runtime-socket layer.
The Quick Fix (TL;DR)
Overwrite your drizzle.config.ts to directly intercept Node’s internal domain resolution cache via CommonJS require. This forces all underlying raw socket handshakes to drop dead IPv6 routes before they freeze your container interface.
import { defineConfig } from 'drizzle-kit';
import * as dotenv from 'dotenv';
import * as path from 'path';
// 1. FORCED IPV4 MONKEY-PATCH FOR CHROMEOS VIRTUAL INTERFACES
// Standard 'import * as dns' loops get wrapped as read-only proxies by esbuild.
// We must pull the raw Node cache reference via require() to intercept sockets.
const dns = require('node:dns');
const originalLookup = dns.lookup;
dns.lookup = function (hostname: string, options: any, callback: any) {
if (typeof options === 'function') {
callback = options;
options = {};
}
const opts = typeof options === 'number' ? { family: options } : { ...options };
// Force family to 4 (IPv4) to avoid the Crostini IPv6 infinite SYN-SENT black hole
opts.family = 4;
return originalLookup.call(this, hostname, opts, callback);
};
// 2. ENV HYDRATION
dotenv.config({ path: '.env' });
// Favor unpooled connections (DIRECT_URL) for migrations/introspections
const dbUrl = process.env.DIRECT_URL || process.env.DATABASE_URL;
if (!dbUrl) throw new Error('Missing database connection configuration string.');
const connectionUrl = new URL(dbUrl);
connectionUrl.searchParams.set('sslmode', 'require');
connectionUrl.searchParams.set('channel_binding', 'require');
export default defineConfig({
dialect: 'postgresql',
schema: './src/lib/server/db/schema.ts',
out: './drizzle',
dbCredentials: {
url: connectionUrl.toString(),
},
verbose: true,
strict: true,
});
1. What Caused It? (The Architecture Breakdown)
The bug occurs due to a hidden conflict between three separate architectural layers: Node.js, the ChromeOS Linux Sandbox, and Serverless Postgres Cloud Providers.
graph TD
A[drizzle-kit push] --> B[DNS Request to Neon Postgres]
B --> C{Node.js v17+ Engine}
C -->|Verbatim Address Order| D[IPv6 Address AAAA]
C -->|Verbatim Address Order| E[IPv4 Address A]
D --> F[ChromeOS Virtual Bridge 100.115.92.25]
F -->|Packet Silently Discarded / No Rejection Signal| G[Infinite SYN-SENT State]
G --> H[Silent CLI Terminal Hang]
E -.->|Bypassed Clean Route| I[Successful Handshake]
-
Node.js Defaults: Since version 17, Node.js defaults to resolving hostnames in the exact order returned by DNS servers (
verbatim), which consistently prioritizes IPv6 (AAAA records) over IPv4 (A records). -
The ChromeOS Network Black Hole: ChromeOS handles Linux application execution inside an internal Debian virtual machine called
Crostini. Crostini routes external packets across an abstract, internal network bridge (100.115.92.25). While this bridge handles standard IPv4 cleanly, it has an improperly configured virtual device layer for IPv6. -
The Silent Hang: In standard Linux distributions, if a route is dead, the kernel fires an immediate
EHOSTUNREACHorENETUNREACHsignal back to the execution block. This fast-fail allows the network socket to immediately drop that address and pivot to the next one. But the ChromeOS virtual bridge silently discards the IPv6 packets. The socket is locked in an permanentSYN-SENTstate, waiting endlessly for a handshake acknowledgment that will never arrive.
The Analogy: The Foggy Canyon Road
Imagine giving a courier two routes to deliver an urgent manifest file to a data center: Route 4 and Route 6.
In a standard town, if Route 6 is completely broken, a physical warning barrier is positioned at the entry gate. The courier drives up, reads the sign, turns around instantly, and safely uses Route 4.
However, in the specialized ChromeOS container district, Route 6 has no warning signs. It simply leads directly off an unmarked cliff into a foggy canyon. The courier drives into the fog and drops into a silent void. Back at the main office, you sit waiting by the door forever. The command-line spinner runs indefinitely because the parent Node thread is waiting for a courier that will never return.
2. Why the Standard Bypasses Failed
Problem A: dns.setDefaultResultOrder('ipv4first') is insufficient
You might try setting the global result order preference to favor IPv4. However, this method only alters the sorting order inside the address array. Because drizzle-kit and its deep dependencies create multiple parallel internal socket pools to introspect the database schema catalogs, downstream requests can still scan the remainder of the array, encounter an IPv6 endpoint, and lock the pipeline.
Problem B: The esbuild Compilation Trap
When you write a modern TypeScript configuration block:
import * as dns from 'node:dns';
dns.lookup = ...
drizzle-kit compiles your TypeScript configuration file on the fly using esbuild. Under the hood, esbuild wraps ESM imports inside an isolated module helper module (__toESM proxy objects). This compilation process converts your target export properties into read-only getters. Attempting to overwrite dns.lookup directly on an ESM import causes a silent configuration mutation error that fails to alter the application's underlying behavior.
3. The Elegant Breakthrough (What Bypassed It)
To solve the compilation barrier, we use a classic low-level Node.js design approach: direct cache modification via CommonJS.
graph TD
A[import * as dns from 'node:dns'] --> B[esbuild Compiles Config]
B --> C[__toESM Proxy Object Wrapper]
C --> D{Attempt to override dns.lookup}
D -->|Fails Silently| E[Read-Only Property Getter Trap]
F[require 'node:dns'] --> G[Node.js Engine Module Cache]
G --> H[Mutate Native dns.lookup Method]
H -->|Succeeds Process-Wide| I[Global Interception of all pg Driver Sockets]
By pulling the DNS library via explicit require('node:dns'), we access Node's underlying root module registry directly, bypassing any proxy constraints introduced by compilation tools.
We then apply a wrapper function over the native dns.lookup method. No matter what internal database driver packages are invoked deep within the execution tree (pg, postgres.js, or native connection pools), every socket creation task is intercepted. We intercept the options map, explicitly assign family = 4, and guarantee that the broken IPv6 route is dropped completely before the network packet reaches the virtual bridge.
Top comments (0)