Skip to content

Webview extensions blank on Safari/WebKit (vscode.dev / code-server / code serve-web): VSBuffer.slice returns view that gets detached by extension-host postMessage transfer #316841

@dop-amine

Description

@dop-amine

Does this issue occur when all extensions are disabled?: Yes

VS Code Version

Reproduced against code-server 4.106.x (vendoring vscode 1.107.x). Bug exists in current microsoft/vscode@main per source inspection of src/vs/base/common/buffer.ts and src/vs/base/parts/ipc/common/ipc.net.ts. Per the introducing commit (#76076) it has been present since 2019.

OS Version

  • Server: Ubuntu 24.04, Node 20.x
  • Clients (reproduces): Safari 18 on macOS 15, Safari 18 on iPadOS 18, Chrome / Brave / Firefox on iOS (all forced WebKit by App Store policy)
  • Clients (does not reproduce): Chrome / Chromium / Brave / Edge on macOS / Linux / Windows (V8), Firefox desktop (SpiderMonkey)

Does this reproduce in native VS Code desktop?

No. Native VS Code (Electron) uses Electron's IPC layer between renderer and extension host — not browser postMessage with transfer lists for VSBuffer payloads. The underlying ArrayBuffer is never detached in that flow, so ChunkStream's held subarray views stay valid and VSBuffer.slice's view-returning behavior is correct. The bug is specifically a browser-mode VS Code issue.

Does this reproduce in code serve-web?

Yes (architecturally identical, not directly stood up). code serve-web ships the same VSBuffer.slice code path and the same browser-based web-worker extension host. Any Safari / iOS client of code serve-web should hit it identically. Reproduces deterministically in code-server, which vendors the same upstream code verbatim.

Does this reproduce in vscode.dev?

Likely yes. I have not directly tested in Safari since vscode.dev is harder to drive a webview-heavy extension on (the marketplace surface is narrower). Long-standing issue #213143 ("Safari 14 no longer able to load vscode.dev") and adjacent reports point in the same direction. Same upstream code path.

Does this reproduce in GitHub Codespaces?

Likely yes (Codespaces uses VS Code web). Not directly tested.

Steps to Reproduce

  1. Run any browser-mode VS Code (vscode.dev, code serve-web, code-server, Codespaces) over HTTPS — service workers must register.
  2. Activate any extension that delivers initial webview content as multi-chunk binary IPC frames. Reliable repro: Claude Code (Anthropic.claude-code version 2.1.77 specifically; newer versions hit an unrelated Anthropic-side regression that masks this bug). Alternative repros that hit the same code path: vscode-pdf (open a PDF), .ipynb notebook renderers (open a notebook with rendered output), Live Preview.
  3. Open in Safari (macOS or iPadOS) or any iOS browser. Open Web Inspector → Console.
  4. Click the extension's activity-bar icon to open its webview panel.
  5. Panel stays permanently blank. Console fires the cascade below.
  6. Confirm it does NOT reproduce on V8: open the same URL in Chrome / Edge on macOS / Linux / Windows. Panel renders normally.

Errors from the Dev Tools Console

[Log] [Extension Host] Extension activated!
[Error] TypeError: Underlying ArrayBuffer has been detached from the view or out-of-bounds
  Uint8Array.prototype.slice
  VSBuffer.prototype.slice              (out/vs/base/common/buffer.js)
  ChunkStream._read                     (out/vs/base/parts/ipc/common/ipc.net.js)
  ProtocolReader.acceptChunk            (out/vs/base/parts/ipc/common/ipc.net.js)
  …

…fires once per IPC chunk on the management socket, then again on the extension-host socket. The cascade kills the channel; any subsequent IPC traffic for the extension is dead until reload.


Root cause

VSBuffer.prototype.slice (src/vs/base/common/buffer.ts) currently returns:

public slice(start?: number, end?: number): VSBuffer {
    return new VSBuffer(this.buffer.subarray(start, end));
}

This is a view into the parent ArrayBuffer, not a copy — a deliberate perf optimization from #76076. The new VSBuffer's .buffer.buffer === this.buffer.buffer.

In vs/base/parts/ipc/common/ipc.net.ts, ChunkStream queues incoming VSBuffer chunks and uses .slice() to extract messages:

private _read(byteCount: number, advance: boolean): VSBuffer {
    
    if (this._chunks[0].byteLength > byteCount) {
        const result = this._chunks[0].slice(0, byteCount);         // ← view
        if (advance) {
            this._chunks[0] = this._chunks[0].slice(byteCount);     // ← view
            
        }
        return result;
    }
    
}

Both result and the residual chunk are views over the same ArrayBuffer that arrived in the WebSocket binary frame.

Downstream, the extracted message is delivered to the web-worker extension host iframe via postMessage(msg, [transferList]). The transfer list detaches the underlying ArrayBuffer in the originating window.

ChunkStream still holds chunks whose .buffer is now a view into a detached ArrayBuffer. The next read calls Uint8Array.prototype.slice on that view.

  • V8 (Chromium / Node / Electron) is permissive about this. Historically TypedArray.prototype.slice on a view of a detached buffer returned an empty buffer; modern V8 throws, but the timing of the detach vs. the read is loose enough that the bug rarely fires in practice. This is why the bug is essentially invisible on every desktop browser and on Electron.
  • JavaScriptCore (Safari, iOS browsers) throws synchronously and unconditionally per TC39 ECMA-262 §25.1.2.1 (IsDetachedBuffer). The IPC reader unwinds, handleUnexpectedError fires, the channel is dead.

The detach-then-read race only fires for messages that need to be sliced (multi-chunk read) AND get transferred immediately downstream (webview content delivery). Routine editor traffic is single-chunk and short-lived, so it never hits the race. Webview-heavy extensions hit it on their very first frame.


Candidate fixes (ranked by blast radius)

A. Make VSBuffer.slice copy unconditionally (simplest, broadest)

 public slice(start?: number, end?: number): VSBuffer {
-    return new VSBuffer(this.buffer.subarray(start, end));
+    return new VSBuffer(this.buffer.slice(start, end));
 }
  • Pros: one-character source change; semantics-correct in all environments; fully reversible; restores the pre-Performance issue with VSBuffer#slice #76076 contract.
  • Cons: walks back the perf optimization in all environments, including desktop Electron where the bug is invisible. Cost: one memcpy per VSBuffer.slice call. Sub-kilobyte typical IPC message sizes; in benchmarks against a sustained extension-host workload (Claude Code session, multiple notebook renderers) I haven't been able to measure the regression above noise, but I'm flagging it.

I'd open this as a Draft RFC PR alongside this issue. If maintainers prefer B or C below, easy to rewrite.

B. Make VSBuffer.slice copy only in web/browser bundles (perf-preserving)

Use the existing browser/common/node source layout to split implementations:

// vs/base/common/buffer.ts (shared interface)
public slice(start?: number, end?: number): VSBuffer {
    return platform === 'browser'
        ? new VSBuffer(this.buffer.slice(start, end))
        : new VSBuffer(this.buffer.subarray(start, end));
}

(Or via a build-target conditional that swaps the implementation file.)

  • Pros: zero perf impact on desktop Electron where the bug is invisible; only the affected platform pays the copy cost.
  • Cons: introduces platform-conditional semantics for a primitive that should arguably behave the same everywhere. Asymmetric semantics is a footgun for future contributors.

C. Copy at the IPC reader, not in VSBuffer.slice (most surgical)

Have ChunkStream._read produce owned buffers regardless of slice semantics:

// vs/base/parts/ipc/common/ipc.net.ts
private _read(byteCount: number, advance: boolean): VSBuffer {
    
    if (this._chunks[0].byteLength > byteCount) {
        const view = this._chunks[0].slice(0, byteCount);
        const result = VSBuffer.alloc(view.byteLength);
        result.set(view, 0);                                    // copy
        if (advance) {
            const tail = this._chunks[0].slice(byteCount);
            const tailCopy = VSBuffer.alloc(tail.byteLength);
            tailCopy.set(tail, 0);
            this._chunks[0] = tailCopy;
            
        }
        return result;
    }
    
}
  • Pros: minimum semantic blast radius; the rest of the codebase keeps the view-returning slice for everywhere else.
  • Cons: more code; doesn't fix the same class of bug if it surfaces in any other code path that holds a view across a transferable boundary (and there are several such paths — anywhere a VSBuffer is held while another caller might post the original).

I believe (C) is partial: it cures the symptom in the IPC reader specifically, but the underlying contract violation (slice returns a view that can outlive its source's lifecycle) remains lurking elsewhere. Fine if maintainers want the minimum change; (A) is the more durable fix.


Verifying the fix

A regression test that fails before (A) and passes after, suitable for src/vs/base/test/common/buffer.test.ts:

test('VSBuffer#slice returns an independent buffer that survives detachment of the source', () => {
    // VSBuffer instances are typically views into a larger ArrayBuffer
    // carrying IPC payload. Once a slice is extracted, downstream code may
    // transfer the original buffer to another execution context (e.g. an
    // extension-host iframe via postMessage). On WebKit (JavaScriptCore),
    // accessing any view into a detached ArrayBuffer throws synchronously
    // per ECMA-262 §25.1.2.1.
    //
    // VSBuffer#slice must therefore return a buffer that owns its data, not
    // a view into the source. This test exercises that contract by detaching
    // the source after slicing and asserting the slice is still readable.
    const source = VSBuffer.alloc(1024);
    source.buffer[0] = 42;
    const slice = source.slice(0, 512);

    // Detach the source's underlying ArrayBuffer.
    source.buffer.buffer.transfer();
    assert.strictEqual(source.buffer.byteLength, 0,
        'source.buffer should be detached');

    // The slice must still be readable — it owns its own storage.
    assert.strictEqual(slice.byteLength, 512);
    assert.strictEqual(slice.buffer[0], 42);
});

ArrayBuffer.prototype.transfer() is in V8 since Chrome 114 (May 2023) and Node 21.7. If the runtime VS Code targets for unit tests is older, the test can use structuredClone(buf, { transfer: [buf] }) or MessageChannel.postMessage(_, [buf]) instead, with the same semantic effect.


Production validation

We've been running fix (A) — applied as a one-character sed to the bundled workbench.js post-install — in production across three independent code-server deployments for ~24h. Before the patch, every webview-heavy extension session on Safari/iPad fired the cascade and stayed blank. After the patch, all WebKit clients work normally with no observable side effects or perf regressions. The same patch on V8 clients is a no-op (Chromium was already working).

Patch site in the minified bundle (offset confirmed at column ~97282 of line ~406 in out/vs/code/browser/workbench/workbench.js for code-server's vendored 1.107.x build):

-slice(i,e){return new a(this.buffer.subarray(i,e))}
+slice(i,e){return new a(this.buffer.slice(i,e))}

If maintainers want a self-contained Docker Compose reproducer (code-server + minimal extension + the broken-on-WebKit state vs. the patched-and-working state, with HAR captures), I'm happy to set one up.


Why this matters

Every Safari and iOS user of browser-based VS Code is currently locked out of every webview-heavy extension. That includes:

  • vscode.dev users on Safari (long surface, hard to deterministically repro outside of a binary-protocol extension).
  • All code-server deployments — disproportionately the population that hits this, because code-server is browser-only and the iPad/iOS audience is large among self-hosted dev environments.
  • code serve-web and Codespaces users on Safari/iOS.

Adjacent issues over the past several years that all roll up to the same root cause but were filed and closed without the underlying mechanism being identified:

Downstream tracking: coder/code-server#7801. Draft PR for candidate fix (A) is open: #316842.

Draft PR for candidate fix (A) is up at #316842, opened as Draft to invite (B) / (C) feedback before un-drafting.

Metadata

Metadata

Assignees

Labels

bugIssue identified by VS Code Team member as probable bugcode-server-websafariIssues running VSCode in Web on Safarivscode.devIssues related to vscode.dev

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions