From 8cb415c1a571512efaa85b2fba4e04a281a59782 Mon Sep 17 00:00:00 2001 From: Shawn Chen Date: Fri, 23 Oct 2026 11:17:42 +0800 Subject: [PATCH 1/2] Shut down embedded engine before Node process exit --- lib/chdb_node.cpp | 17 ++++++++++++++--- test/v3/engine-exit.test.ts | 24 ++++++++++++++++++++++++ test/v3/layer3/codegen.test.ts | 3 ++- 3 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 test/v3/engine-exit.test.ts diff --git a/lib/chdb_node.cpp b/lib/chdb_node.cpp index d53e78f..858de56 100644 --- a/lib/chdb_node.cpp +++ b/lib/chdb_node.cpp @@ -214,10 +214,19 @@ static void hard_close_all() { g_reg.boundKey.clear(); } +// Node's environment cleanup closes connections, but the engine may still own +// background threads when C++ static destructors run. On macOS with core +// v26.9.0, a CLI that queried once could hang indefinitely inside exit(). +// Run this process-wide backstop before the engine's own exit handlers. +static void shutdown_on_process_exit() { + hard_close_all(); + (void)chdb_shutdown(); +} + static void ensure_atexit() { static bool done = false; if (!done) { - std::atexit(hard_close_all); + std::atexit(shutdown_on_process_exit); done = true; } } @@ -225,7 +234,6 @@ static void ensure_atexit() { // Default connection for standalone query()/queryBind() (lazy, in-memory). A // single shared default per process; reused across standalone calls. static chdb_connection *get_default_conn(char **error_message) { - ensure_atexit(); std::lock_guard lk(g_reg_mu); if (g_reg.defaultConn) return g_reg.defaultConn; // No default up. If a session holds a real data directory, "" cannot bind a @@ -245,6 +253,9 @@ static chdb_connection *get_default_conn(char **error_message) { g_reg.defaultConn = c; g_reg.boundKey = ""; g_reg.conns.insert(c); + // Register only after chdb_connect initialized the engine: atexit handlers + // run in reverse registration order, so shutdown must precede its teardown. + ensure_atexit(); return c; } @@ -253,7 +264,6 @@ static chdb_connection *get_default_conn(char **error_message) { static chdb_connection *acquire_session_conn(const std::string &path, const std::vector &extraArgs, char **error_message) { - ensure_atexit(); std::lock_guard lk(g_reg_mu); if (!g_reg.conns.empty()) { if (g_reg.boundKey.empty()) { @@ -300,6 +310,7 @@ static chdb_connection *acquire_session_conn(const std::string &path, } g_reg.boundKey = path; g_reg.conns.insert(c); + ensure_atexit(); return c; } diff --git a/test/v3/engine-exit.test.ts b/test/v3/engine-exit.test.ts new file mode 100644 index 0000000..5eca9a4 --- /dev/null +++ b/test/v3/engine-exit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +describe('engine process teardown (chdb-node#97)', () => { + it('exits after an async query on the published engine', () => { + const entry = join(process.cwd(), 'index.js') + const script = ` + const { queryBindAsync } = require(${JSON.stringify(entry)}); + queryBindAsync('SELECT 42', {}, { format: 'CSV' }).then( + result => { process.stdout.write(result.text()); process.exit(0); }, + error => { console.error(error); process.exit(1); } + ); + ` + const child = spawnSync(process.execPath, ['-e', script], { + encoding: 'utf8', + timeout: 15_000, + }) + expect(child.error).toBeUndefined() + expect(child.status).toBe(0) + expect(child.stdout.trim()).toBe('42') + expect(child.stderr).toBe('') + }, 20_000) +}) diff --git a/test/v3/layer3/codegen.test.ts b/test/v3/layer3/codegen.test.ts index acf40c5..3f5c119 100644 --- a/test/v3/layer3/codegen.test.ts +++ b/test/v3/layer3/codegen.test.ts @@ -186,8 +186,9 @@ describe('CLI — end-to-end', () => { const r = spawnSync( process.execPath, ['dist/layer3/codegen/cli.js', '--from-file', csv, '--format', 'CSVWithNames', '--label', 'events'], - { cwd: process.cwd(), encoding: 'utf-8' }, + { cwd: process.cwd(), encoding: 'utf-8', timeout: 15_000 }, ) + expect(r.error).toBeUndefined() expect(r.status).toBe(0) expect(r.stdout).toMatch(/export interface Database \{/) expect(r.stdout).toMatch(/events: \{[\s\S]*'Nullable\(Int64\)'/) From fa0afaf26140a1624b85276f8a4db0e2073bdac5 Mon Sep 17 00:00:00 2001 From: Shawn Chen Date: Fri, 23 Oct 2026 12:43:16 +0800 Subject: [PATCH 2/2] Pin available S3 test image for durable CI --- .github/workflows/chdb-node-test.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/chdb-node-test.yml b/.github/workflows/chdb-node-test.yml index 1768139..0364900 100644 --- a/.github/workflows/chdb-node-test.yml +++ b/.github/workflows/chdb-node-test.yml @@ -57,8 +57,8 @@ jobs: # Provider conformance for the S3 backend. Separate from the job above so a # flaky container cannot redden the engine-free suite, and because this is the # only automated check that conditional create and compare-and-swap work - # against a real object store rather than a filesystem. MinIO needs no - # credentials, so it runs on every PR; AWS S3 and R2 stay manual. + # against a real object store rather than a filesystem. The local server + # needs no external credentials, so it runs on every PR; AWS S3 and R2 stay manual. durable-s3: runs-on: ubuntu-latest steps: @@ -68,13 +68,15 @@ jobs: node-version: 22.x cache: npm - run: npm install --ignore-scripts - - name: Start MinIO + - name: Start MinIO-compatible Silo # Not a `services:` container: GitHub offers no way to pass a command to # one, and this image needs `server /data` to start at all. + # Official MinIO images are no longer anonymously pullable from Quay or + # Docker Hub. Pin a tested MinIO-compatible Silo release and its digest. run: | docker run -d --name minio -p 9000:9000 \ -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \ - quay.io/minio/minio:latest server /data + docker.io/pgsty/silo:RELEASE.2026-09-16T00-00-00Z@sha256:635197cb9f36d01bee221d34d1c7d7960f6a95c48b0b6c01d99cd13bdae51a46 server /data for i in $(seq 1 30); do if curl -sf http://127.0.0.1:9000/minio/health/live >/dev/null; then echo "MinIO ready after ${i}s"; exit 0