diff --git a/.changeset/tough-rabbits-boot.md b/.changeset/tough-rabbits-boot.md new file mode 100644 index 000000000..159b63616 --- /dev/null +++ b/.changeset/tough-rabbits-boot.md @@ -0,0 +1,5 @@ +--- +'@electric-sql/pglite': patch +--- + +Allow initdb to complete in Node-shaped sandbox runtimes that reject writes to `process.exitCode`, preserve the host exit code, and release the Postgres module after close. diff --git a/packages/pglite/src/initdb.ts b/packages/pglite/src/initdb.ts index cf99fb805..098ce8030 100644 --- a/packages/pglite/src/initdb.ts +++ b/packages/pglite/src/initdb.ts @@ -44,6 +44,43 @@ function log(debug?: number, ...args: any[]) { } } +function callWithWritableProcessExitCode(callback: () => T): T { + const processObject = globalThis.process + if (!processObject) { + return callback() + } + + let exitCode: typeof processObject.exitCode + try { + exitCode = processObject.exitCode + processObject.exitCode = exitCode + } catch { + // Emscripten's Node quit handler writes process.exitCode during a normal + // initdb exit. Some Node-shaped sandbox runtimes expose a setter that + // rejects that host operation, so give only the synchronous callMain a + // delegating process object with a writable exitCode. + const processShim = Object.create(processObject) + Object.defineProperty(processShim, 'exitCode', { + configurable: true, + enumerable: true, + value: exitCode, + writable: true, + }) + globalThis.process = processShim + try { + return callback() + } finally { + globalThis.process = processObject + } + } + + try { + return callback() + } finally { + processObject.exitCode = exitCode + } +} + async function execInitdb({ pg, debug, @@ -199,7 +236,7 @@ async function execInitdb({ const initDbMod = await InitdbModFactory(emscriptenOpts) log(debug, 'calling initdb.main with', args) - const result = initDbMod.callMain(args) + const result = callWithWritableProcessExitCode(() => initDbMod.callMain(args)) return { exitCode: result, diff --git a/packages/pglite/src/pglite.ts b/packages/pglite/src/pglite.ts index 517a4376a..4b99c63f5 100644 --- a/packages/pglite/src/pglite.ts +++ b/packages/pglite/src/pglite.ts @@ -826,14 +826,14 @@ export class PGlite // we need to do this explicitly // this sets process.exitCode to 0 this.mod!._emscripten_force_exit(0) - // clear mod to release memory - this.mod = undefined } catch (e: any) { this.#log(e) if (e.status !== 0) { this.#log('Error when exiting', e.toString()) } } finally { + // clear mod to release memory, including when force_exit throws ExitStatus + this.mod = undefined try { pglUtils.pgliteProc.exitCode = exitCode } catch { diff --git a/packages/pglite/tests/fixtures/sandboxed-exit-code.js b/packages/pglite/tests/fixtures/sandboxed-exit-code.js new file mode 100644 index 000000000..877d023cd --- /dev/null +++ b/packages/pglite/tests/fixtures/sandboxed-exit-code.js @@ -0,0 +1,72 @@ +const realProcess = globalThis.process +const originalExitCode = realProcess.exitCode +const mode = realProcess.argv[2] + +let setterCalls = 0 +let expectedProcess = realProcess +let pg +let result + +if (mode === 'sandboxed') { + const sandboxedProcess = Object.create(realProcess) + Object.defineProperty(sandboxedProcess, 'exitCode', { + get() { + return 0 + }, + set() { + setterCalls++ + throw new Error('sandboxed process.exitCode setter called') + }, + configurable: false, + enumerable: true, + }) + globalThis.process = sandboxedProcess + expectedProcess = sandboxedProcess +} else if (mode === 'node' || mode === 'close') { + if (mode === 'node') { + realProcess.exitCode = 23 + } +} else { + throw new Error(`Unknown fixture mode: ${mode}`) +} + +try { + const { PGlite } = await import('../../dist/index.js') + pg = await PGlite.create() + const queryResult = await pg.query('SELECT 1 AS one') + const moduleLoaded = pg.ENV !== undefined + + if (mode === 'close') { + await pg.close() + } + + result = { + ok: true, + row: queryResult.rows[0]?.one, + exitCode: globalThis.process.exitCode, + moduleLoaded, + moduleCleared: mode === 'close' ? pg.ENV === undefined : undefined, + processRestored: globalThis.process === expectedProcess, + setterCalls, + } +} catch (error) { + result = { + ok: false, + processRestored: globalThis.process === expectedProcess, + setterCalls, + error: { + name: error instanceof Error ? error.name : typeof error, + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }, + } +} finally { + globalThis.process = realProcess + if (pg && !pg.closed) { + await pg.close() + } + realProcess.exitCode = originalExitCode +} + +realProcess.stdout.write(JSON.stringify(result)) +realProcess.exit(0) diff --git a/packages/pglite/tests/sandboxed-exit-code.test.ts b/packages/pglite/tests/sandboxed-exit-code.test.ts new file mode 100644 index 000000000..bf90de263 --- /dev/null +++ b/packages/pglite/tests/sandboxed-exit-code.test.ts @@ -0,0 +1,118 @@ +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +interface FixtureResult { + ok: boolean + row?: number + exitCode?: number + moduleLoaded?: boolean + moduleCleared?: boolean + processRestored: boolean + setterCalls: number + error?: { + name: string + message: string + stack?: string + } +} + +const fixturePath = fileURLToPath( + new URL('./fixtures/sandboxed-exit-code.js', import.meta.url), +) + +function runFixture( + mode: 'sandboxed' | 'node' | 'close', +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [fixturePath, mode], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + let timedOut = false + + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + + const timeout = setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + }, 20_000) + + child.on('error', (error) => { + clearTimeout(timeout) + reject(error) + }) + child.on('close', (code, signal) => { + clearTimeout(timeout) + if (timedOut) { + reject(new Error(`Fixture timed out; stderr: ${stderr}`)) + return + } + if (code !== 0) { + reject( + new Error( + `Fixture exited with code ${code} and signal ${signal}; stderr: ${stderr}`, + ), + ) + return + } + + try { + resolve(JSON.parse(stdout)) + } catch (error) { + reject( + new Error( + `Fixture returned invalid JSON: ${stdout}; stderr: ${stderr}`, + { + cause: error, + }, + ), + ) + } + }) + }) +} + +describe('process exit handling', () => { + it('boots when a Node-shaped process has a throwing exitCode setter', async () => { + const result = await runFixture('sandboxed') + + expect(result.ok, JSON.stringify(result, null, 2)).toBe(true) + expect(result).toMatchObject({ + row: 1, + processRestored: true, + }) + }) + + it('preserves the normal Node exitCode behavior', async () => { + const result = await runFixture('node') + + expect(result).toMatchObject({ + ok: true, + row: 1, + exitCode: 23, + processRestored: true, + setterCalls: 0, + }) + }) + + it('releases the Postgres module after the expected force exit', async () => { + const result = await runFixture('close') + + expect(result).toMatchObject({ + ok: true, + row: 1, + moduleLoaded: true, + moduleCleared: true, + processRestored: true, + }) + }) +})