fix(files): await pull-mode stream readiness

This commit is contained in:
2026-07-10 17:38:35 +02:00
parent 306bf08452
commit 8c67a00f37
2 changed files with 93 additions and 6 deletions

View File

@@ -444,8 +444,12 @@ export function createFilesHttpClient(
}; };
return out; return out;
} }
// Streamed read — only supported when the queue drainer is wired. // Streamed read — only supported when pull-mode was configured.
if (drainer === null) { // `drainer` is assigned asynchronously after the streams bridge has
// subscribed, so checking it here races client construction. The
// promise itself is the synchronous configuration signal; awaiting it
// also guarantees the drainer has been installed by its `.then()`.
if (streamsBridgePromise === null) {
throw new InternalFileError( throw new InternalFileError(
`http RPC client received a streamed read (size ${wire.size}) but is in inline-only mode. Pass { outboundQueueUrl, transferBaseUrl } when constructing the client to enable streamed reads.`, `http RPC client received a streamed read (size ${wire.size}) but is in inline-only mode. Pass { outboundQueueUrl, transferBaseUrl } when constructing the client to enable streamed reads.`,
); );
@@ -507,8 +511,11 @@ export function createFilesHttpClient(
); );
} }
// Streamed write — requires the queue drainer + streams-bridge. // Streamed write — requires pull-mode configuration. Do not inspect
if (drainer === null) { // `drainer` as a readiness flag: bridge registration is asynchronous,
// and an immediate large write must wait for it instead of being
// misclassified as an inline-only client.
if (streamsBridgePromise === null) {
throw new ConflictError( throw new ConflictError(
`http RPC client supports inline writes only (≤ ${INLINE_THRESHOLD} bytes). The supplied input was promoted to streams (size ${decision.size ?? 'unknown'}). Pass { outboundQueueUrl, transferBaseUrl } to enable streamed writes.`, `http RPC client supports inline writes only (≤ ${INLINE_THRESHOLD} bytes). The supplied input was promoted to streams (size ${decision.size ?? 'unknown'}). Pass { outboundQueueUrl, transferBaseUrl } to enable streamed writes.`,
); );

View File

@@ -7,8 +7,10 @@ import {
} from '@shade/server'; } from '@shade/server';
import { SubtleCryptoProvider } from '@shade/crypto-web'; import { SubtleCryptoProvider } from '@shade/crypto-web';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { createFilesHttpClient, type FileEntry } from '../../src/index.js';
const crypto = new SubtleCryptoProvider(); const crypto = new SubtleCryptoProvider();
type ShadeInstance = Awaited<ReturnType<typeof createShade>>;
/** /**
* Stand up the full pull-mode rig: * Stand up the full pull-mode rig:
@@ -20,7 +22,8 @@ const crypto = new SubtleCryptoProvider();
* no inbound listener, streams supported via long-poll. * no inbound listener, streams supported via long-poll.
*/ */
async function setupPullRig(opts: { async function setupPullRig(opts: {
bobHandler: Parameters<NonNullable<Awaited<ReturnType<typeof createShade>>['files']>['serve']>[0]; bobHandler: Parameters<NonNullable<ShadeInstance['files']>['serve']>[0];
wrapClientShade?: (shade: ShadeInstance) => Parameters<typeof createFilesHttpClient>[0];
}) { }) {
const prekey = createPrekeyServer({ const prekey = createPrekeyServer({
crypto, crypto,
@@ -46,7 +49,7 @@ async function setupPullRig(opts: {
const bobServer = Bun.serve({ port: 0, fetch: app.fetch }); const bobServer = Bun.serve({ port: 0, fetch: app.fetch });
const baseUrl = `http://localhost:${bobServer.port}`; const baseUrl = `http://localhost:${bobServer.port}`;
const fs = alice.files.httpClient('bob', { const fs = createFilesHttpClient(opts.wrapClientShade?.(alice) ?? alice, 'bob', {
rpcUrl: `${baseUrl}/rpc`, rpcUrl: `${baseUrl}/rpc`,
outboundQueueUrl: `${baseUrl}/queue`, outboundQueueUrl: `${baseUrl}/queue`,
transferBaseUrl: baseUrl, transferBaseUrl: baseUrl,
@@ -70,6 +73,83 @@ async function setupPullRig(opts: {
} }
describe('@shade/files HTTP RPC — pull-mode streams', () => { describe('@shade/files HTTP RPC — pull-mode streams', () => {
test('immediate streamed write waits for asynchronous stream-bridge registration', async () => {
const payload = new Uint8Array(512 * 1024);
for (let i = 0; i < payload.length; i++) payload[i] = (i * 53) & 0xff;
let releaseRegistration!: () => void;
const registrationGate = new Promise<void>((resolve) => {
releaseRegistration = resolve;
});
let registrationStarted!: () => void;
const started = new Promise<void>((resolve) => {
registrationStarted = resolve;
});
const rig = await setupPullRig({
wrapClientShade: (alice) =>
new Proxy(alice, {
get(target, property) {
if (property === 'onIncomingTransfer') {
return async (handler: Parameters<typeof target.onIncomingTransfer>[0]) => {
registrationStarted();
await registrationGate;
return await target.onIncomingTransfer(handler);
};
}
const value = Reflect.get(target, property, target) as unknown;
return typeof value === 'function' ? value.bind(target) : value;
},
}),
bobHandler: {
write: async (ctx) => {
const content = ctx.args.content;
if (content.kind !== 'streams') throw new Error('expected streamed content');
const reader = content.stream.getReader();
let received = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
received += value?.byteLength ?? 0;
}
reader.releaseLock();
await content.sha256;
const entry: FileEntry = {
name: 'immediate.bin',
kind: 'file',
size: received,
mtime: Date.now(),
metadata: {},
};
return { entry };
},
},
});
try {
let settled = false;
const write = rig.fs.write('/immediate.bin', payload);
void write.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await started;
await Bun.sleep(10);
expect(settled).toBe(false);
releaseRegistration();
const result = await write;
expect(result.entry.size).toBe(payload.byteLength);
} finally {
releaseRegistration();
await rig.teardown();
}
}, 15_000);
test('streamed read (4 MiB) via long-poll queue', async () => { test('streamed read (4 MiB) via long-poll queue', async () => {
const payload = new Uint8Array(4 * 1024 * 1024); const payload = new Uint8Array(4 * 1024 * 1024);
for (let i = 0; i < payload.length; i++) payload[i] = (i * 97) & 0xff; for (let i = 0; i < payload.length; i++) payload[i] = (i * 97) & 0xff;