Skip to content

Commit f7e36bf

Browse files
committed
Piping suite: absorb special-buffer pipe writes (audit G9)
1 parent 8b301dc commit f7e36bf

8 files changed

Lines changed: 182 additions & 110 deletions

File tree

src/tests/streams/AGENTS.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,6 @@ configs' flag lists).
139139
- `src/workerd/api/tests/ts-webstreams-test.js` — TypeScript-impl
140140
internals (native/buffer/iterable bodies ARE ts streams, pumpTo);
141141
single-implementation by nature.
142-
- `src/workerd/api/tests/pipe-write-special-buffer-test.js`
143-
SharedArrayBuffer/resizable-buffer pipe writes (special env).
144142
- Security regression singles in `src/workerd/api/tests/`
145143
(streams-byob-close-reentry, streams-byob-concurrent-readatleast,
146144
streams-byte-cancel-uaf, streams-byte-handlePush-uaf,

src/tests/streams/piping/AGENTS.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ a deliberate defect pin, not a hole).
2828
| 8 | dest controller error()s while the pipe waits on a read | HALF-PROPAGATES: cancels the source with the error but FULFILLS the pipe promise | rejects the pipe and cancels the source with the error (spec) | `destControllerErrorsMidPipe` |
2929
| 9 | FixedLengthStream length violations via pipe | overflow: pipe NEVER SETTLES (bounded); underflow: never settles | overflow: rejects RangeError; underflow: never settles (parity of nonconformance) | `fixedLengthStreamPipeOverflow`/`Underflow` |
3030
| 10 | already-closed source → already-closed dest | rejects TypeError (spec; the WPT multiple-propagation seed) | FULFILLS as a trivially complete pipe | `closedSourceToClosedDest` |
31+
| 11 | SharedArrayBuffer-backed views into CompressionStream | copies the shared bytes; round-trips | write path REJECTS TypeError 'The provided value is not of type (ArrayBuffer or ArrayBufferView)' — while its identity stream ACCEPTS the same views | `sabViewThroughCompressionRoundTrip` |
3132

