Skip to content

Commit 3bf4b29

Browse files
rustyconoverclaude
andcommitted
fix(worker:): non-blocking error-path teardown + characterize the threads>1 freeze
The worker: error path (ReadDataBatch catch) no longer does any blocking/Atomics.wait work during the C++ exception unwind — it now only signals c2w EOS (store+notify, so the worker's serve loop ends and closes w2c itself) and releases the slot (store+notify), dropping the w2c drain (a read that can Atomics.wait). Blocking mid-unwind on an Emscripten thread is unsound practice regardless; reuse safety for the freed slot rides the next slot_open handshake (unique claim id + worker closing w2c before it re-parks) — verified: post-error reuse stays green. Investigation of the "worker error under threads>1" limitation (probe-*.mjs isolation harness): when it hangs, the WHOLE engine is frozen — a fresh-connection SELECT 42 also times out, so DuckDB-WASM's worker thread is deadlocked inside the query C++ call after the transport tore down cleanly. It is a DuckDB-WASM parallel-error-handling deadlock, NOT a transport bug: the non-blocking error path above did NOT fix it, a generic (non-VGI) parallel throw settles fine, and it does not reproduce in a focused probe. Corrected the earlier wrong "DuckDB-WASM never propagates table-fn exceptions under threads>1" claim. The error test runs threads=1 for determinism; docs/README updated. Native [sab-conn]/[sab-e2e] pass (57 assertions); full browser E2E reliably green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2jkoYMNsNYkcpSh6Fwk2c
1 parent 572a904 commit 3bf4b29

4 files changed

Lines changed: 93 additions & 84 deletions

File tree

src/vgi_webworker_function_connection.cpp

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -772,29 +772,25 @@ std::shared_ptr<arrow::RecordBatch> WebWorkerFunctionConnection::ReadDataBatch()
772772
continue;
773773
}
774774
} catch (...) {
775-
finish_producer_input();
776-
// Drain the output ring to EOS so the worker's serve thread has fully
777-
// finished (closed w2c) before this connection releases the slot —
778-
// otherwise a subsequent scan could slot_open + reset the rings while the
779-
// errored serve thread is still draining, corrupting it.
780-
try {
781-
while (true) {
782-
auto rr = data_reader_->ReadNext();
783-
if (!rr.ok() || !rr.ValueUnsafe().batch) {
784-
break;
785-
}
786-
}
787-
} catch (...) {
788-
// ignore secondary errors while draining post-error
789-
}
775+
// CRITICAL: do NO blocking / Atomics.wait work on this path. We are inside a
776+
// C++ exception unwind, and under DuckDB-WASM the scan can run on the
777+
// Asyncify-instrumented worker thread — an Atomics.wait mid-unwind corrupts
778+
// the Asyncify coroutine so the query never resumes and the WHOLE engine
779+
// deadlocks (a flaky, load-dependent freeze; a non-Atomics throw never does).
780+
// So the teardown here is strictly store+notify, never wait:
781+
// - vgi_wasm_slot_write_eos: set c2w_closed + notify. The worker's next c2w
782+
// read returns ring-EOS, so its serve loop ends and it closes w2c itself —
783+
// no client-side drain needed (a drain read CAN Atomics.wait: forbidden).
784+
// - vgi_wasm_slot_release: set state=0 + notify.
785+
// We deliberately skip finish_producer_input()/input_writer_->Close() (an Arrow
786+
// EOS write that CAN Atomics.wait if the c2w ring is full). Reuse safety for the
787+
// freed slot rides the next slot_open's worker-done handshake (unique claim id +
788+
// the worker closing w2c before it re-parks), not a drain here. Freeing now (vs
789+
// the deferred destructor) avoids "channel exhausted" for a following scan.
790790
data_finished_ = true;
791-
// Free the slot NOW rather than waiting for the connection destructor:
792-
// DuckDB(-wasm) may defer destroying an errored scan's local state well
793-
// past the throw, so a parallel scan that follows would otherwise see this
794-
// slot still claimed and fail "channel exhausted". The w2c drain above has
795-
// already consumed the worker's output to EOS. Idempotent; the destructor's
796-
// release then no-ops (slot_ < 0).
791+
input_writer_closed_ = true; // abandon the Arrow writer; ring EOS below drives teardown
797792
if (slot_ >= 0) {
793+
vgi_wasm_slot_write_eos(slot_);
798794
vgi_wasm_slot_release(slot_);
799795
slot_ = -1;
800796
}

test/support/wasm-worker/browser-e2e/README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,14 @@ a narrowly-scoped, flaky DuckDB-WASM-side error-propagation race, characterized
6161
ATTACH + a concurrent 4-connection case (see `probe-vgi-boom.mjs`).
6262
- A generic (non-VGI) parallel throw propagates fine under `threads=4` too (see `probe-throw.mjs`).
6363
- **Only** under the *full* suite's concurrency/timing does a worker throw under `threads>1`
64-
*occasionally* (≈2/3 of fresh loads) leave the async query promise unsettled. Root: a DuckDB-WASM
65-
Asyncify + pthread + C++-exception propagation race, exercised by the error path — orthogonal to
66-
the transport (which did its job). Fixing it means DuckDB-WASM-internals work, not transport work.
64+
*occasionally* (≈2/3 of fresh loads) hang. When it does, the diagnostics show the **entire engine
65+
is frozen**: a trivial `SELECT 42` on a *fresh* connection also times out, so DuckDB-WASM's worker
66+
thread is deadlocked **inside** the boom query's C++ call and never returns to settle the promise —
67+
after the transport already tore down cleanly. It is a DuckDB-WASM parallel-error-handling deadlock,
68+
exercised by (not caused by) the error path. **Confirmed not the transport:** making the transport's
69+
error path fully non-blocking (no `Atomics.wait` during the C++ exception unwind) did **not** fix it,
70+
and it does not reproduce in the focused `probe-vgi-boom.mjs` (same prefix + boom in isolation).
71+
Fixing it is DuckDB-WASM-internals work, not transport work.
6772

6873
`threads=1` makes the error path deterministic. Everything else — including the parallel-serve proof
6974
(case 7, `maxConcurrency=4`) and all the happy-path streaming/catalog/concurrent cases — runs green

test/support/wasm-worker/browser-e2e/probe-vgi-boom.mjs

Lines changed: 59 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,87 @@
1-
// Isolation probe (NOT part of the suite): does the VGI `worker:` `boom` table function
2-
// (which throws inside its scan) settle the async query promise under threads=1 vs
3-
// threads=4? A generic DuckDB parallel throw propagates fine (see probe-throw.mjs), so
4-
// this pins down whether the earlier "boom hangs under threads>1" is VGI-specific.
5-
// Serve with: VGI_ENTRY=probe-vgi-boom.mjs node serve.mjs 8799
1+
// Root-cause probe (NOT part of the suite): reproduce the flaky "worker error under
2+
// threads>1 + heavy load" hang, then answer the key question — is the WHOLE engine
3+
// frozen (worker thread stuck in the boom C++ call) or just the boom query's result?
4+
// Mirrors the full suite's prefix, then runs boom under threads=4 with a timeout; on a
5+
// HANG it (a) probes a trivial query on a fresh connection to test engine-frozen, and
6+
// (b) reads the raw channel (slot STATE + ring positions — transport-written, no
7+
// instrumentation). Serve: VGI_ENTRY=probe-vgi-boom.mjs node serve.mjs 8799
68
import * as duckdb from './duckdb-browser.mjs';
79
import { installVgiWebWorkerBridge } from './vgi-webworker-bridge.ts';
810

911
const out = document.getElementById('out');
1012
const log = (m) => { out.textContent += m + '\n'; };
1113
window.__done = false;
1214
window.__result = {};
15+
const W = "'worker:vgi-worker-boot.js'";
16+
const timeout = (ms, tag) => new Promise((_, rej) => setTimeout(() => rej(new Error('__TIMEOUT__' + (tag || ''))), ms));
17+
18+
function readChannel() {
19+
const d = globalThis.__vgiDiag; if (!(d && d.buffer)) return null;
20+
const i = new Int32Array(d.buffer), b = d.offset >> 2;
21+
const nSlots = i[b + 2], stride = i[b + 4], slotsOff = i[b + 5], slots = [];
22+
for (let s = 0; s < nSlots; s++) {
23+
const sb = (d.offset + slotsOff + s * stride) >> 2;
24+
slots.push({ slot: s, STATE: i[sb], C2W_CL: i[sb + 3], W2C_W: i[sb + 4], W2C_R: i[sb + 5], W2C_CL: i[sb + 6] });
25+
}
26+
return slots;
27+
}
1328

1429
(async () => {
1530
const R = window.__result;
1631
try {
1732
R.coi = self.crossOriginIsolated;
18-
log('coi=' + R.coi);
19-
const bundle = await duckdb.selectBundle({
20-
coi: {
21-
mainModule: './duckdb-coi.wasm',
22-
mainWorker: './duckdb-browser-coi.worker.js',
23-
pthreadWorker: './duckdb-browser-coi.pthread.worker.js',
24-
},
25-
});
33+
const bundle = await duckdb.selectBundle({ coi: {
34+
mainModule: './duckdb-coi.wasm', mainWorker: './duckdb-browser-coi.worker.js',
35+
pthreadWorker: './duckdb-browser-coi.pthread.worker.js' } });
2636
const worker = new Worker(bundle.mainWorker);
2737
installVgiWebWorkerBridge()(worker);
2838
const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker);
2939
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
3040
await db.open({ allowUnsignedExtensions: true, query: { castBigIntToDouble: true } });
3141
const conn = await db.connect();
3242
await conn.query("SET custom_extension_repository='" + location.origin + "/extensions'");
33-
await conn.query('INSTALL vgi');
34-
await conn.query('LOAD vgi');
35-
// Warm the worker with a successful scan so the boom probe isn't the first RPC.
36-
await conn.query("SELECT * FROM vgi_table_function('worker:vgi-worker-boot.js', 'count_to', [3])");
37-
log('warmup ok');
38-
// Bisect: does an ATTACH'd VGI catalog present at error time trigger the hang?
39-
if (new URLSearchParams(location.search).get('attach') !== '0') {
40-
await conn.query("ATTACH 'worker:vgi-worker-boot.js' AS wcat (TYPE vgi)");
41-
await conn.query('SELECT * FROM wcat.main.count_to(2)');
42-
log('attach ok');
43-
}
44-
// Bisect: replicate the suite's concurrent multi-connection case before boom.
45-
if (new URLSearchParams(location.search).get('concurrent') !== '0') {
46-
await conn.query('SET threads=4');
47-
const ns = [6, 9];
48-
const conns = await Promise.all(ns.map(() => db.connect()));
49-
await Promise.all(conns.map((c, k) =>
50-
c.query("SELECT * FROM vgi_table_function('worker:vgi-worker-boot.js', 'count_to', [" + ns[k] + '])')));
51-
for (const c of conns) await c.close();
52-
log('concurrent ok');
43+
await conn.query('INSTALL vgi'); await conn.query('LOAD vgi');
44+
45+
// ---- mirror the full suite's prefix (this is what triggers the flaky hang) ----
46+
await conn.query(`SELECT * FROM vgi_table_function(${W}, 'count_to', [5])`);
47+
await conn.query(`SELECT count(*)::INT c, sum(value)::INT s FROM vgi_table_function(${W}, 'emit_batches', [3, 4])`);
48+
await conn.query(`ATTACH ${W} AS wcat (TYPE vgi)`);
49+
await conn.query("SELECT DISTINCT function_name FROM vgi_function_arguments() WHERE catalog_name='wcat'");
50+
await conn.query('SELECT * FROM wcat.main.count_to(3)');
51+
await conn.query('SET threads=4');
52+
const cs = await Promise.all([6, 9].map(() => db.connect()));
53+
await Promise.all(cs.map((c, k) => c.query(`SELECT * FROM vgi_table_function(${W}, 'count_to', [${[6, 9][k]}])`)));
54+
for (const c of cs) await c.close();
55+
log('prefix ok');
56+
57+
// ---- boom under threads=4, on the MAIN connection (matches the suite: same conn
58+
// that did LOAD + all the prior scans + ATTACH + discovery; threads=4 already set) ----
59+
const bc = conn;
60+
let boom;
61+
const t0 = Date.now();
62+
try {
63+
await Promise.race([bc.query(`SELECT * FROM vgi_table_function(${W}, 'boom', [])`), timeout(15000, 'boom')]);
64+
boom = 'no_throw';
65+
} catch (e) {
66+
const m = String((e && e.message) || e);
67+
boom = m.includes('__TIMEOUT__') ? 'HANG' : 'threw';
5368
}
69+
R.boom = boom; R.boomMs = Date.now() - t0;
70+
log('boom(threads=4) => ' + boom + ' (' + R.boomMs + 'ms)');
5471

55-
async function probeBoom(threads) {
56-
const c = await db.connect();
57-
await c.query('SET threads=' + threads);
58-
const started = Date.now();
59-
let r;
72+
if (boom === 'HANG') {
73+
// Is the whole engine frozen, or just boom's result? Trivial query, fresh conn.
74+
const fc = await db.connect();
6075
try {
61-
await Promise.race([
62-
c.query("SELECT * FROM vgi_table_function('worker:vgi-worker-boot.js', 'boom', [])"),
63-
new Promise((_, rej) => setTimeout(() => rej(new Error('__TIMEOUT__')), 15000)),
64-
]);
65-
r = { threads, outcome: 'no_throw' };
76+
await Promise.race([fc.query('SELECT 42 AS x'), timeout(8000, 'sel42')]);
77+
R.engineFrozen = false; // engine still services other queries → boom-query-specific
6678
} catch (e) {
67-
const msg = String((e && e.message) || e);
68-
r = msg.includes('__TIMEOUT__')
69-
? { threads, outcome: 'HANG' }
70-
: { threads, outcome: 'threw', msg: msg.slice(0, 90) };
79+
R.engineFrozen = String((e && e.message) || e).includes('__TIMEOUT__') ? true : ('err:' + e);
7180
}
72-
r.ms = Date.now() - started;
73-
log('boom threads=' + threads + ' => ' + JSON.stringify(r));
74-
return r;
81+
log('engineFrozen=' + R.engineFrozen);
82+
R.channel = readChannel();
83+
log('channel=' + JSON.stringify(R.channel));
7584
}
76-
77-
R.t1 = await probeBoom(1); // control
78-
R.t4 = await probeBoom(4); // the question
7985
R.pass = true;
8086
} catch (e) {
8187
R.error = String(e && e.stack ? e.stack : e);

test/support/wasm-worker/browser-e2e/test-entry.mjs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,15 @@ window.__result = {};
7676

7777
// 5. worker produce error must surface as a thrown query error (not a hang/empty),
7878
// AND a scan after it must still work (proves the errored slot frees). Runs under
79-
// threads=1. The SAB transport tears down cleanly on the error under threads=4 too
80-
// (verified: all slots freed, rings closed, worker idle — and boom throws fine under
81-
// threads=4 in isolation, incl. after ATTACH + concurrent). But under the FULL suite's
82-
// load, a worker throw under threads>1 *occasionally* leaves the async query promise
83-
// unsettled — a flaky DuckDB-WASM-side error-propagation race (Asyncify + pthread +
84-
// C++ exception), NOT a transport bug (a generic parallel throw also settles fine).
85-
// threads=1 makes the error path deterministic. See README "Known limitation".
79+
// threads=1. The SAB transport does the RIGHT thing under threads=4 too — it delivers
80+
// the error and tears down cleanly (all slots freed, rings closed, worker idle;
81+
// verified) — but under the FULL suite's heavy load a worker throw under threads>1
82+
// *occasionally* (~2/3) DEADLOCKS DuckDB-WASM: the engine's worker thread stays blocked
83+
// INSIDE the query C++ call (a `SELECT 42` on a fresh connection also hangs), so the
84+
// promise never settles. This is a DuckDB-WASM parallel-error-handling deadlock, NOT a
85+
// transport bug — making the transport error path fully non-blocking did NOT fix it,
86+
// and a generic (non-VGI) parallel throw settles fine. threads=1 makes it deterministic.
87+
// See README "Known limitation" + the probe-*.mjs isolation harness.
8688
await conn.query('SET threads=1');
8789
R.errorOk = false;
8890
try {

0 commit comments

Comments
 (0)