Skip to content
Merged
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
14 changes: 14 additions & 0 deletions docs-site/guide/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `icudt<N>l.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.
Expand Down
62 changes: 62 additions & 0 deletions prelude/bootstrap-shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ///////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -564,6 +625,7 @@ function installDiagnostic(snapshotPrefix) {
module.exports = {
patchDlopen: patchDlopen,
patchChildProcess: patchChildProcess,
patchIntlSegmenter: patchIntlSegmenter,
setupProcessPkg: setupProcessPkg,
installDiagnostic: installDiagnostic,
COMPRESS_NONE: COMPRESS_NONE,
Expand Down
1 change: 1 addition & 0 deletions prelude/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -1958,3 +1958,4 @@ REQUIRE_SHARED.patchChildProcess(ENTRYPOINT);
// /////////////////////////////////////////////////////////////////

REQUIRE_SHARED.patchDlopen(insideSnapshot);
REQUIRE_SHARED.patchIntlSegmenter();
1 change: 1 addition & 0 deletions prelude/sea-bootstrap-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var SNAPSHOT_PREFIX = vfs.SNAPSHOT_PREFIX;

shared.patchDlopen(insideSnapshot);
shared.patchChildProcess(entrypoint);
shared.patchIntlSegmenter();
shared.setupProcessPkg(entrypoint, manifest.entrypoint);

// /////////////////////////////////////////////////////////////////
Expand Down
4 changes: 4 additions & 0 deletions prelude/sea-worker-entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
34 changes: 34 additions & 0 deletions test/test-50-intl-segmenter/main.js
Original file line number Diff line number Diff line change
@@ -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);
28 changes: 28 additions & 0 deletions test/test-50-intl-segmenter/test-x-index.js
Original file line number Diff line number Diff line change
@@ -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');
28 changes: 28 additions & 0 deletions test/unit/intl-segmenter.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading