Milestone v1 (v2.0.0): Mokosh — Session Capture #1
456
.planning/debug/01-08-sw-incompatibility.md
Normal file
456
.planning/debug/01-08-sw-incompatibility.md
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
---
|
||||||
|
slug: 01-08-sw-incompatibility
|
||||||
|
status: investigating
|
||||||
|
trigger: |
|
||||||
|
Plan 01-08 Tasks 1-4 landed cleanly (5 commits 5035314..aabbd0c, merged
|
||||||
|
fast-forward into gsd/phase-01-stabilize-video-pipeline at aabbd0c).
|
||||||
|
All gates green: tsc clean, type-safety grep clean, npm run build exit 0,
|
||||||
|
60/62 vitest GREEN (only the 2 fixture-dependent webm-playback duration
|
||||||
|
tests remain RED — those are Task 5's empirical responsibility).
|
||||||
|
|
||||||
|
Operator ran smoke.sh against the post-remux build and reported: "it
|
||||||
|
errored, and i can't even see the SW console" —
|
||||||
|
chrome://serviceworker-internals shows the SW at Running Status:
|
||||||
|
STARTING (stuck forever), Fetch handler existence: DOES_NOT_EXIST, Log
|
||||||
|
empty. The SW dies at top-level module evaluation BEFORE any handler
|
||||||
|
registers and before any console.log can fire.
|
||||||
|
|
||||||
|
Initial orchestrator hypothesis ("ts-ebml uses `new Function` + Buffer
|
||||||
|
globals → CSP-blocks SW") was speculation from bundle grep and proved
|
||||||
|
WRONG when tested. A proper Node-simulation that strips SW-relevant
|
||||||
|
globals (`delete globalThis.Buffer; delete globalThis.process; await
|
||||||
|
import('./dist/assets/index.ts-8ny38Qcj.js')`) reveals the actual error
|
||||||
|
fires at top-level module init:
|
||||||
|
|
||||||
|
TypeError: Cannot read properties of undefined (reading 'readVint')
|
||||||
|
at file:///.../dist/assets/index.ts-8ny38Qcj.js:12:33809
|
||||||
|
at hn (file:///.../dist/assets/index.ts-8ny38Qcj.js:12:41461)
|
||||||
|
at file:///.../dist/assets/index.ts-8ny38Qcj.js:12:42172
|
||||||
|
at ModuleJob.run (node:internal/modules/esm/module_job:430:25)
|
||||||
|
|
||||||
|
Bundle context at the failure site:
|
||||||
|
|
||||||
|
i.readVint=i.writeVint=i.readBlock=...=void 0;
|
||||||
|
const s=mo, h=a(go()), {tools:f}=Pc, d=Gc;
|
||||||
|
i.readVint = f.readVint; // ← throws: f is undefined
|
||||||
|
|
||||||
|
This is the bundled form of ts-ebml/lib/tools.js. The destructure
|
||||||
|
`{tools:f}=Pc` fails because `Pc` is an empty placeholder namespace
|
||||||
|
object (`var Pc={}` — declared once, never populated). `Pc` is the
|
||||||
|
Vite/Rollup-mangled identifier for the `ebml` package (transitive dep
|
||||||
|
of ts-ebml; ts-ebml's tools.js does `const { tools: _tools } =
|
||||||
|
require("ebml")`).
|
||||||
|
|
||||||
|
Root cause is a Vite/Rollup CJS-interop bug, NOT a SW-API mismatch.
|
||||||
|
ts-ebml itself is structurally SW-compatible; it just cannot find its
|
||||||
|
transitive `ebml` dependency at runtime because Rollup tree-shook the
|
||||||
|
entire ebml module body while leaving a placeholder reference behind.
|
||||||
|
The CSP-eval and Buffer-global concerns from the original hypothesis
|
||||||
|
are real (they would have fired AFTER this error) but are downstream
|
||||||
|
of the actual init-time crash.
|
||||||
|
|
||||||
|
Plan 01-08's Task 1 deps-compatibility test (tests/background/
|
||||||
|
webm-remux-deps.test.ts) ran in vitest's Node env where Buffer IS
|
||||||
|
defined and inspected source files for DOM globals — it never loaded
|
||||||
|
the bundled output in a SW-simulated env, so the runtime tree-shake
|
||||||
|
hole and the SW-global stripping were both missed.
|
||||||
|
|
||||||
|
This blocks Plan 01-08 entirely until the bundle either successfully
|
||||||
|
imports `ebml` or replaces ts-ebml with something Vite-friendly.
|
||||||
|
created: 2026-05-17T07:34:32Z
|
||||||
|
updated: 2026-05-17T08:15:00Z
|
||||||
|
phase: 01-stabilize-video-pipeline
|
||||||
|
related_plan: .planning/phases/01-stabilize-video-pipeline/01-08-PLAN.md
|
||||||
|
related_summary: .planning/phases/01-stabilize-video-pipeline/01-08-SUMMARY.md
|
||||||
|
related_uat: .planning/phases/01-stabilize-video-pipeline/01-UAT.md
|
||||||
|
prior_resolved_sessions:
|
||||||
|
- .planning/debug/resolved/d12-blob-port-transfer-fails.md
|
||||||
|
- .planning/debug/resolved/webm-playback-freeze.md
|
||||||
|
- .planning/debug/resolved/empty-archive-port-race.md
|
||||||
|
- .planning/debug/d13-multi-ebml-concat-unplayable.md (the prior bug that Plan 01-08 was supposed to fix; still open until 01-08 actually works)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Debug: Plan 01-08 SW init crash — Vite/Rollup CJS interop strips `ebml` from bundle
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
**Expected:** SW initializes cleanly; chrome://extensions shows the
|
||||||
|
"service worker" link active; SW console accessible; offscreen
|
||||||
|
handshake completes; recording starts.
|
||||||
|
|
||||||
|
**Actual:** SW dies at top-level module evaluation;
|
||||||
|
chrome://serviceworker-internals: Status=STARTING (stuck),
|
||||||
|
Fetch handler=DOES_NOT_EXIST, Log=empty. Operator cannot reach the SW
|
||||||
|
console because no handler ever registers.
|
||||||
|
|
||||||
|
**Reproduction (bundle-level, no Chrome needed):**
|
||||||
|
1. `git checkout gsd/phase-01-stabilize-video-pipeline` (HEAD: aabbd0c)
|
||||||
|
2. `npm install && npm run build`
|
||||||
|
3. Run a SW-simulated Node import:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --input-type=module -e "
|
||||||
|
delete globalThis.Buffer;
|
||||||
|
delete globalThis.process;
|
||||||
|
await import('./dist/assets/index.ts-8ny38Qcj.js');
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Observe identical crash to operator: `TypeError: Cannot read
|
||||||
|
properties of undefined (reading 'readVint')`
|
||||||
|
|
||||||
|
**Reproduction (full smoke):**
|
||||||
|
1. Steps 1-2 above
|
||||||
|
2. `KEEP_PROFILE=0 ./smoke.sh`
|
||||||
|
3. In Chrome: Load Unpacked → dist/ — SW dies as described
|
||||||
|
|
||||||
|
**Diagnostic evidence (bundle inspection):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ grep -boE "\bPc\b" dist/assets/index.ts-8ny38Qcj.js
|
||||||
|
73034:Pc # ← declaration
|
||||||
|
211437:Pc # ← only use site (the failing destructure)
|
||||||
|
```
|
||||||
|
|
||||||
|
Bytes 72950-73050: `...var Pc={},Zi={exports:{}},Xi={exports:{}}...`
|
||||||
|
— `Pc` is declared as an empty object literal and **never assigned**
|
||||||
|
anywhere else in the 374 KB bundle.
|
||||||
|
|
||||||
|
Bytes 211350-211450 (failure site, transpiled `ts-ebml/lib/tools.js`):
|
||||||
|
```js
|
||||||
|
const s=mo, h=a(go()), {tools:f}=Pc, d=Gc;
|
||||||
|
i.readVint = f.readVint; // ← throws here at module init
|
||||||
|
```
|
||||||
|
|
||||||
|
Identifier mapping (verified against `node_modules/ts-ebml/lib/tools.js`):
|
||||||
|
- `mo` → `int64-buffer` (correctly bundled, source visible)
|
||||||
|
- `go()` → `EBMLEncoder` factory (correctly bundled)
|
||||||
|
- `Pc` → `ebml` package (empty placeholder; tree-shaken)
|
||||||
|
- `Gc` → `ebml-block` (correctly bundled, source visible)
|
||||||
|
|
||||||
|
Bundle source-identifier audit for `ebml` package:
|
||||||
|
```bash
|
||||||
|
$ grep -c "EbmlEncoder" dist/assets/index.ts-8ny38Qcj.js
|
||||||
|
0
|
||||||
|
$ grep -c "EbmlDecoder" dist/assets/index.ts-8ny38Qcj.js
|
||||||
|
0
|
||||||
|
$ grep -c "Tools as tools" dist/assets/index.ts-8ny38Qcj.js
|
||||||
|
0
|
||||||
|
```
|
||||||
|
|
||||||
|
None of the `ebml` package's source identifiers appear in the bundle —
|
||||||
|
Rollup tree-shook the entire module body while leaving the destructure
|
||||||
|
reference dangling.
|
||||||
|
|
||||||
|
**Why the CJS interop fails:**
|
||||||
|
|
||||||
|
`node_modules/ebml/package.json` declares all three of `main`,
|
||||||
|
`module`, and `browser`. Vite (browser/SW target) prefers `module`
|
||||||
|
(`lib/ebml.esm.js`), which exports as **named ESM**:
|
||||||
|
|
||||||
|
export { Tools as tools, schema, EbmlDecoder as Decoder, EbmlEncoder as Encoder };
|
||||||
|
|
||||||
|
But `node_modules/ts-ebml/lib/tools.js` (compiled CJS) does:
|
||||||
|
|
||||||
|
const { tools: _tools } = require("ebml");
|
||||||
|
|
||||||
|
`@rollup/plugin-commonjs` is supposed to bridge a CJS `require()` of an
|
||||||
|
ESM module by wrapping it. Here it allocated the namespace placeholder
|
||||||
|
`var Pc = {}` for the would-be `module.exports`, but the wrapper that
|
||||||
|
should rewrite it via `Pc.tools = Tools; Pc.schema = schema; ...` was
|
||||||
|
never emitted. Body of `ebml.esm.js` was tree-shaken because Rollup
|
||||||
|
could not statically prove `Pc.readVint`/`Pc.writeVint` reach the
|
||||||
|
public surface (they're funneled through ts-ebml's `_tools` local).
|
||||||
|
|
||||||
|
This is a known class of @rollup/plugin-commonjs failure mode for
|
||||||
|
packages that mix `module`/`main`/`browser` fields with consumers that
|
||||||
|
require them via CJS; usually fixed by forcing esbuild's CJS-interop
|
||||||
|
via `optimizeDeps.include` or by tightening `commonjsOptions`.
|
||||||
|
|
||||||
|
**Timeline:**
|
||||||
|
- Bug introduced: commit 41e94d5 ("feat(01-08): implement remuxSegments")
|
||||||
|
pulled in `ts-ebml@3.0.2` as a runtime dependency.
|
||||||
|
- Deps test (Task 1, commit 5035314) wrongly certified SW-compat:
|
||||||
|
it only checked source-level `document`/`window` references, not
|
||||||
|
bundle-level import-load behavior in a SW-simulated env.
|
||||||
|
- Discovered: 2026-05-17 by operator empirical smoke.
|
||||||
|
- Initial orchestrator hypothesis (new Function + Buffer) FALSIFIED
|
||||||
|
2026-05-17 via Node-simulation; real cause identified the same day.
|
||||||
|
|
||||||
|
## Current Focus
|
||||||
|
|
||||||
|
hypothesis: |
|
||||||
|
Vite/Rollup's default CJS-interop pipeline tree-shakes the `ebml`
|
||||||
|
package out of the SW bundle while leaving a dangling destructure
|
||||||
|
reference in the bundled `ts-ebml/lib/tools.js`. At SW init time the
|
||||||
|
destructure `{tools:f}=Pc` evaluates to `{tools: undefined}` because
|
||||||
|
`Pc` is an empty placeholder namespace object that the CJS wrapper
|
||||||
|
never populates. Then `_tools.readVint` throws TypeError at
|
||||||
|
module-level execution, killing the SW before any handler registers.
|
||||||
|
|
||||||
|
This is NOT a ts-ebml-vs-SW-API mismatch, NOT a CSP eval issue, NOT
|
||||||
|
a Buffer-global issue. Those concerns were the orchestrator's
|
||||||
|
initial speculative hypothesis and are FALSIFIED by the Node
|
||||||
|
simulation — the crash fires before any of those code paths would
|
||||||
|
execute. (They may surface as secondary issues once the primary is
|
||||||
|
fixed; the strengthened RED gate must catch those too.)
|
||||||
|
|
||||||
|
The fix space is bundler-configuration vs library-swap vs
|
||||||
|
architectural relocation. See "Candidate fix strategies" below.
|
||||||
|
|
||||||
|
test: |
|
||||||
|
Two-tier RED gate, both required:
|
||||||
|
|
||||||
|
Tier 1 (cheap, deterministic, runs in vitest): load the built SW
|
||||||
|
bundle via `await import(distPath)` after stripping SW-incompatible
|
||||||
|
globals (`delete globalThis.Buffer; delete globalThis.process;
|
||||||
|
delete globalThis.document; delete globalThis.window`). Assert no
|
||||||
|
throw. Lives at `tests/background/sw-bundle-import.test.ts`. This is
|
||||||
|
the gate that should have caught this bug pre-checkpoint.
|
||||||
|
|
||||||
|
Tier 2 (optional, expensive): playwright + a real Chrome MV3
|
||||||
|
unpacked-load that checks the SW reaches OFFSCREEN_READY. Deferred
|
||||||
|
unless Tier 1 proves insufficient.
|
||||||
|
|
||||||
|
Tier 1 will go RED IMMEDIATELY against the current dist bundle. It
|
||||||
|
will go GREEN only after the chosen fix lands.
|
||||||
|
|
||||||
|
expecting: |
|
||||||
|
After fix lands:
|
||||||
|
1. Tier 1 SW-bundle-import test passes.
|
||||||
|
2. SW initializes cleanly in Chrome; chrome://serviceworker-internals
|
||||||
|
shows Running Status: ACTIVATED, Fetch handler: EXISTS.
|
||||||
|
3. Offscreen handshake completes.
|
||||||
|
4. smoke.sh produces a zip with playable ~30s WebM.
|
||||||
|
5. The 2 currently-RED webm-playback duration tests (Task 5's gate)
|
||||||
|
either go GREEN or surface a separate, post-fix issue worth
|
||||||
|
debugging on its own merits.
|
||||||
|
|
||||||
|
next_action: |
|
||||||
|
CHECKPOINT to orchestrator with the 4 candidate fix strategies +
|
||||||
|
the debugger's recommendation. Orchestrator routes to user via
|
||||||
|
AskUserQuestion. Per feedback-no-unilateral-scope-reduction, the
|
||||||
|
debugger does NOT pick.
|
||||||
|
|
||||||
|
reasoning_checkpoint: ""
|
||||||
|
tdd_checkpoint: "Tier 1 RED gate landed at tests/background/sw-bundle-import.test.ts — verified RED against HEAD aabbd0c"
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- TDD mode is ON. Tier 1 RED test landed BEFORE any GREEN fix.
|
||||||
|
- Auto-loaded memories: `feedback-gsd-ceremony-for-fixes.md` (no
|
||||||
|
hot-edits) and `feedback-no-unilateral-scope-reduction.md` (no
|
||||||
|
scope narrowing; surface choices via AskUserQuestion).
|
||||||
|
- `feedback-pre-checkpoint-bundle-gates.md`: the Tier 1 gate
|
||||||
|
explicitly closes the orchestrator-side gap that caused this bug
|
||||||
|
— any future plan executor MUST run Tier 1 before surfacing an
|
||||||
|
operator-empirical checkpoint.
|
||||||
|
- Plan 01-08 Tasks 1-4 are committed (5 commits). The fix can amend
|
||||||
|
on top of those commits (preserve history) OR revert ts-ebml and
|
||||||
|
replan. Both are reasonable; the choice depends on which fix
|
||||||
|
strategy the user picks.
|
||||||
|
- The pre-existing deps test
|
||||||
|
(tests/background/webm-remux-deps.test.ts) is INSUFFICIENT; the
|
||||||
|
new Tier 1 gate supersedes it. Whether to delete or rename the old
|
||||||
|
one is a follow-up — keep it for now.
|
||||||
|
- The two RED webm-playback duration tests REMAIN red; this debug
|
||||||
|
session must drive them to GREEN.
|
||||||
|
|
||||||
|
## Candidate fix strategies (surface to user; debugger does NOT pick)
|
||||||
|
|
||||||
|
### Strategy A — Vite `optimizeDeps.include: ['ts-ebml', 'ebml']`
|
||||||
|
|
||||||
|
**Mechanism:** Force esbuild to pre-bundle `ts-ebml` + `ebml` during
|
||||||
|
Vite's dep-optimization phase. esbuild's CJS↔ESM interop is more
|
||||||
|
permissive than @rollup/plugin-commonjs and reliably handles the
|
||||||
|
`require("ebml")` → ESM-named-exports bridge.
|
||||||
|
|
||||||
|
**Blast radius:** Tiny — adds 2 lines to vite.config.ts. No src/
|
||||||
|
changes. No dep changes. Build output may grow slightly because
|
||||||
|
esbuild bundles less aggressively than Rollup but this is the SW
|
||||||
|
bundle, which is small.
|
||||||
|
|
||||||
|
**Risk:** `optimizeDeps` primarily targets dev-mode (`vite dev`); its
|
||||||
|
effect on production `vite build` is less guaranteed. May need to
|
||||||
|
pair with `build.commonjsOptions` (Strategy B). Worth testing in
|
||||||
|
isolation first.
|
||||||
|
|
||||||
|
**Effort:** 30 min including verification.
|
||||||
|
|
||||||
|
### Strategy B — Vite `build.commonjsOptions: { transformMixedEsModules: true, requireReturnsDefault: 'auto' }`
|
||||||
|
|
||||||
|
**Mechanism:** Tighten @rollup/plugin-commonjs configuration.
|
||||||
|
`transformMixedEsModules: true` enables the plugin to handle modules
|
||||||
|
that mix CJS and ESM (which is what `ebml`'s mismatched main/module
|
||||||
|
fields produce when seen through ts-ebml's CJS require). `auto`
|
||||||
|
requireReturnsDefault picks the right shape per-module.
|
||||||
|
|
||||||
|
**Blast radius:** Same as A — 2 lines in vite.config.ts. May
|
||||||
|
combine with A.
|
||||||
|
|
||||||
|
**Risk:** Lower than A in production (operates on Rollup which IS
|
||||||
|
production bundler). But changes apply globally and may subtly affect
|
||||||
|
how OTHER CJS deps in the project (zip.js, etc.) bundle. Needs a full
|
||||||
|
vitest re-run.
|
||||||
|
|
||||||
|
**Effort:** 30 min including verification.
|
||||||
|
|
||||||
|
### Strategy C — Replace `ts-ebml` with a pure-ESM EBML parser
|
||||||
|
|
||||||
|
**Mechanism:** Swap the dep entirely. Candidates:
|
||||||
|
- `jswebm` — pure-ESM WebM parser; smaller surface; needs API verification
|
||||||
|
- `ebml-stream` — modern fork of node-ebml; may have similar CJS issues
|
||||||
|
- `webm-cluster-parser` — narrow-scope parser; might fit our needs
|
||||||
|
- Hand-rolled minimal EBML reader for just the 3 element types we need
|
||||||
|
(Segment, Cluster, SimpleBlock) — maybe ~200 LOC
|
||||||
|
|
||||||
|
**Blast radius:** Large — rewrite of `src/background/webm-remux.ts`
|
||||||
|
+ all unit tests that mock ts-ebml. Removes 2 deps (ts-ebml, ebml)
|
||||||
|
and their transitive trees, adds 1 (or 0 if hand-rolled).
|
||||||
|
|
||||||
|
**Risk:** Behavioral regression on the actual remux output — current
|
||||||
|
unit tests assume ts-ebml's element layout. Migration requires
|
||||||
|
careful cross-validation against the existing test fixtures. Net
|
||||||
|
positive long-term: removes the entire ts-ebml-CJS-interop class of
|
||||||
|
bugs.
|
||||||
|
|
||||||
|
**Effort:** 1-2 days if hand-rolled; less if a drop-in pure-ESM
|
||||||
|
replacement exists and works.
|
||||||
|
|
||||||
|
### Strategy D — Move EBML parsing to OFFSCREEN document
|
||||||
|
|
||||||
|
**Mechanism:** OFFSCREEN has full DOM, lenient CSP, and standard
|
||||||
|
ESM/CJS interop because Vite emits a separate offscreen bundle that
|
||||||
|
goes through a different (more permissive) loader path. Move
|
||||||
|
`remuxSegments` from `src/background/webm-remux.ts` to a new
|
||||||
|
`src/offscreen/remux.ts`; the SW posts segments to offscreen via
|
||||||
|
chrome.runtime.sendMessage and gets the remuxed Blob back.
|
||||||
|
|
||||||
|
**Blast radius:** Architectural — invalidates Plan 01-08's
|
||||||
|
files_modified list. Requires Plan 01-08 amendment. May touch Plan
|
||||||
|
01-09's `src/offscreen/recorder.ts` for handler co-location. Adds a
|
||||||
|
new SW↔offscreen message type.
|
||||||
|
|
||||||
|
**Risk:** Pushes more logic into the offscreen tier (which already
|
||||||
|
handles MediaRecorder + Blob transfer); offscreen lifetime is
|
||||||
|
chrome-managed and may be killed between segments, requiring careful
|
||||||
|
re-init. Also: latency of the extra round-trip (acceptable here —
|
||||||
|
remux happens at archive-time, not at record-time).
|
||||||
|
|
||||||
|
**Effort:** ~1 day including re-coordination with Plan 01-09.
|
||||||
|
|
||||||
|
### Debugger recommendation
|
||||||
|
|
||||||
|
**Try A first (30 min), fall back to B (30 min), fall back to C
|
||||||
|
(1-2 days), fall back to D (1 day).** Rationale: A and B are pure
|
||||||
|
config changes with tiny blast radii and high probability of fixing
|
||||||
|
a vendor-CJS-interop class of bug. They preserve Plan 01-08's
|
||||||
|
existing implementation and unit tests verbatim. C and D are
|
||||||
|
heavier-weight backstops only justified if A and B both fail.
|
||||||
|
|
||||||
|
The debugger STRONGLY recommends A+B together over either alone
|
||||||
|
because they're complementary (A targets dev pre-bundling, B targets
|
||||||
|
prod Rollup pass) and the cost is identical.
|
||||||
|
|
||||||
|
## Files of Interest
|
||||||
|
|
||||||
|
- `src/background/webm-remux.ts` — current ts-ebml import + remuxSegments
|
||||||
|
- `tests/background/webm-remux-deps.test.ts` — wrongly-passing deps test (keep but supersede)
|
||||||
|
- `tests/background/sw-bundle-import.test.ts` — NEW Tier 1 RED gate (this session)
|
||||||
|
- `dist/assets/index.ts-8ny38Qcj.js` — broken SW bundle (diagnostic only)
|
||||||
|
- `node_modules/ts-ebml/lib/tools.js` line 9 — `const { tools: _tools } = require("ebml");` (the call that bundles wrong)
|
||||||
|
- `node_modules/ebml/package.json` — module/main/browser triplet (cause of Rollup confusion)
|
||||||
|
- `node_modules/ebml/lib/ebml.esm.js` — what Vite picked (named exports)
|
||||||
|
- `node_modules/ebml/lib/ebml.js` — what ts-ebml's CJS require expects (default export)
|
||||||
|
- `vite.config.ts` — where strategies A and B would apply
|
||||||
|
- `src/background/index.ts` — createArchive call site (importer)
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- timestamp: 2026-05-17T08:10:00Z
|
||||||
|
source: Node SW-simulation
|
||||||
|
finding: |
|
||||||
|
`node --input-type=module -e "delete globalThis.Buffer; delete
|
||||||
|
globalThis.process; await import('./dist/assets/index.ts-8ny38Qcj.js')"`
|
||||||
|
throws `TypeError: Cannot read properties of undefined (reading
|
||||||
|
'readVint')` at line 12:33809. Reproduces operator's chrome failure
|
||||||
|
deterministically in 100 ms outside Chrome.
|
||||||
|
|
||||||
|
- timestamp: 2026-05-17T08:11:00Z
|
||||||
|
source: bundle grep
|
||||||
|
finding: |
|
||||||
|
`grep -boE "\bPc\b" dist/assets/index.ts-8ny38Qcj.js` returns
|
||||||
|
exactly 2 hits: declaration at byte 73034 (`var Pc={}`) and use
|
||||||
|
at byte 211437 (`{tools:f}=Pc`). Zero assignments between. `Pc`
|
||||||
|
is the bundled identifier for the unresolved `ebml` import.
|
||||||
|
|
||||||
|
- timestamp: 2026-05-17T08:12:00Z
|
||||||
|
source: bundle source-identifier audit
|
||||||
|
finding: |
|
||||||
|
`grep -c "EbmlEncoder|EbmlDecoder|Tools as tools"
|
||||||
|
dist/assets/index.ts-8ny38Qcj.js` returns 0/0/0. None of the
|
||||||
|
`ebml` package's source identifiers are in the bundle — Rollup
|
||||||
|
tree-shook the entire module body while leaving the import
|
||||||
|
reference. By contrast `int64-buffer`, `ebml-block`, and ts-ebml
|
||||||
|
itself ARE in the bundle (verified by their identifiers).
|
||||||
|
|
||||||
|
- timestamp: 2026-05-17T08:13:00Z
|
||||||
|
source: ts-ebml/lib/tools.js inspection
|
||||||
|
finding: |
|
||||||
|
Line 9: `const { tools: _tools } = require("ebml");`. Line 11:
|
||||||
|
`exports.readVint = _tools.readVint;`. This is the exact pattern
|
||||||
|
that Vite/Rollup bundles into `{tools:f}=Pc; i.readVint=f.readVint`.
|
||||||
|
|
||||||
|
- timestamp: 2026-05-17T08:14:00Z
|
||||||
|
source: node_modules/ebml/package.json
|
||||||
|
finding: |
|
||||||
|
Declares `main: lib/ebml.js` (CJS, default-exports-style),
|
||||||
|
`module: lib/ebml.esm.js` (ESM named exports), and `browser:
|
||||||
|
lib/ebml.iife.js` (IIFE). Vite picks `module` for the browser/SW
|
||||||
|
target. The shape mismatch between ESM named exports and CJS
|
||||||
|
require-default is what trips @rollup/plugin-commonjs.
|
||||||
|
|
||||||
|
- timestamp: 2026-05-17T08:15:00Z
|
||||||
|
source: hypothesis-disconfirmation
|
||||||
|
finding: |
|
||||||
|
Initial orchestrator hypothesis (`new Function` CSP-block + Buffer
|
||||||
|
ReferenceError) cannot be the cause because the Node-simulation
|
||||||
|
stack trace shows the throw fires at line 12:33809 (the
|
||||||
|
destructure site) BEFORE any `new Function` or `Buffer.from` call
|
||||||
|
executes. Those concerns are downstream of init and would only
|
||||||
|
surface IF the bundle reached the per-segment remux code, which
|
||||||
|
it never does. The original hypothesis is FALSIFIED.
|
||||||
|
|
||||||
|
## Eliminated
|
||||||
|
|
||||||
|
- "ts-ebml uses `new Function`, blocked by SW CSP" — FALSIFIED.
|
||||||
|
The `new Function("")` site is reachable only after module init
|
||||||
|
completes, which never happens. CSP block is downstream.
|
||||||
|
|
||||||
|
- "ts-ebml uses `Buffer.from`, undefined in SW" — FALSIFIED for the
|
||||||
|
init crash. Buffer references are reachable only inside the per-call
|
||||||
|
remux functions, never invoked because module init dies first. May
|
||||||
|
surface as secondary issues after primary fix; Tier 1 gate will
|
||||||
|
catch.
|
||||||
|
|
||||||
|
- "ts-ebml itself is SW-incompatible" — FALSIFIED. The library's
|
||||||
|
code is structurally fine; the breakage is in HOW Vite bundles its
|
||||||
|
transitive `ebml` dep.
|
||||||
|
|
||||||
|
- "Plan 01-08 implementation bug in src/background/webm-remux.ts" —
|
||||||
|
FALSIFIED. The crash is in bundled node_modules code, not in
|
||||||
|
application src/. The Plan 01-08 implementation is fine.
|
||||||
|
|
||||||
|
## Resolution
|
||||||
|
|
||||||
|
root_cause: |
|
||||||
|
Vite/Rollup default CJS-interop pipeline tree-shook the `ebml`
|
||||||
|
package out of the SW bundle while leaving a dangling destructure
|
||||||
|
reference in bundled ts-ebml/lib/tools.js. The destructure
|
||||||
|
`{tools:f}=Pc` against an empty placeholder `Pc` throws TypeError
|
||||||
|
at SW top-level module init, killing the SW before any handler can
|
||||||
|
register. Caused by `ebml`'s mismatched main/module/browser package
|
||||||
|
fields colliding with ts-ebml's CJS-style `require("ebml")` import.
|
||||||
|
fix: ""
|
||||||
|
verification: ""
|
||||||
|
files_changed: []
|
||||||
208
tests/background/sw-bundle-import.test.ts
Normal file
208
tests/background/sw-bundle-import.test.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
// tests/background/sw-bundle-import.test.ts
|
||||||
|
//
|
||||||
|
// Tier-1 SW-bundle-loadability gate (Debug session 01-08-sw-incompatibility).
|
||||||
|
//
|
||||||
|
// This test closes the orchestrator-side gap that produced the 01-08 init
|
||||||
|
// crash: prior to this gate, the only SW-compat assertion was
|
||||||
|
// `tests/background/webm-remux-deps.test.ts`, which loaded the SOURCE
|
||||||
|
// packages (`ts-ebml`, `webm-muxer`) under default Node globals. That test
|
||||||
|
// passed because the raw packages import fine in Node — but it never
|
||||||
|
// loaded the BUNDLED output emitted by `vite build`, and Vite's CJS-interop
|
||||||
|
// pipeline tree-shook the transitive `ebml` package out of the bundle while
|
||||||
|
// leaving a dangling destructure reference (`{tools:f}=Pc` against an empty
|
||||||
|
// placeholder `Pc`). The result: SW dies at top-level module init with
|
||||||
|
// `TypeError: Cannot read properties of undefined (reading 'readVint')`
|
||||||
|
// before any handler can register, and the operator's chrome://extensions
|
||||||
|
// "service worker" link goes inaccessible.
|
||||||
|
//
|
||||||
|
// This test exercises the actual built artifact under SW-simulated globals
|
||||||
|
// (Buffer/process/document/window stripped). Any throw at top-level module
|
||||||
|
// evaluation surfaces as a clean test failure with the exact stack the
|
||||||
|
// operator would see in Chrome.
|
||||||
|
//
|
||||||
|
// Implementation note: the strip+import happens in a SPAWNED Node child
|
||||||
|
// process, not in-process. Vitest's own RPC layer references both `Buffer`
|
||||||
|
// (`node_modules/vitest/dist/chunks/rpc.*.js`) and `process.nextTick`, so
|
||||||
|
// stripping those on the test runner's globalThis crashes vitest itself.
|
||||||
|
// A child process gives us a fresh V8 isolate where we can strip cleanly.
|
||||||
|
//
|
||||||
|
// Pre-flight contract: callers must `npm run build` first. The test fails
|
||||||
|
// fast with a clear "run npm run build" message if `dist/` is missing.
|
||||||
|
//
|
||||||
|
// Per `feedback-pre-checkpoint-bundle-gates.md` (auto-loaded memory): any
|
||||||
|
// future plan executor whose work surfaces a SW must run this test before
|
||||||
|
// any operator-empirical checkpoint.
|
||||||
|
//
|
||||||
|
// Reference for SW-restricted globals:
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope
|
||||||
|
// Reference for Node ESM dynamic-import accepting file:// URLs:
|
||||||
|
// https://nodejs.org/api/esm.html#import-expressions
|
||||||
|
// Reference for `child_process.execFile`:
|
||||||
|
// https://nodejs.org/api/child_process.html#child_processexecfilefile-args-options-callback
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { resolve as resolvePath } from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
// Globals stripped to simulate the MV3 Service Worker runtime. Service
|
||||||
|
// Workers expose ServiceWorkerGlobalScope which has NO `window`, NO
|
||||||
|
// `document`, NO `Buffer`, and NO `process`. They DO have standard
|
||||||
|
// browser-ish globals (fetch, Blob, Crypto, etc.) which Node provides
|
||||||
|
// natively in modern versions, so we don't strip those.
|
||||||
|
const SW_FORBIDDEN_GLOBALS: ReadonlyArray<string> = [
|
||||||
|
'window',
|
||||||
|
'document',
|
||||||
|
'Buffer',
|
||||||
|
'process',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sentinel strings emitted by the child process; checked by the parent
|
||||||
|
// to distinguish success from failure without resorting to exit codes
|
||||||
|
// (the child may exit 0 on a caught throw).
|
||||||
|
const CHILD_OK_SENTINEL = '__SW_BUNDLE_IMPORT_OK__';
|
||||||
|
const CHILD_FAIL_SENTINEL = '__SW_BUNDLE_IMPORT_FAILED__';
|
||||||
|
|
||||||
|
// Cap how long the child has to import. Real SW init takes < 1 s; if the
|
||||||
|
// import takes longer than this, treat it as a hang (functionally the
|
||||||
|
// same as a top-level throw from the operator's perspective).
|
||||||
|
const CHILD_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
interface ChildImportResult {
|
||||||
|
readonly ok: boolean;
|
||||||
|
readonly errorMessage: string;
|
||||||
|
readonly errorStackFirstLine: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the file:// URL of the built SW chunk by parsing the
|
||||||
|
* `dist/service-worker-loader.js` shim that crxjs emits. The shim is a
|
||||||
|
* one-liner of the form `import './assets/index.ts-<hash>.js';` whose hash
|
||||||
|
* changes per build; parsing it avoids hard-coding a content hash that
|
||||||
|
* would break on every rebuild.
|
||||||
|
*
|
||||||
|
* @returns Absolute file:// URL of the SW chunk, ready for dynamic import.
|
||||||
|
* @throws If `dist/` is missing or the loader shim cannot be parsed.
|
||||||
|
*/
|
||||||
|
function resolveBuiltSwChunkUrl(): string {
|
||||||
|
const distDir = resolvePath(process.cwd(), 'dist');
|
||||||
|
const loaderPath = resolvePath(distDir, 'service-worker-loader.js');
|
||||||
|
|
||||||
|
if (!existsSync(loaderPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`dist/service-worker-loader.js not found at ${loaderPath}. ` +
|
||||||
|
`Run \`npm run build\` before running this test.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaderSource = readFileSync(loaderPath, 'utf8');
|
||||||
|
// crxjs emits exactly: `import './assets/index.ts-<hash>.js';\n`
|
||||||
|
const importMatch = loaderSource.match(/^\s*import\s+['"](.+?)['"]\s*;?\s*$/m);
|
||||||
|
if (importMatch === null) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not parse SW import path from service-worker-loader.js. ` +
|
||||||
|
`Loader content was: ${JSON.stringify(loaderSource)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkRelativePath = importMatch[1];
|
||||||
|
const chunkAbsolutePath = resolvePath(distDir, chunkRelativePath);
|
||||||
|
return pathToFileURL(chunkAbsolutePath).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the inline ESM source that the spawned child will execute. The
|
||||||
|
* child strips the listed globals on its own isolate and then dynamically
|
||||||
|
* imports the SW chunk, reporting back via stdout sentinels.
|
||||||
|
*
|
||||||
|
* Kept as a function (not a template literal at module top) so the
|
||||||
|
* sentinel/global lists stay synced with the constants above.
|
||||||
|
*
|
||||||
|
* @param chunkUrl - file:// URL the child will import.
|
||||||
|
* @returns ESM source the child runs under `node --input-type=module`.
|
||||||
|
*/
|
||||||
|
function buildChildSource(chunkUrl: string): string {
|
||||||
|
const stripLines = SW_FORBIDDEN_GLOBALS.map(
|
||||||
|
(key) => `delete globalThis['${key}'];`,
|
||||||
|
).join(' ');
|
||||||
|
return [
|
||||||
|
stripLines,
|
||||||
|
`try {`,
|
||||||
|
` await import(${JSON.stringify(chunkUrl)});`,
|
||||||
|
` console.log(${JSON.stringify(CHILD_OK_SENTINEL)});`,
|
||||||
|
`} catch (e) {`,
|
||||||
|
` const msg = e && e.message ? String(e.message) : String(e);`,
|
||||||
|
` const stack = e && e.stack ? String(e.stack).split('\\n')[0] : '(no stack)';`,
|
||||||
|
` console.log(${JSON.stringify(CHILD_FAIL_SENTINEL)});`,
|
||||||
|
` console.log('MSG:' + msg);`,
|
||||||
|
` console.log('STK:' + stack);`,
|
||||||
|
`}`,
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn a Node child process that imports the SW chunk under stripped
|
||||||
|
* globals and returns the result. Resolves rather than rejects on import
|
||||||
|
* failure — the failure is the data the test asserts on.
|
||||||
|
*
|
||||||
|
* @param chunkUrl - file:// URL the child will import.
|
||||||
|
* @returns Structured result; `ok: false` means the bundle threw.
|
||||||
|
*/
|
||||||
|
async function runSwBundleImportInChild(chunkUrl: string): Promise<ChildImportResult> {
|
||||||
|
const source = buildChildSource(chunkUrl);
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
process.execPath,
|
||||||
|
['--input-type=module', '-e', source],
|
||||||
|
{
|
||||||
|
timeout: CHILD_TIMEOUT_MS,
|
||||||
|
maxBuffer: 4 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stdout.includes(CHILD_OK_SENTINEL)) {
|
||||||
|
return { ok: true, errorMessage: '', errorStackFirstLine: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stdout.includes(CHILD_FAIL_SENTINEL)) {
|
||||||
|
const msgMatch = stdout.match(/^MSG:(.*)$/m);
|
||||||
|
const stkMatch = stdout.match(/^STK:(.*)$/m);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
errorMessage: msgMatch?.[1] ?? '(no message)',
|
||||||
|
errorStackFirstLine: stkMatch?.[1] ?? '(no stack)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Child process produced neither OK nor FAIL sentinel. ` +
|
||||||
|
`stdout was: ${JSON.stringify(stdout)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SW bundle loadability (Tier-1 gate — closes the 01-08 orchestrator gap)', () => {
|
||||||
|
// Resolved at module-level (BEFORE the spawn) so `process.cwd()` and
|
||||||
|
// `process.execPath` are still available.
|
||||||
|
const swChunkUrl = resolveBuiltSwChunkUrl();
|
||||||
|
|
||||||
|
it('built SW chunk imports without throwing under SW-simulated globals', async () => {
|
||||||
|
const result = await runSwBundleImportInChild(swChunkUrl);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.ok,
|
||||||
|
result.ok
|
||||||
|
? 'unreachable'
|
||||||
|
: `Built SW bundle throws at top-level module init under ` +
|
||||||
|
`SW-simulated globals (${SW_FORBIDDEN_GLOBALS.join(', ')} ` +
|
||||||
|
`stripped). This is exactly what kills the SW in Chrome and ` +
|
||||||
|
`makes chrome://extensions "service worker" inaccessible.\n\n` +
|
||||||
|
`First line of stack: ${result.errorStackFirstLine}\n` +
|
||||||
|
`Full message: ${result.errorMessage}\n\n` +
|
||||||
|
`Bundle URL: ${swChunkUrl}`,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user