fix(web): unbreak linux CI — keepalive EINVAL and the bundle tripwire

The idle-heartbeat test failed deterministically on linux (not the
timing flake it was first read as): TCP_KEEPIDLE/TCP_KEEPINTVL have
whole-second granularity there, so the test config's 50ms heartbeat
truncated to zero and setsockopt returned EINVAL on both ends of the
handshake — a real runtime bug for any sub-second heartbeat config,
fixed by clamping only the kernel keepalive cadence to >= 1s.

The 6 MiB gzip tripwire was set when the bundle measured ~4.5 MiB;
feature growth since (collab, prompt center, templates) added ~4.5 MiB
of embedded product assets, ~2 MiB of it pre-compressed JPEG that gzip
passes through. Reset the tripwire to 8 MiB and document that it is a
regression guard, not a budget, with the asset-split TODO that brings
the bundle back down.
This commit is contained in:
Kayshen-X 2026-08-08 22:10:13 +08:00
parent f3ea6e8c99
commit 1030f5b131
5 changed files with 21 additions and 10 deletions

View file

@ -23,7 +23,7 @@ name: WASM bundle build (#56 — real canvaskit deployable)
# 2. wasm-bindgen --target web --out-dir crates/op-host-web/pkg <target.wasm>
# 3. assert 0 env.* imports (LinkError guard)
# 4. wasm-opt -Oz with the rustc-emitted WebAssembly feature flags, then
# gzip size <= ceiling (default 6291456 bytes = 6 MiB, overridable via
# gzip size <= ceiling (default 8388608 bytes = 8 MiB, overridable via
# STEP1B_SHELL_WASM_GZIP_LIMIT_BYTES)
# The job calls the script directly rather than duplicating that logic — the
# script already runs non-interactively (`set -euo pipefail`, exit 0/1/2) and
@ -118,7 +118,7 @@ jobs:
# cargo build (canvaskit) -> wasm-bindgen --target web -> 0-env-import
# assert -> wasm-opt -Oz with rustc's WebAssembly feature flags -> gzip
# size <= ceiling, and exits non-zero on any breach, which fails the job.
# The ceiling default (6 MiB gzip) lives in the script; override here only
# The ceiling default (8 MiB gzip) lives in the script; override here only
# if a release intentionally re-baselines.
- name: Build + size-gate the canvaskit bundle
run: bash tools/check-wasm-bundle.sh

View file

@ -1,7 +1,7 @@
name: op-web-sdk bundle build + size gate
# Builds the op-web-sdk WASM package (canvaskit feature), asserts 0 env.*
# imports and gzip size <= 6 MiB, then uploads pkg/ as the `op-web-sdk-bundle`
# imports and gzip size <= 8 MiB, then uploads pkg/ as the `op-web-sdk-bundle`
# artifact. This is the CI counterpart of the local
# `crates/op-web-sdk/tools/build-wasm.sh` developer gate.
#
@ -19,7 +19,7 @@ name: op-web-sdk bundle build + size gate
# 2. wasm-bindgen --target web --out-dir crates/op-web-sdk/pkg <target.wasm>
# 3. assert 0 env.* imports (LinkError guard)
# 4. wasm-opt -Oz with rustc-emitted WebAssembly feature flags, then
# gzip size <= ceiling (default 6291456 bytes = 6 MiB, overridable via
# gzip size <= ceiling (default 8388608 bytes = 8 MiB, overridable via
# OP_WEB_SDK_WASM_GZIP_LIMIT_BYTES)
# The job calls the script directly — it is the single source of truth for the
# gate logic.

View file

@ -66,7 +66,7 @@ a local `parse_hex` / `escape_json` again.
- **Max 800 lines per file — zero violations workspace-wide.** As of `d2d8104c` no `.rs` file in `crates/` exceeds the cap, and the sibling-module split is the universal shape: a spine keeps the public surface and `mod` declarations, cohesive clusters move into siblings, and re-exports keep every import path and test name stable. Splits are pure code motion — when you split, do not also change behaviour. Test modules follow the same rule (`foo_tests.rs`, or a `foo/tests/` directory when the tests themselves outgrow the cap). Check with `find crates -name '*.rs' -exec wc -l {} + | awk '$1>800'`.
- **Blocking on a future from sync host code goes through `op_host_services::chat_runtime::block_on_anywhere`.** A bare `Runtime::block_on` (or a privately-built current-thread runtime) aborts with "runtime within runtime" when the caller happens to sit on a tokio worker. `block_on_anywhere` picks the safe strategy for whichever context it is called from; it is the only sanctioned entry point and is exercised by tests for the no-runtime, multi-thread-worker, and borrowing-non-`Send`-future cases.
- **Fallible paths carry typed error enums, not `String`.** The whole workspace is converted (80+ enums; `Result<_, String>` survives at exactly two documented boundary sites). Find the domain's enum in its `*_error.rs` / `error.rs` sibling module (e.g. `CliError`, `ProgramError`, `WebCanvasError`, `McpServeError`, `ExportError`, `McpLiveError`, `DocIoError`, `ImageGenerateError`). The pattern to copy: one enum per failure domain in its own sibling module, structured fields instead of pre-formatted text, a `Display` impl that reproduces the previous message **byte-identically** (so user-visible strings and their tests don't move), and `From` impls that collapse the `map_err` adapters at the call sites. New fallible code should introduce or reuse an enum rather than add another `Result<_, String>`.
- **Web bundle ceiling: 6 MiB gzip + 0 env.\* imports.** Enforced by `tools/check-wasm-bundle.sh`. The CanvasKit bundle carries the full app logic (codegen AI pipeline, Figma parser, AI/live-sync), so the ceiling sits well above the retired skia raster path's 1 MiB (~4.5 MiB today). The 0 env.\* guard still holds — CanvasKit needs no libc shim.
- **Web bundle ceiling: 8 MiB gzip + 0 env.\* imports.** Enforced by `tools/check-wasm-bundle.sh`. The CanvasKit bundle carries the full app logic (codegen AI pipeline, Figma parser, AI/live-sync, collaboration) plus ~4.5 MiB of embedded product assets (template documents, preview JPEGs, iconify catalog, skill corpus), so the ceiling sits well above the retired skia raster path's 1 MiB (~7.5 MiB today; the ceiling is a runaway-regression tripwire, not a budget). The 0 env.\* guard still holds — CanvasKit needs no libc shim.
## Document model (`shell-core/src/document/`)