3233
Parity worth noting (probed, pinned): the whole error-propagation-
3334
forward core matrix (starts-errored rejection/hook IDENTITY on both
@@ -70,12 +71,14 @@ the source FIRST, then releasing the write (`pipeStopsPullingWhenDestStalls`).
7071
| `close-propagation.js` | the WPT-disabled backward territory, bounded: external close/abort on piped dest, write-throw backward propagation (ledger #7), idle dest-controller error (ledger #8) |
7172
| `flow-control.js` | backpressure chain (migrated from streams-backpressure-test.js), stalled-dest read-ahead bound |
7273
| `interop.js` | cancel propagation ×2 (migrated from api/streams/streams-test.js), FixedLengthStream (ledger #9), pre-settled pairings (ledger #10) |
74+
| `special-buffers.js` | SharedArrayBuffer-backed and resizable-buffer views through native and JS pipe endpoints (migrated from pipe-write-special-buffer-test.js, strengthened to content checks; ledger #11); the JS path delivers the very view uncopied, resizable buffers stay resizable |
7375
| `legacy-pipes.js` | the unflagged cell (flags table) |
7476
| `data-volumes.js` | end-to-end pipe volumes: 1 MiB pipeTo JS→JS, 8 MiB pipeThrough chain, 1 MiB JS→identity with body readback, 1 MiB identity→JS with a concurrent writer — all byte-exact |
7577

76-
Consumed sources (deleted or shrunk): pipe-streams-test.js (deleted),
78+
Consumed sources (deleted or shrunk): pipe-streams-test.js and
79+
pipe-write-special-buffer-test.js (deleted),
7780
streams-error-edge-cases-test.js (−2), streams-backpressure-test.js
7881
(−1), api/streams/streams-test.js (−2; partiallyReadStream and inspect
7982
remain). The security regression files remain authoritative and
8083
separate: identity-transform-stream-uaf, pipe-source-error-uaf,
81-
pipe-write-special-buffer (SharedArrayBuffer/resizable shapes).
84+
identity-transform-stream-uaf and pipe-source-error-uaf.

src/tests/streams/piping/main.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,11 @@ export {
9090
largePipeJsToIdentity,
9191
largePipeIdentityToJs,
9292
} from 'data-volumes';
93+
94+
export {
95+
sabViewThroughCompressionRoundTrip,
96+
sabViewThroughIdentityTransform,
97+
sabViewThroughJsPipeChain,
98+
resizableViewThroughIdentityTransform,
99+
resizableViewThroughJsPipeChain,
100+
} from 'special-buffers';

src/tests/streams/piping/piping-modules.capnp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ const modules :List(Workerd.Worker.Module) = [
1313
(name = "flow-control", esModule = embed "flow-control.js"),
1414
(name = "interop", esModule = embed "interop.js"),
1515
(name = "data-volumes", esModule = embed "data-volumes.js"),
16+
(name = "special-buffers", esModule = embed "special-buffers.js"),
1617
];
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright (c) 2026 Cloudflare, Inc.
2+
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3+
// https://opensource.org/licenses/Apache-2.0
4+
5+
// Pipes carrying views over SPECIAL buffers: SharedArrayBuffer-backed
6+
// views (non-transferable by definition) and resizable-ArrayBuffer
7+
// views, written through native and JS-backed endpoints (migrated from
8+
// pipe-write-special-buffer-test.js, strengthened from length checks
9+
// to content verification).
10+
11+
import { strictEqual, ok, rejects } from 'node:assert';
12+
import { usingTsImpl } from 'which-impl';
13+
14+
function filledSabView(length, byte) {
15+
const sab = new SharedArrayBuffer(length);
16+
const view = new Uint8Array(sab);
17+
view.fill(byte);
18+
return view;
19+
}
20+
21+
function streamOf(view) {
22+
return new ReadableStream({
23+
start(controller) {
24+
controller.enqueue(view);
25+
controller.close();
26+
},
27+
});
28+
}
29+
30+
async function drainToBytes(readable) {
31+
const reader = readable.getReader();
32+
const parts = [];
33+
let total = 0;
34+
for (;;) {
35+
const { value, done } = await reader.read();
36+
if (done) break;
37+
parts.push(value);
38+
total += value.byteLength;
39+
}
40+
reader.releaseLock();
41+
const out = new Uint8Array(total);
42+
let offset = 0;
43+
for (const part of parts) {
44+
out.set(part, offset);
45+
offset += part.byteLength;
46+
}
47+
return out;
48+
}
49+
50+
function assertAllBytes(bytes, expectedLength, expectedByte) {
51+
strictEqual(bytes.byteLength, expectedLength);
52+
for (let i = 0; i < bytes.byteLength; i++) {
53+
if (bytes[i] !== expectedByte) {
54+
strictEqual(bytes[i], expectedByte, `byte ${i} corrupted`);
55+
}
56+
}
57+
}
58+
59+
// A SAB-backed view piped through CompressionStream. DIVERGENCE: C++
60+
// copies the shared bytes and round-trips them; the TypeScript
61+
// CompressionStream write path REJECTS SharedArrayBuffer-backed views
62+
// ('The provided value is not of type (ArrayBuffer or
63+
// ArrayBufferView)') even though its identity stream accepts them (see
64+
// the next test). The shared buffer is untouched either way.
65+
export const sabViewThroughCompressionRoundTrip = {
66+
async test() {
67+
const view = filledSabView(100, 0x41);
68+
const compressed = streamOf(view).pipeThrough(
69+
new CompressionStream('gzip')
70+
);
71+
if (usingTsImpl) {
72+
await rejects(
73+
drainToBytes(compressed.pipeThrough(new DecompressionStream('gzip'))),
74+
{
75+
name: 'TypeError',
76+
message:
77+
'The provided value is not of type (ArrayBuffer or ArrayBufferView)',
78+
}
79+
);
80+
} else {
81+
const restored = await drainToBytes(
82+
compressed.pipeThrough(new DecompressionStream('gzip'))
83+
);
84+
assertAllBytes(restored, 100, 0x41);
85+
}
86+
// The shared buffer itself must be untouched (it cannot be
87+
// detached).
88+
assertAllBytes(view, 100, 0x41);
89+
},
90+
};
91+
92+
// A SAB-backed view piped into a native identity stream.
93+
export const sabViewThroughIdentityTransform = {
94+
async test() {
95+
const view = filledSabView(1024, 0x42);
96+
const its = new IdentityTransformStream();
97+
const [bytes] = await Promise.all([
98+
drainToBytes(its.readable),
99+
streamOf(view).pipeTo(its.writable),
100+
]);
101+
assertAllBytes(bytes, 1024, 0x42);
102+
assertAllBytes(view, 1024, 0x42);
103+
},
104+
};
105+
106+
// A SAB-backed view through a JS-backed TransformStream into a JS
107+
// sink: the JS pipe path must carry the view without detaching its
108+
// (non-detachable) buffer.
109+
export const sabViewThroughJsPipeChain = {
110+
async test() {
111+
const view = filledSabView(512, 0x44);
112+
const received = [];
113+
await streamOf(view)
114+
.pipeThrough(new TransformStream())
115+
.pipeTo(
116+
new WritableStream({
117+
write(chunk) {
118+
received.push(chunk);
119+
},
120+
})
121+
);
122+
strictEqual(received.length, 1);
123+
assertAllBytes(received[0], 512, 0x44);
124+
assertAllBytes(view, 512, 0x44);
125+
// The delivered chunk is the very view (no copy on the JS path).
126+
strictEqual(received[0], view);
127+
},
128+
};
129+
130+
// A resizable-ArrayBuffer view piped into a native identity stream.
131+
export const resizableViewThroughIdentityTransform = {
132+
async test() {
133+
const buffer = new ArrayBuffer(1024, { maxByteLength: 2048 });
134+
const view = new Uint8Array(buffer);
135+
view.fill(0x43);
136+
const its = new IdentityTransformStream();
137+
const [bytes] = await Promise.all([
138+
drainToBytes(its.readable),
139+
streamOf(view).pipeTo(its.writable),
140+
]);
141+
assertAllBytes(bytes, 1024, 0x43);
142+
},
143+
};
144+
145+
// A resizable-ArrayBuffer view through the JS pipe path: delivered
146+
// intact, and the buffer remains resizable afterwards.
147+
export const resizableViewThroughJsPipeChain = {
148+
async test() {
149+
const buffer = new ArrayBuffer(256, { maxByteLength: 512 });
150+
const view = new Uint8Array(buffer);
151+
view.fill(0x45);
152+
const received = [];
153+
await streamOf(view)
154+
.pipeThrough(new TransformStream())
155+
.pipeTo(
156+
new WritableStream({
157+
write(chunk) {
158+
received.push(chunk);
159+
},
160+
})
161+
);
162+
strictEqual(received.length, 1);
163+
assertAllBytes(received[0], 256, 0x45);
164+
ok(!buffer.detached);
165+
buffer.resize(512); // still resizable
166+
strictEqual(buffer.byteLength, 512);
167+
},
168+
};

src/workerd/api/tests/BUILD.bazel

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,11 +1283,6 @@ wd_test(
12831283
data = ["byob-reader-resize-pending-read-test.js"],
12841284
)
12851285

1286-
wd_test(
1287-
src = "pipe-write-special-buffer-test.wd-test",
1288-
data = ["pipe-write-special-buffer-test.js"],
1289-
)
1290-
12911286
wd_test(
12921287
src = "htmlrewriter-transform-cancel-test.wd-test",
12931288
data = ["htmlrewriter-transform-cancel-test.js"],

src/workerd/api/tests/pipe-write-special-buffer-test.js

Lines changed: 0 additions & 87 deletions
This file was deleted.

src/workerd/api/tests/pipe-write-special-buffer-test.wd-test

Lines changed: 0 additions & 14 deletions
This file was deleted.

0 commit comments

Comments
 (0)