Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tough-rabbits-boot.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 38 additions & 1 deletion packages/pglite/src/initdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,43 @@ function log(debug?: number, ...args: any[]) {
}
}

function callWithWritableProcessExitCode<T>(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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/pglite/src/pglite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
72 changes: 72 additions & 0 deletions packages/pglite/tests/fixtures/sandboxed-exit-code.js
Original file line number Diff line number Diff line change
@@ -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)
118 changes: 118 additions & 0 deletions packages/pglite/tests/sandboxed-exit-code.test.ts
Original file line number Diff line number Diff line change
@@ -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<FixtureResult> {
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,
})
})
})