mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-31 19:42:15 +03:00
Compare commits
7 Commits
chore/esli
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b342c1a361 | ||
|
|
550541c175 | ||
|
|
4febee9415 | ||
|
|
9dc6500ebd | ||
|
|
2af28c4e2c | ||
|
|
6f1f1668dd | ||
|
|
8aa3f1e5ab |
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -976,7 +976,11 @@ jobs:
|
||||
# 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it);
|
||||
# merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive
|
||||
# release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16).
|
||||
timeout-minutes: 20
|
||||
# 30, not 20 (2026-08-29): the informational Codecov upload below hung for the rest of
|
||||
# the budget on two consecutive main runs (33207760653, 33215115341); the job ended
|
||||
# `cancelled` and dragged the whole run's conclusion to `cancelled` although every
|
||||
# blocking job was green. The upload step now has its own ceiling; this is headroom.
|
||||
timeout-minutes: 30
|
||||
needs: test-unit
|
||||
if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
|
||||
env:
|
||||
@@ -1055,6 +1059,10 @@ jobs:
|
||||
# (if-no-files-found: warn) — Sonar consumes the same file.
|
||||
- name: Upload coverage to Codecov (informational)
|
||||
if: always()
|
||||
# Informational means informational: its own ceiling and continue-on-error, so a
|
||||
# stalled upload can neither eat the job's budget nor turn a green job cancelled.
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
|
||||
3
.github/workflows/docker-publish.yml
vendored
3
.github/workflows/docker-publish.yml
vendored
@@ -485,6 +485,9 @@ jobs:
|
||||
severity: CRITICAL
|
||||
ignore-unfixed: true
|
||||
exit-code: "1"
|
||||
# Explicit: the advisory scan above already points at it, and the blocking
|
||||
# gate must honour the same accepted-risk list (#12084).
|
||||
trivyignores: .trivyignore
|
||||
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
|
||||
27
.github/workflows/electron-release.yml
vendored
27
.github/workflows/electron-release.yml
vendored
@@ -4,12 +4,21 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
# A dispatch builds the ref it is dispatched ON (`gh workflow run … --ref v3.8.50` rebuilds
|
||||
# that tag; `--ref main` builds the repaired line). The ref is deliberately NOT an input:
|
||||
# CodeQL flags an input-controlled checkout next to the npm cache on the default branch as
|
||||
# cache poisoning (actions/cache-poisoning/poisonable-step), and `github.ref` is trusted.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g., v1.6.8)"
|
||||
required: true
|
||||
type: string
|
||||
publish_npm:
|
||||
description: "Also run the npm publish leg (turn off when re-attaching desktop assets to a release whose npm package already shipped)"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
# Least-privilege default: read-only at the top level; each job grants the writes it
|
||||
# needs (build/release upload assets, publish-npm forwards npm provenance / packages
|
||||
@@ -404,7 +413,14 @@ jobs:
|
||||
tag_name: ${{ needs.validate.outputs.version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
# NEVER. Phase 3 of the release flow creates the GitHub Release with the curated
|
||||
# notes seconds after pushing the tag, so by the time this step runs (1-2 h of
|
||||
# builds later) the body already exists — and `true` APPENDS GitHub's
|
||||
# auto-generated "What's Changed" block to it (v3.8.48 shipped that way; the
|
||||
# v3.8.50 re-attach dispatch added +1,416 chars to a 121 KB body, run
|
||||
# 33238093090). A curated body sits ~3 KB under the 125,000-char cap, so the
|
||||
# append can also turn this step RED and leave the release with no assets.
|
||||
generate_release_notes: false
|
||||
fail_on_unmatched_files: false
|
||||
files: |
|
||||
release-assets/*.dmg
|
||||
@@ -462,11 +478,20 @@ jobs:
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
needs: [validate, release]
|
||||
# A re-dispatch that only re-attaches desktop assets must not publish the npm package again.
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }}
|
||||
permissions:
|
||||
# Must be `write`, not `read`: this job calls the reusable npm-publish.yml whose
|
||||
# `publish` job needs `contents: write` (gh release upload — attach the SBOM, #3874).
|
||||
# A reusable workflow's job cannot request more permission than the caller grants,
|
||||
# so a `read` here makes GitHub reject the run at startup (startup_failure).
|
||||
#
|
||||
# `actions: read` for the same reason: the called `publish` job downloads the next-build
|
||||
# artefact and requests it. v3.8.50 (run 33005490476) died at startup with "The nested
|
||||
# job 'publish' is requesting 'actions: read', but is only allowed 'actions: none'" — and
|
||||
# because `release` lives in this same workflow, the tag shipped with ZERO assets. Keep
|
||||
# this block a superset of every job's permissions in npm-publish.yml.
|
||||
actions: read
|
||||
contents: write
|
||||
id-token: write # npm provenance (forwarded to the reusable workflow)
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
|
||||
39
.github/workflows/nightly-release-green.yml
vendored
39
.github/workflows/nightly-release-green.yml
vendored
@@ -196,6 +196,26 @@ jobs:
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Close tracking issue when the branch is green again
|
||||
if: steps.validate.outputs.exit == '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET: ${{ steps.branch.outputs.target }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The open/update step above is the UPWARD half of the loop; without this
|
||||
# step a stale "not green" issue outlives the fix and every base-green check
|
||||
# (`AGENTS.md` → "Base-green check") keeps stamping new PRs as base-red inherited.
|
||||
TITLE="🔴 Release branch not green: ${TARGET}"
|
||||
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh issue close "$EXISTING" --repo "$GITHUB_REPOSITORY" --reason completed \
|
||||
--comment "✅ \`${TARGET}\` is release-green again at \`${GITHUB_SHA:0:9}\` — ${RUN_URL}. Auto-closed by Release-Green (continuous)."
|
||||
echo "Closed issue #$EXISTING"
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -294,6 +314,25 @@ jobs:
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Close tracking issue when the branch is green again
|
||||
if: steps.validate.outputs.exit == '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The open/update step above is the UPWARD half of the loop; without this
|
||||
# step a stale "not green" issue outlives the fix and every base-green check
|
||||
# (`AGENTS.md` → "Base-green check") keeps stamping new PRs as base-red inherited.
|
||||
TITLE="🔴 main branch not green"
|
||||
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh issue close "$EXISTING" --repo "$GITHUB_REPOSITORY" --reason completed \
|
||||
--comment "✅ \`main\` is main-green again at \`${GITHUB_SHA:0:9}\` — ${RUN_URL}. Auto-closed by Release-Green (continuous)."
|
||||
echo "Closed issue #$EXISTING"
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
15
.github/workflows/npm-publish.yml
vendored
15
.github/workflows/npm-publish.yml
vendored
@@ -273,11 +273,20 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Attach SBOM to GitHub Release
|
||||
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'release'
|
||||
# Not only on the `release` event: the v3.8.50 package shipped through a
|
||||
# workflow_dispatch (staged publish, 11 attempts) and this step was skipped, so the
|
||||
# GitHub Release carried no SBOM until it was attached by hand from the run's
|
||||
# `sbom-npm` artifact. Attach whenever a release for the published tag exists.
|
||||
if: steps.resolve.outputs.skip != 'true' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: gh release upload "$TAG" sbom-npm.cdx.json --clobber
|
||||
TAG: ${{ github.event_name == 'release' && github.ref_name || format('v{0}', inputs.version) }}
|
||||
run: |
|
||||
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "::notice::no GitHub Release for $TAG yet — SBOM stays on the sbom-npm workflow artifact"
|
||||
exit 0
|
||||
fi
|
||||
gh release upload "$TAG" sbom-npm.cdx.json --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
# WS1.2/WS1.3 (#7065 class): the artifact that is about to be published must
|
||||
# BOOT. build:cli already assembled dist/ above; this packs+installs+boots the
|
||||
|
||||
5
.github/workflows/scorecard.yml
vendored
5
.github/workflows/scorecard.yml
vendored
@@ -4,12 +4,15 @@ on:
|
||||
schedule:
|
||||
- cron: "27 7 * * 1"
|
||||
push:
|
||||
branches: ["main"]
|
||||
# Scorecard only accepts the DEFAULT branch — here the active release/vX.Y.Z,
|
||||
# not `main`. The job below guards on it so a push to any other branch skips.
|
||||
branches: ["main", "release/**"]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
if: ${{ github.event_name != 'push' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
10
.trivyignore
10
.trivyignore
@@ -19,4 +19,12 @@
|
||||
# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a
|
||||
# patched base / bump the dep) over suppressing. Stale entries are debt.
|
||||
#
|
||||
# (No accepted-risk suppressions at present — ignore-unfixed covers the noise.)
|
||||
# CVE-2025-68121 — Go stdlib crypto/tls (session-resumption certificate validation)
|
||||
# inside the PREBUILT bogdanfinn/tls-client v1.15.1 .so that tls-client-node's
|
||||
# postinstall downloads (built with go 1.24.1; fixed in 1.24.13). No upstream
|
||||
# rebuild exists (v1.15.1 is still the latest release) and nothing in this repo
|
||||
# can bump it. The binary is only loaded by the browser-TLS web-provider
|
||||
# executors (claude-web / grok-web / lmarena / perplexity-web / notion-web),
|
||||
# whose handshakes go through utls. Tracking issue: #12084. Revisit at the next
|
||||
# tls-client release or base-image bump and BEFORE the v3.8.51 tag (2026-09-15).
|
||||
CVE-2025-68121
|
||||
|
||||
36
Dockerfile
36
Dockerfile
@@ -184,19 +184,29 @@ ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
|
||||
# silently leaving no standalone bundle. Next derives the worker count from
|
||||
# CIRCLE_NODE_TOTAL (workers = N-1). (#10060)
|
||||
#
|
||||
# Lowered 8 → 3 (7 workers → 2). Every page-data worker inherits NODE_OPTIONS
|
||||
# above, so the ceiling is per PROCESS, not per build: 7 workers on a 16 GB
|
||||
# GitHub runner (ubuntu-24.04 / ubuntu-24.04-arm, 4 vCPU) exhausted the host and
|
||||
# buildkit failed the whole step with `ResourceExhausted: ... cannot allocate
|
||||
# memory`. The compile phase always finished ("✓ Compiled successfully in
|
||||
# 4.2min"); the kernel killed the build right after "Collecting page data using
|
||||
# 7 workers". It was intermittent for a while and went 100% on 2026-08-22, which
|
||||
# is what a threshold being crossed by ordinary codebase growth looks like.
|
||||
# tests/unit/docker-build-memory-budget.test.ts does the arithmetic and fails if
|
||||
# either knob is raised past what a 16 GB runner holds. 2 workers also stops
|
||||
# oversubscribing the runner's 4 vCPU, which 7 did. Override for a big builder:
|
||||
# `--build-arg OMNIROUTE_BUILD_WORKERS=8`.
|
||||
ARG OMNIROUTE_BUILD_WORKERS=3
|
||||
# Lowered 8 → 3 (7 workers → 2) in #11419, then 3 → 2 (2 workers → 1) in #7518.
|
||||
# Every page-data worker inherits NODE_OPTIONS above, so the ceiling is per
|
||||
# PROCESS, not per build: 7 workers on a 16 GB GitHub runner (ubuntu-24.04 /
|
||||
# ubuntu-24.04-arm, 4 vCPU) exhausted the host and buildkit failed the whole
|
||||
# step with `ResourceExhausted: ... cannot allocate memory`. The compile phase
|
||||
# always finished ("✓ Compiled successfully in 4.2min"); the kernel killed the
|
||||
# build right after "Collecting page data using N workers".
|
||||
#
|
||||
# #11419's first fix (8 → 3) modeled the per-worker peak as an INFERENCE
|
||||
# (2560 MB, guessed from "7 workers didn't fit") and assumed the parent
|
||||
# process's RSS tracked the V8 heap ceiling. Both assumptions were wrong: a
|
||||
# live VPS reproduction (issue #7518, dmesg OOM-killer report) measured the
|
||||
# real per-process RSS directly at ~4.5 GB, independent of the NODE_OPTIONS
|
||||
# heap flag (Turbopack itself is native/Rust, outside the V8 heap) — and it
|
||||
# applies to the parent process too, not just workers. 2 workers (3 processes
|
||||
# × 4.5 GB = 13.5 GB) still didn't fit the 12.288 GB (75%) budget on a 16 GB
|
||||
# runner, matching the still-live publish failures after #11419 merged. 1
|
||||
# worker (2 processes × 4.5 GB = 9 GB) fits with headroom to spare.
|
||||
# tests/unit/docker-build-memory-budget.test.ts does the arithmetic against
|
||||
# the measured figure and fails if either knob is raised past what a 16 GB
|
||||
# runner holds. Override for a big builder: `--build-arg
|
||||
# OMNIROUTE_BUILD_WORKERS=8`.
|
||||
ARG OMNIROUTE_BUILD_WORKERS=2
|
||||
ENV CIRCLE_NODE_TOTAL=${OMNIROUTE_BUILD_WORKERS}
|
||||
|
||||
COPY . ./
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ── Multi-stage Dockerfile for Native Bun Runtime (web-latest-bun) ───────────
|
||||
FROM oven/bun:1.3.14-slim AS base
|
||||
FROM oven/bun:1.4.0-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
@@ -34,8 +34,10 @@ RUN if [ -f "node_modules/tls-client-node/scripts/postinstall.js" ]; then \
|
||||
bun node_modules/tls-client-node/scripts/postinstall.js || true; \
|
||||
fi
|
||||
|
||||
# Disable Turbopack for Bun builder stage (Turbopack V8 internal worker bindings require Node)
|
||||
ENV OMNIROUTE_USE_TURBOPACK=0
|
||||
# Turbopack is supported on Bun 1.4+ (Next 16.3); override via
|
||||
# --build-arg OMNIROUTE_USE_TURBOPACK=0 to force the webpack fallback.
|
||||
ARG OMNIROUTE_USE_TURBOPACK=1
|
||||
ENV OMNIROUTE_USE_TURBOPACK=${OMNIROUTE_USE_TURBOPACK}
|
||||
|
||||
ARG OMNIROUTE_BASE_PATH=""
|
||||
ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
|
||||
@@ -46,6 +48,33 @@ ENV DASHBOARD_ALLOW_EMBED=$DASHBOARD_ALLOW_EMBED
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Cap the Next.js build heap and page-data worker pool inside the Bun image the
|
||||
# same way the node Dockerfile does (#10060/#11419/#7518). Without these knobs
|
||||
# Next falls back to its defaults: worker pool = os.cpus()-1 (3 on the 4-vCPU
|
||||
# GitHub runner) and an 8 GB V8 heap ceiling per process. 4+ V8 processes at
|
||||
# multi-GB each blow past the 16 GB runner, the cgroup OOM killer SIGKILLs a
|
||||
# build worker mid-compile, and buildx fails the step with `ResourceExhausted:
|
||||
# ... cannot allocate memory` — every Bun image published on main since the -bun
|
||||
# targets landed (#11709, #11039).
|
||||
#
|
||||
# The per-process peak is a MEASURED ~4.5 GB RSS (dmesg OOM-killer report,
|
||||
# #7518), independent of NODE_OPTIONS — Turbopack is native/Rust and compiles
|
||||
# outside the V8 heap — and it applies to the parent process too, so 2 page-data
|
||||
# workers (3 processes × 4.5 GB ≈ 13.5 GB) do not fit the 12.288 GB (75%)
|
||||
# budget either. Both images therefore default to OMNIROUTE_BUILD_WORKERS=2
|
||||
# (1 page-data worker): 2 processes × 4.5 GB ≈ 9 GB fits with headroom (#11663).
|
||||
# The default Turbopack path keeps the compile outside the V8 heap, but the
|
||||
# guards must hold for the webpack fallback (OMNIROUTE_USE_TURBOPACK=0) too, so
|
||||
# they are wired exactly like the node image.
|
||||
#
|
||||
# NODE_OPTIONS propagates to the spawned `next build` child and its workers
|
||||
# (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env), so the
|
||||
# ceiling is per PROCESS, not per build.
|
||||
ARG OMNIROUTE_BUILD_MEMORY_MB=6144
|
||||
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
|
||||
ARG OMNIROUTE_BUILD_WORKERS=2
|
||||
ENV CIRCLE_NODE_TOTAL=${OMNIROUTE_BUILD_WORKERS}
|
||||
|
||||
# Bun native Next.js build execution
|
||||
RUN bun run --quiet build
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(docker):** bump the Bun image to 1.4.0, enable Turbopack on Bun, and port the node image's build memory guards so the `-bun` container builds fit the 16 GB GitHub runner instead of dying with `cannot allocate memory` ([#11719](https://github.com/diegosouzapw/OmniRoute/pull/11719)). Both images now default `OMNIROUTE_BUILD_WORKERS` to `2` (1 page-data worker) against the measured ~4.5 GB per-process RSS budget (#7518/#11663).
|
||||
1
changelog.d/fixes/v3850-electron-lockfile-build-ref.md
Normal file
1
changelog.d/fixes/v3850-electron-lockfile-build-ref.md
Normal file
@@ -0,0 +1 @@
|
||||
- Electron release: `electron/package-lock.json` regained the optional `electron-builder-squirrel-windows` subtree (13 entries) that `npm ci` had been refusing as out of sync — the Linux desktop leg died on it — and `electron-release.yml` gained a `build_ref` dispatch input so a release whose tag was cut with the broken lock can have its assets rebuilt from the repaired line
|
||||
1
changelog.d/fixes/v3850-electron-release-assets.md
Normal file
1
changelog.d/fixes/v3850-electron-release-assets.md
Normal file
@@ -0,0 +1 @@
|
||||
- Electron release workflow: the `publish-npm` job now grants `actions: read` to the reusable `npm-publish.yml` it calls (its `publish` job requests it), which is what made GitHub refuse the whole v3.8.50 run at startup and ship the release with zero desktop assets; a `workflow_dispatch` now builds the requested tag instead of the dispatching branch and can skip the npm leg (`publish_npm=false`) when only re-attaching assets
|
||||
1
changelog.d/fixes/v3850-sbom-attach-on-dispatch.md
Normal file
1
changelog.d/fixes/v3850-sbom-attach-on-dispatch.md
Normal file
@@ -0,0 +1 @@
|
||||
- npm publish workflow: the CycloneDX SBOM is attached to the GitHub Release on `workflow_dispatch` publishes too (when a release for the tag exists), not only on the `release` event — v3.8.50 shipped through a staged dispatch and its release carried no SBOM until it was attached by hand from the run's `sbom-npm` artifact
|
||||
@@ -0,0 +1 @@
|
||||
- `Coverage` job on `ci.yml`: the informational Codecov upload gets its own 5-minute ceiling and `continue-on-error`, and the job budget grows from 20 to 30 minutes (the 8-shard c8 merge alone takes ~10) — a stalled upload no longer ends the job `cancelled` and drags a fully green `main` run's conclusion down with it
|
||||
@@ -226,16 +226,22 @@ Three build args control what the `builder` stage costs. They are build-time onl
|
||||
| --------------------------- | ------- | ----------------------------------------------------------------------------------- |
|
||||
| `OMNIROUTE_USE_TURBOPACK` | `1` | `0` builds with webpack instead. Lower peak memory, slower. |
|
||||
| `OMNIROUTE_BUILD_MEMORY_MB` | `6144` | V8 heap ceiling (`--max-old-space-size`) for the spawned `next build`. |
|
||||
| `OMNIROUTE_BUILD_WORKERS` | `3` | Feeds `CIRCLE_NODE_TOTAL`; Next derives `workers = N - 1` for page-data collection. |
|
||||
| `OMNIROUTE_BUILD_WORKERS` | `2` | Feeds `CIRCLE_NODE_TOTAL`; Next derives `workers = N - 1` for page-data collection. |
|
||||
|
||||
`OMNIROUTE_BUILD_WORKERS` is the one to raise on a big builder and the one to
|
||||
suspect when a constrained build dies **after** `✓ Compiled successfully`. Each
|
||||
page-data worker is its own process and inherits `NODE_OPTIONS`, so the heap
|
||||
ceiling is per process, not per build: the default of `3` (→ 2 workers) is sized
|
||||
for the 16 GB / 4 vCPU GitHub-hosted runners the publish pipeline uses. At `8`
|
||||
(→ 7 workers) that runner ran out of memory and buildkit failed the step with
|
||||
`ResourceExhausted: ... cannot allocate memory`. `tests/unit/docker-build-memory-budget.test.ts`
|
||||
does the arithmetic and fails if either knob outgrows the runner.
|
||||
page-data worker is its own process, and so is the parent `next build` itself;
|
||||
a live VPS reproduction (issue #7518) measured each process's peak RSS at
|
||||
~4.5 GB independent of the `NODE_OPTIONS` heap flag (Turbopack compiles in
|
||||
native/Rust memory outside the V8 heap). The default of `2` (→ 1 worker, 2
|
||||
processes total) is sized for the 16 GB / 4 vCPU GitHub-hosted runners the
|
||||
publish pipeline uses. At `8` (→ 7 workers) that runner ran out of memory and
|
||||
buildkit failed the step with `ResourceExhausted: ... cannot allocate memory`;
|
||||
`3` (→ 2 workers) still didn't fit once the per-process RSS was measured
|
||||
directly instead of inferred. `tests/unit/docker-build-memory-budget.test.ts`
|
||||
does the arithmetic against the measured figure and fails if either knob
|
||||
outgrows the runner. Both images (node and Bun) share these defaults; the Bun
|
||||
image's are set in `Dockerfile.bun` (Turbopack on Bun 1.4+, `#11719`).
|
||||
|
||||
Turbopack compiles in native Rust memory that lives **outside** the V8 heap, so
|
||||
`OMNIROUTE_BUILD_MEMORY_MB` does not bound it. On a host with a memory ceiling the
|
||||
|
||||
195
electron/package-lock.json
generated
195
electron/package-lock.json
generated
@@ -297,6 +297,45 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/windows-sign": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
|
||||
"integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"fs-extra": "^11.1.1",
|
||||
"minimist": "^1.2.8",
|
||||
"postject": "^1.0.0-alpha.6"
|
||||
},
|
||||
"bin": {
|
||||
"electron-windows-sign": "bin/electron-windows-sign.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/windows-sign/node_modules/fs-extra": {
|
||||
"version": "11.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz",
|
||||
"integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -1091,6 +1130,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-dirname": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -1411,6 +1459,19 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-builder-squirrel-windows": {
|
||||
"version": "26.15.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz",
|
||||
"integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.15.3",
|
||||
"builder-util": "26.15.3",
|
||||
"electron-winstaller": "5.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-publish": {
|
||||
"version": "26.15.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz",
|
||||
@@ -1445,6 +1506,66 @@
|
||||
"tiny-typed-emitter": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
|
||||
"integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
"fs-extra": "^7.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"temp": "^0.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@electron/windows-sign": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/fs-extra": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/jsonfile": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -2359,6 +2480,20 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2622,6 +2757,36 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postject": {
|
||||
"version": "1.0.0-alpha.6",
|
||||
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
|
||||
"integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
"bin": {
|
||||
"postject": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postject/node_modules/commander": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
|
||||
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
|
||||
@@ -2816,6 +2981,21 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
|
||||
"integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/roarr": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
|
||||
@@ -3071,6 +3251,21 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/temp": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-file": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz",
|
||||
|
||||
@@ -131,9 +131,15 @@ function runNextBuild() {
|
||||
}
|
||||
|
||||
export function resolveNextBuildBundlerFlag(baseEnv = process.env) {
|
||||
// Turbopack is the default on Node.js; on Bun or when explicitly disabled (=0),
|
||||
// use Webpack (--webpack) to avoid Turbopack V8 internal worker API mismatches.
|
||||
if (process.versions.bun || baseEnv.OMNIROUTE_USE_TURBOPACK === "0") {
|
||||
// Turbopack is the default; OMNIROUTE_USE_TURBOPACK=0 is the documented escape hatch
|
||||
// to webpack (Windows, native-binding trouble, RAM-constrained machines — #6409, and
|
||||
// docs/reference/ENVIRONMENT.md). The choice is env-only ON PURPOSE: the variable is
|
||||
// the operator's control and CI sets it explicitly, so sniffing the runtime here would
|
||||
// silently override an operator who asked for Turbopack. Bun 1.4+ supports Turbopack's
|
||||
// V8 worker bindings (#11471), so the historical `process.versions.bun` → `--webpack`
|
||||
// hardcode is gone; the `OMNIROUTE_USE_TURBOPACK=0` fallback remains for Bun < 1.4
|
||||
// images built with the webpack path.
|
||||
if (baseEnv.OMNIROUTE_USE_TURBOPACK === "0") {
|
||||
return "--webpack";
|
||||
}
|
||||
return "--turbopack";
|
||||
|
||||
@@ -123,6 +123,48 @@ function discoverPackagedExecutable() {
|
||||
throw new Error(`Packaged Electron smoke check does not support ${platform()}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The packaged app opens SQLite lazily: `/login` (the readiness URL) never touches the
|
||||
* database, so a smoke that only waits for readiness sees no `[DB]` line at all. After
|
||||
* readiness the smoke requests a DB-backed endpoint and waits for evidence that the
|
||||
* database opened. The primary open path does NOT print "[DB] Driver: ..." (only the
|
||||
* recovery path and the sql.js fallback do), so the evidence is any `[DB]`/`[Migration]`
|
||||
* startup line — and the #7592 guard below rejects the fallback's own line explicitly.
|
||||
*/
|
||||
export const DB_TOUCH_PATH = "/api/monitoring/health";
|
||||
export const DB_OPEN_EVIDENCE_PATTERN =
|
||||
/\[DB\] (Driver: |SQLite database ready|Added [^\n]* column|Changing cache_size|cache_size changed)|\[Migration\] (Applied|Pre-migration backup)/;
|
||||
|
||||
export async function waitForDatabaseOpen(getLogs, { timeoutMs = 15_000, pollMs = 250 } = {}) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const logs = getLogs();
|
||||
assertNoFatalLogs(logs);
|
||||
if (DB_OPEN_EVIDENCE_PATTERN.test(logs)) return logs;
|
||||
await sleep(pollMs);
|
||||
}
|
||||
throw new Error(
|
||||
`Packaged Electron app logged no [DB]/[Migration] startup line within ${timeoutMs}ms of ` +
|
||||
`touching ${DB_TOUCH_PATH} — the database never opened, so the SQLite driver cannot be verified.`
|
||||
);
|
||||
}
|
||||
|
||||
async function openDatabaseForSmoke({ logs, smokeUrl }) {
|
||||
const touchUrl = new URL(DB_TOUCH_PATH, smokeUrl).toString();
|
||||
try {
|
||||
const response = await fetchWithTimeout(touchUrl, 5_000);
|
||||
console.log(
|
||||
`[electron-smoke] touched ${touchUrl} (HTTP ${response.status}) to open the database`
|
||||
);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`[electron-smoke] touching ${touchUrl} failed (${error instanceof Error ? error.message : String(error)}) — waiting for the database anyway`
|
||||
);
|
||||
}
|
||||
await waitForDatabaseOpen(() => logs.value);
|
||||
console.log("[electron-smoke] database opened");
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, timeoutMs) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
@@ -450,8 +492,13 @@ export function assertNativeDriverSelected(logs) {
|
||||
);
|
||||
}
|
||||
|
||||
// The primary open path prints no "[DB] Driver: ..." line at all (only the recovery path and
|
||||
// the sql.js fallback do), so a database that demonstrably opened WITHOUT the fallback's own
|
||||
// line is the native driver — that is exactly what #7592 guards.
|
||||
if (DB_OPEN_EVIDENCE_PATTERN.test(logs)) return;
|
||||
|
||||
throw new Error(
|
||||
"Packaged Electron app logs contain no '[DB] Driver: ...' line — cannot confirm which SQLite " +
|
||||
"Packaged Electron app logs show no database activity at all — cannot confirm which SQLite " +
|
||||
"driver loaded."
|
||||
);
|
||||
}
|
||||
@@ -506,7 +553,14 @@ async function waitForReady({ logs, smokeUrl, timeoutMs, settleMs, exitState })
|
||||
* by the single-launch path and the cold-restart (two-launch) path so both
|
||||
* exercise identical spawn/readiness/shutdown behavior.
|
||||
*/
|
||||
async function launchAndCollectLogs({ appExecutable, smokeUrl, dataDir, timeoutMs, settleMs, streamLogs }) {
|
||||
async function launchAndCollectLogs({
|
||||
appExecutable,
|
||||
smokeUrl,
|
||||
dataDir,
|
||||
timeoutMs,
|
||||
settleMs,
|
||||
streamLogs,
|
||||
}) {
|
||||
const smokeEnv = buildSmokeEnv({ dataDir });
|
||||
await assertPortIsFree(smokeUrl);
|
||||
await ensureSmokeEnvDirs(smokeEnv, dataDir);
|
||||
@@ -538,6 +592,8 @@ async function launchAndCollectLogs({ appExecutable, smokeUrl, dataDir, timeoutM
|
||||
|
||||
try {
|
||||
await waitForReady({ logs, smokeUrl, timeoutMs, settleMs, exitState });
|
||||
// Outside waitForReady on purpose: a missing database is a verdict, not a readiness retry.
|
||||
await openDatabaseForSmoke({ logs, smokeUrl });
|
||||
return logs.value;
|
||||
} catch (error) {
|
||||
if (!streamLogs) {
|
||||
@@ -568,7 +624,14 @@ async function main() {
|
||||
!process.env.ELECTRON_SMOKE_DATA_DIR && process.env.ELECTRON_SMOKE_KEEP_DATA !== "1";
|
||||
|
||||
try {
|
||||
await launchAndCollectLogs({ appExecutable, smokeUrl, dataDir, timeoutMs, settleMs, streamLogs });
|
||||
await launchAndCollectLogs({
|
||||
appExecutable,
|
||||
smokeUrl,
|
||||
dataDir,
|
||||
timeoutMs,
|
||||
settleMs,
|
||||
streamLogs,
|
||||
});
|
||||
|
||||
if (!coldRestart) return;
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("Protocol clients E2E", () => {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ a2aEnabled: true }),
|
||||
});
|
||||
expect([200, 401]).toContain(response.status);
|
||||
expect([200, 401, 403]).toContain(response.status);
|
||||
});
|
||||
|
||||
it(
|
||||
@@ -134,7 +134,7 @@ describe("Protocol clients E2E", () => {
|
||||
}
|
||||
|
||||
const auditRes = await apiFetch("/api/mcp/audit?limit=50&tool=omniroute_get_health");
|
||||
expect([200, 401]).toContain(auditRes.status);
|
||||
expect([200, 401, 403]).toContain(auditRes.status);
|
||||
if (auditRes.status === 200) {
|
||||
expect(auditRes.ok).toBe(true);
|
||||
const auditJson = (await auditRes.json()) as any;
|
||||
|
||||
19
tests/unit/build/resolve-next-build-bundler-flag.test.mjs
Normal file
19
tests/unit/build/resolve-next-build-bundler-flag.test.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveNextBuildBundlerFlag } from "../../../scripts/build/build-next-isolated.mjs";
|
||||
|
||||
test("resolveNextBuildBundlerFlag returns --turbopack by default", () => {
|
||||
const flag = resolveNextBuildBundlerFlag({});
|
||||
assert.equal(flag, "--turbopack");
|
||||
});
|
||||
|
||||
test("resolveNextBuildBundlerFlag returns --webpack when OMNIROUTE_USE_TURBOPACK is '0'", () => {
|
||||
const flag = resolveNextBuildBundlerFlag({ OMNIROUTE_USE_TURBOPACK: "0" });
|
||||
assert.equal(flag, "--webpack");
|
||||
});
|
||||
|
||||
test("resolveNextBuildBundlerFlag returns --turbopack when OMNIROUTE_USE_TURBOPACK is '1'", () => {
|
||||
const flag = resolveNextBuildBundlerFlag({ OMNIROUTE_USE_TURBOPACK: "1" });
|
||||
assert.equal(flag, "--turbopack");
|
||||
});
|
||||
@@ -88,13 +88,37 @@ test("createSyncDriverFactory prefers better-sqlite3 when running under Node", (
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveNextBuildBundlerFlag automatically disables Turbopack and uses Webpack under Bun", async () => {
|
||||
// `OMNIROUTE_USE_TURBOPACK` is the operator's only control over the bundler:
|
||||
// Turbopack is the default and `0` is the documented escape hatch (webpack), taken
|
||||
// for Windows / native-binding trouble / RAM-constrained machines — see
|
||||
// docs/reference/ENVIRONMENT.md and #6409. Nothing sniffs the runtime, so pin that:
|
||||
// a hidden override would silently ignore an explicit `=1` from an operator who set
|
||||
// it on purpose (CI does, in build.yml / ci.yml / quality.yml). Bun 1.4+ supports
|
||||
// Turbopack's V8 worker bindings (#11471), so the historical bun-only webpack
|
||||
// forced path is gone from the Bun image as well.
|
||||
test("resolveNextBuildBundlerFlag is decided by OMNIROUTE_USE_TURBOPACK alone, not by the runtime", async () => {
|
||||
const buildIsolated = await import("../../scripts/build/build-next-isolated.mjs");
|
||||
const originalBun = process.versions.bun;
|
||||
try {
|
||||
(process.versions as Record<string, string>).bun = "1.1.20";
|
||||
const buildIsolated = await import("../../scripts/build/build-next-isolated.mjs");
|
||||
assert.equal(buildIsolated.resolveNextBuildBundlerFlag({}), "--webpack");
|
||||
assert.equal(buildIsolated.resolveNextBuildBundlerFlag({ OMNIROUTE_USE_TURBOPACK: "1" }), "--webpack");
|
||||
for (const bun of [undefined, "1.1.20", "1.3.14"]) {
|
||||
if (bun === undefined) {
|
||||
delete (process.versions as Record<string, string | undefined>).bun;
|
||||
} else {
|
||||
(process.versions as Record<string, string>).bun = bun;
|
||||
}
|
||||
const where = `bun=${bun ?? "absent"}`;
|
||||
assert.equal(buildIsolated.resolveNextBuildBundlerFlag({}), "--turbopack", where);
|
||||
assert.equal(
|
||||
buildIsolated.resolveNextBuildBundlerFlag({ OMNIROUTE_USE_TURBOPACK: "1" }),
|
||||
"--turbopack",
|
||||
where
|
||||
);
|
||||
assert.equal(
|
||||
buildIsolated.resolveNextBuildBundlerFlag({ OMNIROUTE_USE_TURBOPACK: "0" }),
|
||||
"--webpack",
|
||||
where
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (originalBun === undefined) {
|
||||
delete (process.versions as Record<string, string | undefined>).bun;
|
||||
|
||||
@@ -4,76 +4,109 @@ import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// The Docker publish workflow builds on GitHub-hosted runners (ubuntu-24.04 and
|
||||
// ubuntu-24.04-arm): 4 vCPU, 16 GB RAM. Every Next page-data worker is its own
|
||||
// process and inherits NODE_OPTIONS, so the V8 ceiling is per PROCESS: the
|
||||
// build's worst case is roughly `workers × OMNIROUTE_BUILD_MEMORY_MB`.
|
||||
// ubuntu-24.04-arm): 4 vCPU, 16 GB RAM. Every Next page-data worker AND the
|
||||
// parent `next build` process are separate OS processes, so the budget has to
|
||||
// cover all of them, not just the workers. Both images (node and Bun) run the
|
||||
// same build-next-isolated.mjs pipeline on the same runners; the Bun image runs
|
||||
// Turbopack by default on Bun 1.4+ (#11471) and the webpack fallback
|
||||
// (OMNIROUTE_USE_TURBOPACK=0) keeps memory in V8, so the guards must hold for
|
||||
// both bundlers on both images (#11709).
|
||||
//
|
||||
// With 7 workers × 6144 MB the runner ran out and buildkit failed the step with
|
||||
// `ResourceExhausted: ... cannot allocate memory`, right after "Collecting page
|
||||
// data using 7 workers" — every Docker publish since 2026-08-22 23:14 UTC.
|
||||
// Lowering to 2 workers (#10060 / PR #11419) was not enough: it modeled the
|
||||
// per-process peak as an INFERENCE (`WORKER_PEAK_MB = 2560`, derived only from
|
||||
// "7 workers didn't fit") and assumed the parent process tracked the V8 heap
|
||||
// ceiling (`OMNIROUTE_BUILD_MEMORY_MB`) rather than its own RSS. The owner's
|
||||
// live VPS reproduction (issue #7518, dmesg OOM-killer report, 2026-08-24)
|
||||
// measured the real number directly: `next-build (v16) ... anon-rss:4522744kB`
|
||||
// (~4.5 GB) per process, independent of the NODE_OPTIONS heap flag — Turbopack
|
||||
// itself is native/Rust and compiles outside the V8 heap. With 2 workers that
|
||||
// keeps the publish pipeline failing at "Collecting page data using 2 workers"
|
||||
// (run 32907937950, 2026-08-25).
|
||||
//
|
||||
// This pins the budget so raising either knob has to be a deliberate change
|
||||
// that re-does the arithmetic, not a one-line bump that silently reds the
|
||||
// publish pipeline again.
|
||||
// This pins the budget on the MEASURED figure, applied uniformly to every
|
||||
// process (parent + workers) and to both images, so raising the worker count
|
||||
// has to be a deliberate change that re-does the arithmetic, not a one-line
|
||||
// bump that silently reds the publish pipeline again.
|
||||
|
||||
const RUNNER_MEMORY_MB = 16 * 1024;
|
||||
// Leave room for buildkit, the snapshotter and page cache.
|
||||
const HEADROOM_FRACTION = 0.75;
|
||||
// Planning figure for one page-data worker's peak RSS. It is an INFERENCE, not
|
||||
// a measurement: 7 workers did not fit in 16 GB alongside the parent, which
|
||||
// puts the per-worker peak somewhere north of ~1.8 GB. 2.5 GB is that bound
|
||||
// rounded up, so the budget below stays conservative. If a future build OOMs
|
||||
// again with a worker count this test accepts, raise this number — do not
|
||||
// weaken the budget.
|
||||
const WORKER_PEAK_MB = 2560;
|
||||
// Measured (not inferred) peak RSS for a single Next/Turbopack build process —
|
||||
// parent or page-data worker alike — from the dmesg OOM-killer report above.
|
||||
// If a future build OOMs again, re-measure via dmesg before raising this
|
||||
// number — do not weaken the budget with another guess.
|
||||
const MEASURED_PROCESS_RSS_MB = 4500;
|
||||
|
||||
const dockerfile = readFileSync(
|
||||
fileURLToPath(new URL("../../Dockerfile", import.meta.url)),
|
||||
"utf8"
|
||||
);
|
||||
const DOCKERFILES = [
|
||||
{ label: "Dockerfile", raw: readFileSync(fileURLToPath(new URL("../../Dockerfile", import.meta.url)), "utf8") },
|
||||
{ label: "Dockerfile.bun", raw: readFileSync(fileURLToPath(new URL("../../Dockerfile.bun", import.meta.url)), "utf8") },
|
||||
];
|
||||
|
||||
function readArgDefault(name: string): number {
|
||||
const match = dockerfile.match(new RegExp(`^ARG ${name}=(\\d+)$`, "m"));
|
||||
assert.ok(match, `Dockerfile no longer declares ARG ${name}`);
|
||||
function readArgDefault(name: string, label: string): number {
|
||||
const raw = DOCKERFILES.find((entry) => entry.label === label)!.raw;
|
||||
const match = raw.match(new RegExp(`^ARG ${name}=(\\d+)$`, "m"));
|
||||
assert.ok(match, `${label} no longer declares ARG ${name}`);
|
||||
return Number(match![1]);
|
||||
}
|
||||
|
||||
test("the Docker build's worker pool is derived from OMNIROUTE_BUILD_WORKERS", () => {
|
||||
// assert.ok(boolean), not assert.match — a failing assert.match dumps the
|
||||
// whole Dockerfile into the report.
|
||||
assert.ok(
|
||||
/^ENV CIRCLE_NODE_TOTAL=\$\{OMNIROUTE_BUILD_WORKERS\}$/m.test(dockerfile),
|
||||
"CIRCLE_NODE_TOTAL must stay wired to the build arg so a big builder can raise it"
|
||||
);
|
||||
assert.ok(
|
||||
/^ENV NODE_OPTIONS="--max-old-space-size=\$\{OMNIROUTE_BUILD_MEMORY_MB\}"$/m.test(dockerfile),
|
||||
"the build heap ceiling must stay wired to OMNIROUTE_BUILD_MEMORY_MB"
|
||||
);
|
||||
});
|
||||
for (const { label } of DOCKERFILES) {
|
||||
test(`the ${label} build's worker pool is derived from OMNIROUTE_BUILD_WORKERS`, () => {
|
||||
// assert.ok(boolean), not assert.match — a failing assert.match dumps the
|
||||
// whole Dockerfile into the report.
|
||||
const raw = DOCKERFILES.find((entry) => entry.label === label)!.raw;
|
||||
assert.ok(
|
||||
/^ENV CIRCLE_NODE_TOTAL=\$\{OMNIROUTE_BUILD_WORKERS\}$/m.test(raw),
|
||||
`${label}: CIRCLE_NODE_TOTAL must stay wired to the build arg so a big builder can raise it`
|
||||
);
|
||||
assert.ok(
|
||||
/^ENV NODE_OPTIONS="--max-old-space-size=\$\{OMNIROUTE_BUILD_MEMORY_MB\}"$/m.test(raw),
|
||||
`${label}: the build heap ceiling must stay wired to OMNIROUTE_BUILD_MEMORY_MB`
|
||||
);
|
||||
});
|
||||
|
||||
test("worker count × per-process heap fits a 16 GB GitHub runner", () => {
|
||||
const workerPool = readArgDefault("OMNIROUTE_BUILD_WORKERS");
|
||||
const heapMb = readArgDefault("OMNIROUTE_BUILD_MEMORY_MB");
|
||||
test(`worker count × measured per-process RSS fits a 16 GB GitHub runner (${label})`, () => {
|
||||
const workerPool = readArgDefault("OMNIROUTE_BUILD_WORKERS", label);
|
||||
|
||||
// Next derives `workers = CIRCLE_NODE_TOTAL - 1`.
|
||||
const workers = workerPool - 1;
|
||||
assert.ok(workers >= 1, `CIRCLE_NODE_TOTAL=${workerPool} leaves no build workers`);
|
||||
// Next derives `workers = CIRCLE_NODE_TOTAL - 1`.
|
||||
const workers = workerPool - 1;
|
||||
assert.ok(workers >= 1, `${label}: CIRCLE_NODE_TOTAL=${workerPool} leaves no build workers`);
|
||||
|
||||
// The parent `next build` process is the one that genuinely needs the raised
|
||||
// ceiling (the webpack/turbopack production pass, #4076); the workers are
|
||||
// budgeted at their inferred peak instead.
|
||||
const worstCaseMb = heapMb + workers * WORKER_PEAK_MB;
|
||||
const budgetMb = RUNNER_MEMORY_MB * HEADROOM_FRACTION;
|
||||
assert.ok(
|
||||
worstCaseMb <= budgetMb,
|
||||
`parent ${heapMb} MB + ${workers} workers × ${WORKER_PEAK_MB} MB = ${worstCaseMb} MB ` +
|
||||
`exceeds the ${budgetMb} MB budget on a ${RUNNER_MEMORY_MB} MB runner — the Docker ` +
|
||||
`publish step dies with "ResourceExhausted: cannot allocate memory" during page-data ` +
|
||||
`collection`
|
||||
);
|
||||
});
|
||||
// Every process — the parent `next build` process AND each page-data
|
||||
// worker — is budgeted at the measured per-process RSS floor (see the file
|
||||
// banner comment). The V8 heap ceiling (OMNIROUTE_BUILD_MEMORY_MB) bounds
|
||||
// JS allocations but not Turbopack's native/Rust memory, so it cannot stand
|
||||
// in for the parent process's real RSS.
|
||||
const processes = workers + 1;
|
||||
const worstCaseMb = processes * MEASURED_PROCESS_RSS_MB;
|
||||
const budgetMb = RUNNER_MEMORY_MB * HEADROOM_FRACTION;
|
||||
assert.ok(
|
||||
worstCaseMb <= budgetMb,
|
||||
`${label}: ${processes} processes (1 parent + ${workers} workers) × ${MEASURED_PROCESS_RSS_MB} MB ` +
|
||||
`measured RSS = ${worstCaseMb} MB exceeds the ${budgetMb} MB budget on a ` +
|
||||
`${RUNNER_MEMORY_MB} MB runner — the Docker publish step dies with "ResourceExhausted: ` +
|
||||
`cannot allocate memory" during page-data collection`
|
||||
);
|
||||
});
|
||||
|
||||
test("the worker pool does not oversubscribe the runner's 4 vCPU", () => {
|
||||
const workers = readArgDefault("OMNIROUTE_BUILD_WORKERS") - 1;
|
||||
assert.ok(workers <= 4, `${workers} workers oversubscribe a 4 vCPU runner`);
|
||||
});
|
||||
test(`both images default to OMNIROUTE_BUILD_WORKERS=2 (1 page-data worker) (${label})`, () => {
|
||||
// At OMNIROUTE_BUILD_WORKERS=2 → CIRCLE_NODE_TOTAL=2 → Next derives 1
|
||||
// page-data worker, so 2 processes (parent + worker) × ~4.5 GB ≈ 9 GB fit
|
||||
// the 12.288 GB (75%) budget on a 16 GB runner with headroom. At =3 → 2
|
||||
// workers → 3 processes × 4.5 GB ≈ 13.5 GB, which exceeds it (measured
|
||||
// per-process RSS, #7518). Raising this default must re-do the budget
|
||||
// arithmetic and stay green on the test above (#11663).
|
||||
assert.equal(
|
||||
readArgDefault("OMNIROUTE_BUILD_WORKERS", label),
|
||||
2,
|
||||
`${label}: OMNIROUTE_BUILD_WORKERS must stay 2 (1 page-data worker)`
|
||||
);
|
||||
});
|
||||
|
||||
test(`the worker pool does not oversubscribe the runner's 4 vCPU (${label})`, () => {
|
||||
const workers = readArgDefault("OMNIROUTE_BUILD_WORKERS", label) - 1;
|
||||
assert.ok(workers <= 4, `${workers} workers oversubscribe a 4 vCPU runner`);
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
FATAL_LOG_PATTERNS,
|
||||
LINUX_EXECUTABLE_NAMES,
|
||||
stopApp,
|
||||
waitForDatabaseOpen,
|
||||
DB_TOUCH_PATH,
|
||||
} from "../../scripts/dev/smoke-electron-packaged.mjs";
|
||||
|
||||
test("electron smoke discovers the default Linux executable name", () => {
|
||||
@@ -94,6 +96,46 @@ test("electron smoke flags a cold-restart fallback to the sql.js WASM driver", (
|
||||
test("electron smoke flags startup logs missing any driver selection line", () => {
|
||||
assert.throws(
|
||||
() => assertNativeDriverSelected("[electron] [server] listening on 20128"),
|
||||
/no '\[DB\] Driver: \.\.\.' line/
|
||||
/no database activity/
|
||||
);
|
||||
});
|
||||
|
||||
test("electron smoke waits for database-open evidence after touching a DB-backed endpoint", async () => {
|
||||
assert.equal(DB_TOUCH_PATH, "/api/monitoring/health");
|
||||
let logs = "[electron] [Server] [STARTUP] ready\n";
|
||||
setTimeout(() => {
|
||||
logs += "[electron] [Server] [DB] Added usage_history.combo_strategy column\n";
|
||||
}, 60);
|
||||
const seen = await waitForDatabaseOpen(() => logs, { timeoutMs: 2_000, pollMs: 20 });
|
||||
assert.match(seen, /\[DB\] Added/);
|
||||
});
|
||||
|
||||
test("electron smoke fails clearly when the database never opens", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
waitForDatabaseOpen(() => "[electron] [Server] [STARTUP] ready\n", {
|
||||
timeoutMs: 120,
|
||||
pollMs: 20,
|
||||
}),
|
||||
/logged no \[DB\]\/\[Migration\] startup line within 120ms/
|
||||
);
|
||||
});
|
||||
|
||||
test("electron smoke driver guard: native line, DB evidence and sql.js fallback", () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertNativeDriverSelected("[DB] Driver: better-sqlite3 | file: /tmp/x/storage.sqlite\n")
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
assertNativeDriverSelected(
|
||||
"[electron] [Server] [DB] Added call_logs.session_tag column\n[electron] [Server] [Migration] Applied: 046_database_settings\n"
|
||||
)
|
||||
);
|
||||
assert.throws(
|
||||
() => assertNativeDriverSelected("[DB] Driver: sql.js | file: /tmp/x/storage.sqlite\n"),
|
||||
/sql\.js \(WASM\) driver/
|
||||
);
|
||||
assert.throws(
|
||||
() => assertNativeDriverSelected("[STARTUP] nothing here\n"),
|
||||
/no database activity/
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user