diff --git a/docs-site/guide/troubleshooting.md b/docs-site/guide/troubleshooting.md index bab48502..5e98f237 100644 --- a/docs-site/guide/troubleshooting.md +++ b/docs-site/guide/troubleshooting.md @@ -117,6 +117,20 @@ unset NODE_OPTIONS NODE_DEBUG NODE_EXTRA_CA_CERTS NODE_NO_WARNINGS If your shell or IDE re-adds them automatically, add an exception rule — e.g. in VS Code add `"terminal.integrated.env.linux": { "NODE_OPTIONS": null }` to disable the auto-injection just for this project. +## Error: `RangeError: pkg: Intl.Segmenter is unavailable in this executable` + +Standard-mode binaries embed a Node.js built with `small-icu`, which ships no ICU break-iterator data. `new Intl.Segmenter()` constructs fine, but `segment()` has no data to work with. + +Upstream Node.js crashes the process outright here — an uncatchable `SIGSEGV` with no stack trace ([nodejs/node#51752](https://github.com/nodejs/node/issues/51752)). pkg replaces `segment()` with this `RangeError` so the failure points at its caller instead. + +You will usually hit this through a dependency rather than directly: `string-width` v7+ calls `Intl.Segmenter` to measure text, and it is a transitive dependency of `ora`, `boxen`, `inquirer` and `cli-table3`. Spinner frames are the classic trigger. + +Pick whichever fits: + +- **Build with [`--sea`](./sea-mode.md).** SEA mode uses the official Node.js binaries, which are full-icu. Nothing else to do. +- **Ship ICU data alongside the executable.** Download the `icudtl.dat` matching `process.versions.icu` from the [ICU releases](https://github.com/unicode-org/icu/releases) and point `NODE_ICU_DATA` at its directory. pkg detects the data and steps aside. This costs you the single-file property, and pkg cannot do it for you — ICU initializes before any user code runs, so the file cannot come from the virtual filesystem. +- **Avoid the API.** Pin `string-width` to v6, or shim `Intl.Segmenter` before importing anything that pulls it in. + ## AI-assisted debugging with Claude Code If you use [Claude Code](https://docs.anthropic.com/en/docs/claude-code), you can install the `/pkg-debug` skill to get interactive AI-assisted troubleshooting for any packaging issue. diff --git a/prelude/bootstrap-shared.js b/prelude/bootstrap-shared.js index 99c4876c..044b490e 100644 --- a/prelude/bootstrap-shared.js +++ b/prelude/bootstrap-shared.js @@ -372,6 +372,67 @@ function patchChildProcess(entrypoint) { }; } +// ///////////////////////////////////////////////////////////////// +// INTL SEGMENTER ////////////////////////////////////////////////// +// ///////////////////////////////////////////////////////////////// + +/** + * Replace Intl.Segmenter#segment with a throwing stub when the embedded + * Node.js has no ICU break-iterator data. + * + * pkg-fetch base binaries are built with --with-intl=small-icu, which + * ships no break-iterator data. V8 only DCHECKs the null + * icu::BreakIterator returned by BreakIterator::create*Instance, and + * DCHECKs are compiled out of release builds — so `new Intl.Segmenter()` + * succeeds and the first .segment() call dereferences null. The result is + * an uncatchable SIGSEGV with no stack, which is close to undebuggable + * downstream (nodejs/node#51752, https://issues.chromium.org/issues/531782498). + * + * Throwing the RangeError upstream intends to throw once the V8 fix lands + * costs nothing and makes the failure name its caller. + */ +function patchIntlSegmenter() { + if (typeof Intl === 'undefined' || typeof Intl.Segmenter !== 'function') { + return; + } + + var variables = (process.config && process.config.variables) || {}; + + // Cheap gate first — full-icu builds are unaffected and pay only this + // property read. + if (variables.icu_small !== true) return; + + // icu_small stays true when the user supplies real data through + // NODE_ICU_DATA / --icu-data-dir, and Segmenter then works, so probe for + // the data rather than trusting the build flag. Data-less small-icu + // resolves every locale to the default one; requiring two unrelated + // locales means no single system locale can mask the check. + if ( + new Intl.DateTimeFormat('de').resolvedOptions().locale === 'de' && + new Intl.DateTimeFormat('ja').resolvedOptions().locale === 'ja' + ) { + return; + } + + var dat = 'icudt' + (variables.icu_ver_major || '') + 'l.dat'; + + // Only .segment() is replaced. string-width v7+ — a transitive + // dependency of ora, boxen, inquirer and cli-table3 — constructs a + // Segmenter at module scope, so removing the constructor would break + // those imports outright instead of at the point of use. + Intl.Segmenter.prototype.segment = function segment() { + throw new RangeError( + 'pkg: Intl.Segmenter is unavailable in this executable. It embeds a ' + + 'small-icu Node.js build with no break-iterator data, where calling ' + + 'segment() would crash the process (nodejs/node#51752). Re-package ' + + 'with --sea, which uses full-icu official Node.js binaries, or set ' + + 'NODE_ICU_DATA to a directory containing ' + + dat + + '.', + ); + }; +} + // ///////////////////////////////////////////////////////////////// // PROCESS.PKG SETUP /////////////////////////////////////////////// // ///////////////////////////////////////////////////////////////// @@ -564,6 +625,7 @@ function installDiagnostic(snapshotPrefix) { module.exports = { patchDlopen: patchDlopen, patchChildProcess: patchChildProcess, + patchIntlSegmenter: patchIntlSegmenter, setupProcessPkg: setupProcessPkg, installDiagnostic: installDiagnostic, COMPRESS_NONE: COMPRESS_NONE, diff --git a/prelude/bootstrap.js b/prelude/bootstrap.js index 7308c34b..e8e5ad8f 100644 --- a/prelude/bootstrap.js +++ b/prelude/bootstrap.js @@ -1958,3 +1958,4 @@ REQUIRE_SHARED.patchChildProcess(ENTRYPOINT); // ///////////////////////////////////////////////////////////////// REQUIRE_SHARED.patchDlopen(insideSnapshot); +REQUIRE_SHARED.patchIntlSegmenter(); diff --git a/prelude/sea-bootstrap-core.js b/prelude/sea-bootstrap-core.js index d2e4adb7..4ad08b6f 100644 --- a/prelude/sea-bootstrap-core.js +++ b/prelude/sea-bootstrap-core.js @@ -28,6 +28,7 @@ var SNAPSHOT_PREFIX = vfs.SNAPSHOT_PREFIX; shared.patchDlopen(insideSnapshot); shared.patchChildProcess(entrypoint); +shared.patchIntlSegmenter(); shared.setupProcessPkg(entrypoint, manifest.entrypoint); // ///////////////////////////////////////////////////////////////// diff --git a/prelude/sea-worker-entry.js b/prelude/sea-worker-entry.js index b26f05da..551e23f9 100644 --- a/prelude/sea-worker-entry.js +++ b/prelude/sea-worker-entry.js @@ -22,3 +22,7 @@ shared.setupProcessPkg( vfs.toPlatformPath(vfs.manifest.entrypoint), vfs.manifest.entrypoint, ); + +// Worker threads get their own Intl, so the main-thread patch does not +// carry over — see patchIntlSegmenter in bootstrap-shared. +shared.patchIntlSegmenter(); diff --git a/test/test-50-intl-segmenter/main.js b/test/test-50-intl-segmenter/main.js new file mode 100644 index 00000000..acc15813 --- /dev/null +++ b/test/test-50-intl-segmenter/main.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('assert'); +const utils = require('../utils.js'); + +assert(!module.parent); +assert(__dirname === process.cwd()); + +const target = process.argv[2] || 'host'; +const input = './test-x-index.js'; +const output = './test-output.exe'; + +utils.pkg.sync(['--target', target, '--output', output, input]); + +// spawn.sync fails the test on a non-zero status or any signal, so a +// SIGSEGV here is caught without an explicit assertion. +const right = utils.spawn.sync(output, [], {}); +const lines = right.split('\n'); + +assert.strictEqual(lines[2], 'ALIVE'); + +if (lines[0] === 'BROKEN:true') { + // small-icu base, no break-iterator data: the prelude must have turned + // the crash into something catchable. + assert.strictEqual(lines[1], 'THREW:RangeError'); +} else { + // full-icu base (or NODE_ICU_DATA supplied): the prelude must stay out + // of the way. '⠋ hi ✔' is 6 grapheme clusters. + assert.strictEqual(lines[1], 'SEGMENTED:6'); +} + +utils.vacuum.sync(output); diff --git a/test/test-50-intl-segmenter/test-x-index.js b/test/test-50-intl-segmenter/test-x-index.js new file mode 100644 index 00000000..f75a5ca1 --- /dev/null +++ b/test/test-50-intl-segmenter/test-x-index.js @@ -0,0 +1,28 @@ +'use strict'; + +// Guards nodejs/node#51752: pkg-fetch base binaries are built with +// small-icu and ship no ICU break-iterator data, where Intl.Segmenter +// constructs fine but segment() dereferences a null icu::BreakIterator +// and takes the process down with SIGSEGV. The prelude replaces +// segment() with a RangeError on such bases. Whichever branch applies, +// the process must survive. + +const variables = process.config.variables; + +// Recomputed independently of the prelude: data-less small-icu resolves +// every locale to the default one. +const hasBreakData = + new Intl.DateTimeFormat('de').resolvedOptions().locale === 'de' && + new Intl.DateTimeFormat('ja').resolvedOptions().locale === 'ja'; + +console.log('BROKEN:' + (variables.icu_small === true && !hasBreakData)); + +const segmenter = new Intl.Segmenter(); + +try { + console.log('SEGMENTED:' + [...segmenter.segment('⠋ hi ✔')].length); +} catch (error) { + console.log('THREW:' + error.constructor.name); +} + +console.log('ALIVE'); diff --git a/test/unit/intl-segmenter.test.ts b/test/unit/intl-segmenter.test.ts new file mode 100644 index 00000000..6459b6aa --- /dev/null +++ b/test/unit/intl-segmenter.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { describe, it } from 'node:test'; + +const shared = createRequire(__filename)('../../prelude/bootstrap-shared.js'); + +// patchIntlSegmenter works around nodejs/node#51752, where segment() +// segfaults on small-icu builds with no break-iterator data. The unit +// suite runs on an official full-icu Node.js, so the case to lock in here +// is the one that would silently break healthy runtimes: on a base where +// Intl.Segmenter works, the patch must leave it alone. The crashing base +// is covered end-to-end by test-50-intl-segmenter. +describe('patchIntlSegmenter', () => { + it('leaves Intl.Segmenter alone when break-iterator data is present', () => { + const hasBreakData = + new Intl.DateTimeFormat('de').resolvedOptions().locale === 'de' && + new Intl.DateTimeFormat('ja').resolvedOptions().locale === 'ja'; + + // Nothing to assert on a host that is itself affected. + if (!hasBreakData) return; + + const before = Intl.Segmenter.prototype.segment; + shared.patchIntlSegmenter(); + + assert.equal(Intl.Segmenter.prototype.segment, before); + assert.equal([...new Intl.Segmenter().segment('⠋ hi ✔')].length, 6); + }); +});