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
32 changes: 32 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,38 @@ jobs:
QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD: "${{ github.repository == 'QwenLM/qwen-code' && '1' || '' }}"
run: 'npm run package:standalone:release -- --version "${RELEASE_VERSION}" --out-dir dist/standalone'

- name: 'Package npm platform packages'
env:
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
run: 'npm run package:npm-platform -- --version "${RELEASE_VERSION}"'
Comment thread
chiga0 marked this conversation as resolved.
Comment thread
chiga0 marked this conversation as resolved.
Comment thread
chiga0 marked this conversation as resolved.

- name: 'Publish platform runtime packages'
Comment thread
chiga0 marked this conversation as resolved.
Comment thread
chiga0 marked this conversation as resolved.
Comment thread
chiga0 marked this conversation as resolved.
if: |-
${{ github.repository == 'QwenLM/qwen-code' }}
run: |-
for pkg_dir in dist/npm-platform/*/; do
Comment thread
chiga0 marked this conversation as resolved.
[[ -f "${pkg_dir}package.json" ]] || continue
Comment thread
chiga0 marked this conversation as resolved.
Comment thread
chiga0 marked this conversation as resolved.
echo "::group::Publishing ${pkg_dir}"
(
cd "${pkg_dir}"
PACKAGE_NAME="$(node -p "require('./package.json').name")"
PUBLISH_ARGS=(--access public "--tag=${NPM_TAG}")
if [[ "${IS_DRY_RUN}" == "true" ]]; then
PUBLISH_ARGS+=(--dry-run)
elif npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >/dev/null 2>&1; then
echo "::notice::${PACKAGE_NAME}@${RELEASE_VERSION} already published; skipping"
exit 0
fi
npm publish "${PUBLISH_ARGS[@]}"
)
echo "::endgroup::"
done
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
NPM_TAG: '${{ needs.prepare.outputs.npm_tag }}'
IS_DRY_RUN: '${{ needs.prepare.outputs.is_dry_run }}'

- name: 'Publish @qwen-code/audio-capture'
if: |-
${{ github.repository == 'QwenLM/qwen-code' }}
Expand Down
9 changes: 7 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,13 @@ ENV PATH=$PATH:/usr/local/share/npm-global/bin
# Copy bundled package from builder stage
COPY --from=builder /home/node/app/dist/*.tgz /tmp/

# Install built packages globally
RUN npm install -g /tmp/*.tgz \
# Install built packages globally.
# --omit=optional: the image must run the CLI bundle it was built from, via
# the node fallback in npm-bin.js — not the prebuilt runtime a platform
# package would resolve to (which tracks the last npm release, not this
# tree). Optional native deps (sharp, node-pty, clipboard) degrade the same
# way and are not needed for the container use cases.
RUN npm install -g --omit=optional /tmp/*.tgz \
Comment thread
chiga0 marked this conversation as resolved.
Comment thread
chiga0 marked this conversation as resolved.
&& npm cache clean --force \
&& rm -rf /tmp/*.tgz

Expand Down
113 changes: 113 additions & 0 deletions docs/design/npm-platform-runtime-packages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Design: npm distribution with per-platform runtime packages

## Problem

`npm install -g @qwen-code/qwen-code` runs the CLI under Node, where the
OpenTUI renderer silently falls back to ink (the locked `@opentui/core` loads
its native renderer through FFI, which Node builds without `node:ffi` cannot
provide). Only the standalone archives — which bundle a pinned Bun runtime —
run OpenTUI. npm users therefore never see the new renderer, and the two
distribution channels diverge.

## Goals

- `npm install -g @qwen-code/qwen-code` yields a working OpenTUI CLI.
- No network access at install time beyond the npm registry itself (no
postinstall downloads from GitHub Releases) so mirrors and offline
registries keep working.
- The JS-only main package stays small; the heavy runtime stays optional so
`--omit=optional` installs and CI installs of the JS package still succeed.

## Approach: optionalDependencies + launcher (the opencode/esbuild pattern)

The main package declares five per-platform runtime packages as
`optionalDependencies`:

```
@qwen-code/qwen-code-{darwin-arm64, darwin-x64, linux-arm64, linux-x64, win-x64}
```

Each platform package is the standalone archive payload (pinned Bun build,
native renderer libraries, bundled CLI, `lib/cli-entry.js`) with an npm
manifest whose `os`/`cpu` fields make npm install exactly one of them per
host — the same mechanism already used in this repo by `@lydell/node-pty` and
`@teddyzhu/clipboard`.

The main package's `bin` becomes `npm-bin.js`, a Node launcher that resolves
the platform package for the current OS/arch and spawns its bundled Bun on
its `lib/cli-entry.js`, setting `QWEN_CODE_LAUNCHER_PATH` exactly like the
standalone `bin/qwen` wrapper so the in-CLI updater can relaunch correctly.

### Why a launcher instead of postinstall copying

opencode's npm package copies the platform binary into the main package via
`postinstall`. We deliberately do not:

- **No postinstall** means no surprise execution during install, no
`--ignore-scripts` breakage, and no partial state when the copy is
interrupted — the launcher resolves whatever is on disk at run time.
- The launcher is a plain Node script, so npm's cross-platform bin shims work
unmodified (no `.cmd` quoting games).
- The cost is one extra Node process start (~50ms) plus a lightweight waiter
resident for the whole session (~65 MB RSS on Node 22) to mirror exit status
and forward signals — inherent to the spawn-and-wait design (Node cannot
execve-replace itself), accepted in preference to postinstall.

### Fallbacks

Whenever the platform package is unavailable, the launcher prints a one-line
notice and runs `cli-entry.js` under node (the legacy node/ink path, which
still ships in the tarball) instead of failing — so `qwen` keeps working on
the node path exactly as before the platform packages existed:

- **Unsupported platform** (e.g. linux-x64 musl variants, win-arm64): no
prebuilt runtime exists; the launcher falls back to node.
- **Platform package missing** (`--omit=optional`, mirror gaps) or **damaged**
(partial extraction): the launcher falls back to node.
- **Main package without any platform package** (e.g. CI installing the JS
bundle for `qwen -p` usage): install succeeds because the dependency is
optional; the bin transparently runs the node path.

## Release flow

`release.yml` gains two steps between "Build Standalone Archives" and the
main package publish:

1. `npm run package:npm-platform -- --version "$RELEASE_VERSION"` —
repackages the five standalone archives into
`dist/npm-platform/<platform>/` npm package directories.
2. `Publish platform runtime packages` — publishes all five with the same
dry-run / already-published / `--tag=$NPM_TAG` guards as the existing
`@qwen-code/audio-capture` publish step.

Platform packages publish before the main package because the main package's
`optionalDependencies` entries (stamped by `prepare:package` from the root
`package.json` version, which `release:version` has already set to the
release version) point at them.

Version alignment is automatic: `scripts/package-npm-platform-packages.js`
must run with the same `--version` as the release, and `prepare-package.js`
derives the `optionalDependencies` versions from the same root
`package.json` the release flow stamps.

## Validation performed

- `npm pack` of both packages, local install via tarballs.
- `qwen --version` through the launcher (Bun path).
- PTY smoke: mouse tracking (`CSI ?1000h/?1006h`), composer, DEC 2026 sync —
all present (OpenTUI active) through the npm-installed bin.
- Platform package removed: launcher exits 1 with the node fallback command,
which was itself verified to run.
- yamllint clean on `release.yml`; eslint clean on the touched scripts.

## Alternatives considered

- **postinstall downloading the standalone tarball from GitHub Releases**:
adds a non-registry network dependency at install time (breaks offline
mirrors), and the downloaded payload is outside npm's integrity model.
- **Shipping opentui-assets in the JS tarball so node users get them**: dead
weight (~22MB) for a renderer node cannot load in the first place; the
platform packages carry them instead.
- **Requiring Bun as a peerDependency**: pushes runtime setup onto users and
CI; the platform-package approach is what users of esbuild/swc/opencode
already expect from npm-native tooling.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"package:hosted-installation": "node scripts/build-hosted-installation-assets.js",
"package:standalone": "node scripts/create-standalone-package.js",
"package:standalone:release": "node scripts/build-standalone-release.js",
"package:npm-platform": "node scripts/package-npm-platform-packages.js",
"verify:installation-release": "node scripts/verify-installation-release.js",
"release:version": "node scripts/version.js",
"changelog": "node scripts/generate-changelog.js",
Expand Down
81 changes: 75 additions & 6 deletions packages/cli/src/serve/run-qwen-serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10260,7 +10260,7 @@ describe('runQwenServe channel worker supervisor', () => {
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
});

it('force-kills channel worker, bridge, and pidfile on a second shutdown signal', async () => {
it('forces exit on a second shutdown signal delivered after the dedupe window', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-force-')),
);
Expand Down Expand Up @@ -10311,12 +10311,14 @@ describe('runQwenServe channel worker supervisor', () => {
expect(signalListener).toBeDefined();

const firstSignal = signalListener!('SIGTERM');
await Promise.resolve();
const secondSignal = signalListener!('SIGTERM');
await secondSignal;
// Wait past the duplicate-delivery dedupe window so the second signal
// reads as a genuine operator double-press, not a forwarded duplicate.
// The fast mock drain settles within that wait, so the second signal
// must take the force-exit branch instead of being deduplicated.
await new Promise((resolve) => setTimeout(resolve, 60));
await signalListener!('SIGTERM');

expect(worker.killAllSync).toHaveBeenCalled();
expect(bridge.killAllSync).toHaveBeenCalled();
expect(worker.stop).toHaveBeenCalled();
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
expect(exitSpy).toHaveBeenCalledWith(1);

Expand All @@ -10329,6 +10331,70 @@ describe('runQwenServe channel worker supervisor', () => {
}
});

it('ignores a duplicate shutdown signal delivered within the dedupe window of drain start', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-signal-dedupe-')),
);
const bridge = makeFakeBridge();
const worker = makeWorker({
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
});
const pidfile = makePidfileDeps();
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never);
const existingSigtermListeners = new Set(process.rawListeners('SIGTERM'));

const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge,
channelWorkerSupervisorFactory: vi.fn(() => worker),
channelServicePidfile: pidfile,
},
);

try {
const signalListener = process
.rawListeners('SIGTERM')
.find(
(listener) =>
!existingSigtermListeners.has(listener) &&
listener.name === 'onSignal',
) as ((signal: NodeJS.Signals) => Promise<void>) | undefined;
expect(signalListener).toBeDefined();

// Process-group shape: the npm launcher (or a cgroup-wide stop)
// delivers the same signal twice, milliseconds apart. The second
// delivery must not force-exit a drain that just started.
const firstSignal = signalListener!('SIGTERM');
await signalListener!('SIGTERM');
await firstSignal;

expect(worker.killAllSync).not.toHaveBeenCalled();
expect(bridge.killAllSync).not.toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(0);
} finally {
for (const listener of process.rawListeners('SIGTERM')) {
if (!existingSigtermListeners.has(listener)) {
process.removeListener('SIGTERM', listener as never);
}
}
await handle.close();
exitSpy.mockRestore();
}
});

it('retries graceful shutdown after an unconfirmed channel worker exit', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-stuck-')),
Expand Down Expand Up @@ -10387,6 +10453,9 @@ describe('runQwenServe channel worker supervisor', () => {
const logPath = path.join(tmpDir, 'debug', 'daemon', 'daemon.log');
expect(fs.readFileSync(logPath, 'utf8')).not.toContain('daemon stopped');

// Wait past the duplicate-delivery dedupe window: this second signal
// is the operator's deliberate retry, not a forwarded duplicate.
await new Promise((resolve) => setTimeout(resolve, 60));
await signalListener!('SIGTERM');
expect(worker.stop).toHaveBeenCalledTimes(2);
expect(worker.killAllSync).not.toHaveBeenCalled();
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/serve/run-qwen-serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,9 @@ const DEFAULT_RUNTIME_STARTUP_TIMEOUT_MS = 120_000;
const FAST_PATH_RUNTIME_START_AFTER_HEALTH_MS = 50;
// Keep manual/non-probed starts moving; health probes cancel this fallback.
const FAST_PATH_RUNTIME_START_FALLBACK_MS = 1_000;
// Same shape as gemini.tsx's SIGINT_RERAISE_IGNORE_MS: signals redelivered
// within this window of drain start come from process-group forwarding.
const SERVE_SIGNAL_DEDUPE_WINDOW_MS = 50;
const RUNTIME_STARTUP_TIMEOUT_ENV = 'QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS';
const MAX_EVENT_RING_SIZE = 1_000_000;
const DEFAULT_MAX_SESSIONS = 32;
Expand Down Expand Up @@ -6889,6 +6892,7 @@ async function runQwenServeImpl(
await unpublishLiveDiscovery();
};
let shuttingDown = false;
let drainStartedAt: number | undefined;
let closePromise: Promise<void> | undefined;
let runtimeStartupTimer: NodeJS.Timeout | undefined;
let runtimeStartAfterHealthTimer: NodeJS.Timeout | undefined;
Expand Down Expand Up @@ -7314,6 +7318,21 @@ async function runQwenServeImpl(
// drain completes. The handler is registered just before `resolve()`.
const onSignal = async (signal: NodeJS.Signals) => {
if (shuttingDown) {
if (
drainStartedAt !== undefined &&
Date.now() - drainStartedAt <= SERVE_SIGNAL_DEDUPE_WINDOW_MS
) {
// Duplicate delivery, not an operator double-press: the npm
// launcher shares the foreground process group and forwards the
// signal it received, and cgroup-wide stops (systemd's default
// KillMode) reach this process twice. A forwarded duplicate
// arrives within milliseconds; a genuine second press is far
// slower, so the force-exit branch below keeps its meaning.
daemonLog.warn(
`ignoring duplicate ${signal} within ${SERVE_SIGNAL_DEDUPE_WINDOW_MS}ms of drain start`,
);
return;
}
// Second signal forces exit. During drain (up to
// ~15s for a stuck child + the 5s force-close timer) an
// operator's reflexive `^C^C` would otherwise be dropped.
Expand Down Expand Up @@ -7350,6 +7369,7 @@ async function runQwenServeImpl(
loggerLifecycle.signalOwned();
}
daemonLog.warn(`received ${signal}, draining`);
drainStartedAt = Date.now();
try {
await handle.close();
process.exit(runtimeStartupError === undefined ? 0 : 1);
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/ui/utils/updateCheck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,27 @@ describe('checkForUpdates', () => {
);
});

it('prefers the host Node stamped by the platform launcher over the Bun execPath', async () => {
const run = vi.fn().mockResolvedValue({ stdout: '"1.1.0"', stderr: '' });
vi.stubEnv('QWEN_CODE_HOST_NODE', '/usr/local/bin/node');
try {
await runGlobalNpm(
['view', '@qwen-code/qwen-code'],
run as unknown as NonNullable<Parameters<typeof runGlobalNpm>[1]>,
);

// On the platform-runtime channel process.execPath is the bundled Bun,
// which has no npm beside it; the launcher-stamped host Node wins.
expect(run).toHaveBeenCalledWith(
'/usr/local/bin/node',
[expect.stringContaining('npm-cli.js'), 'view', '@qwen-code/qwen-code'],
expect.anything(),
);
} finally {
vi.unstubAllEnvs();
}
});

it('does not fall back when the global npm query fails', async () => {
const run = vi.fn().mockRejectedValue(new Error('npm view failed'));

Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/ui/utils/updateCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ export async function runGlobalNpm(
args: string[],
run: typeof execFileAsync = execFileAsync,
platform = process.platform,
nodePath = process.execPath,
// On the npm platform-runtime channel the CLI runs under the platform
// package's bundled Bun, so process.execPath has no npm beside it; the
// npm-bin.js launcher stamps the host Node's path for exactly this case.
nodePath = process.env['QWEN_CODE_HOST_NODE'] ?? process.execPath,
resolveNpmCliPath = getNpmCliPath,
): Promise<string> {
const { stdout } = await run(
Expand Down
Loading
Loading