View file

@ -345,9 +345,15 @@ fn configure_tcp_common(stream: &TcpStream, config: TransportConfig) -> Result<(
stream.set_nodelay(true)?;
let socket = SockRef::from(stream);
socket.set_keepalive(true)?;
// Linux TCP_KEEPIDLE/TCP_KEEPINTVL have whole-second granularity and
// reject zero, so a sub-second heartbeat (test configs use 50ms) must
// not truncate to 0 or setsockopt fails with EINVAL. Application-level
// heartbeats still run at the configured cadence; only the kernel
// keepalive probes are clamped.
let keepalive_cadence = config.timeouts.heartbeat.max(Duration::from_secs(1));
let keepalive = TcpKeepalive::new()
.with_time(config.timeouts.heartbeat)
.with_interval(config.timeouts.heartbeat);
.with_time(keepalive_cadence)
.with_interval(keepalive_cadence);
socket.set_tcp_keepalive(&keepalive)?;
Ok(())
}

View file

@ -13,7 +13,7 @@
# the wasm-bindgen JS shim). Any env.* import = LinkError at
# load time → regression → fail.
# 4. Post wasm-opt -Oz gzip size ≤ STEP1B_SHELL_WASM_GZIP_LIMIT_BYTES
# (default 6 291 456 bytes = 6 MiB for the full CanvasKit app logic).
# (default 8 388 608 bytes = 8 MiB for the full CanvasKit app logic).
#
# This script is the local counterpart to
# `.github/workflows/wasm-bundle-build.yml`; keep the two recipes aligned.
@ -47,9 +47,14 @@ WASM_OPT_CANDIDATE_FEATURES=(
# Ceiling for the CanvasKit production bundle's gzipped wasm. It is far above
# the retired skia raster path's 1 MiB (spec §6) because this bundle now carries
# the FULL app logic absorbed from the skia path (codegen AI pipeline, Figma
# parser, AI/live-sync). ~4.5 MiB today. TODO(perf): code-split / lazy-load the
# parser, AI/live-sync, collaboration) plus ~4.5 MiB of embedded product
# assets — scene-template .op documents, the prompt-center/template preview
# JPEGs (already compressed, so gzip passes them through), the iconify
# catalog, and the AI skill corpus. ~7.5 MiB today; the ceiling is a
# runaway-regression tripwire, not a budget. TODO(perf): serve the preview
# JPEGs from the daemon instead of embedding, and code-split / lazy-load the
# codegen + Figma paths to shrink the initial download. Override via env.
LIMIT="${STEP1B_SHELL_WASM_GZIP_LIMIT_BYTES:-6291456}"
LIMIT="${STEP1B_SHELL_WASM_GZIP_LIMIT_BYTES:-8388608}"
step() { printf '\n[step %d/%d] %s\n' "$1" "$2" "$3"; }
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }