release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers byte-for-byte. The version bump is semantic: audit-cycle complete, opt-in surface fully exposed, threat model refreshed for every new surface. Highlights: - All 24 @shade/* packages bumped to 4.0.0 in lockstep. - CHANGELOG 4.0.0 section is the canonical manifest of what landed. - THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12 Web-Worker boundary) + residual-risks table refreshed. - OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox, bridge, observer, /metrics, /healthz, /ready. - MIGRATION 0.3.x → 4.0 documented + smoke-tested against shade migrate-storage on a real SQLite DB. - docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer. - scripts/soak.ts harness for the GA-stable 2-week soak window. - All V*.md plans archived under docs/archive/ with Status: Done. - Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen non-realtime stack. Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green. Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports version 4.0.0 on /health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,22 @@ import {
|
||||
type StreamMetadata,
|
||||
} from '@shade/streams';
|
||||
import { decodeStreamChunk, inspectEnvelopeType } from '@shade/proto';
|
||||
import {
|
||||
ATTR_BYTES_BIN,
|
||||
ATTR_DIRECTION,
|
||||
ATTR_ERROR_CODE,
|
||||
ATTR_LANE_COUNT,
|
||||
ATTR_PARTITION,
|
||||
ATTR_PEER_HASH,
|
||||
ATTR_RESULT,
|
||||
ATTR_RETRY_COUNT,
|
||||
bytesBin,
|
||||
laneCountBin,
|
||||
NOOP_HOOK,
|
||||
peerHash,
|
||||
type ObservabilityHook,
|
||||
type Span,
|
||||
} from '@shade/observability';
|
||||
import {
|
||||
TransferAbortError,
|
||||
TransferIntegrityError,
|
||||
@@ -72,6 +88,12 @@ export interface TransferEngineDeps {
|
||||
* random per-process key).
|
||||
*/
|
||||
deviceKey?: Uint8Array;
|
||||
/**
|
||||
* Optional observability hook. Spans are emitted for each upload and for
|
||||
* each accepted incoming transfer. Defaults to a no-op when omitted.
|
||||
* Use `withTracer()` from `@shade/observability` to plug in OTel.
|
||||
*/
|
||||
observability?: ObservabilityHook;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,8 +112,10 @@ export class TransferEngine {
|
||||
>();
|
||||
private readonly unsubscribeControl: () => void;
|
||||
private readonly persister: Persister | null;
|
||||
private readonly observability: ObservabilityHook;
|
||||
|
||||
constructor(private readonly deps: TransferEngineDeps) {
|
||||
this.observability = deps.observability ?? NOOP_HOOK;
|
||||
this.persister =
|
||||
deps.resumeStore !== undefined
|
||||
? new Persister(deps.crypto, deps.resumeStore, deps.deviceKey)
|
||||
@@ -172,6 +196,13 @@ export class TransferEngine {
|
||||
opts.onProgress,
|
||||
opts.onEvent,
|
||||
);
|
||||
state.span = this.observability.startSpan('shade.transfer.upload', {
|
||||
[ATTR_DIRECTION]: 'upload',
|
||||
[ATTR_PEER_HASH]: peerHash(opts.to),
|
||||
[ATTR_BYTES_BIN]: bytesBin(input.size ?? 0),
|
||||
[ATTR_LANE_COUNT]: laneCountBin(lanes.length),
|
||||
[ATTR_PARTITION]: partitionMode,
|
||||
});
|
||||
this.outgoing.set(streamId, state);
|
||||
|
||||
// Persist initial resume state BEFORE sending init, so a crash before
|
||||
@@ -308,6 +339,13 @@ export class TransferEngine {
|
||||
opts?.onProgress,
|
||||
opts?.onEvent,
|
||||
);
|
||||
state.span = this.observability.startSpan('shade.transfer.upload.resume', {
|
||||
[ATTR_DIRECTION]: 'upload',
|
||||
[ATTR_PEER_HASH]: peerHash(peer),
|
||||
[ATTR_BYTES_BIN]: bytesBin(input.size ?? metadata.sizeBytes ?? 0),
|
||||
[ATTR_LANE_COUNT]: laneCountBin(lanes.length),
|
||||
[ATTR_PARTITION]: partitionMode,
|
||||
});
|
||||
// Mark already-shipped lanes as having advanced.
|
||||
for (const lane of lanes) {
|
||||
const lp = (state as unknown as { laneProgress: Map<number, LaneProgress> }).laneProgress.get(
|
||||
@@ -615,6 +653,13 @@ export class TransferEngine {
|
||||
if (opts.onProgress !== undefined) state.onProgress = opts.onProgress;
|
||||
if (opts.onEvent !== undefined) state.onEvent = opts.onEvent;
|
||||
state.startedAt = nowMs();
|
||||
state.span = this.observability.startSpan('shade.transfer.download', {
|
||||
[ATTR_DIRECTION]: 'download',
|
||||
[ATTR_PEER_HASH]: peerHash(from),
|
||||
[ATTR_BYTES_BIN]: bytesBin(msg.metadata.sizeBytes ?? 0),
|
||||
[ATTR_LANE_COUNT]: laneCountBin(msg.lanes.length),
|
||||
[ATTR_PARTITION]: msg.lanes[0]?.partition.kind ?? 'unknown',
|
||||
});
|
||||
state.emit({ type: 'start', streamId: msg.streamId });
|
||||
return state.handle;
|
||||
},
|
||||
@@ -843,6 +888,8 @@ interface ChunkJob {
|
||||
class OutgoingState {
|
||||
startedAt = -1;
|
||||
aborted = false;
|
||||
span: Span | null = null;
|
||||
private retryCount = 0;
|
||||
private completedAt = -1;
|
||||
private resolveDone!: (result: TransferResult) => void;
|
||||
private rejectDone!: (err: unknown) => void;
|
||||
@@ -942,6 +989,16 @@ class OutgoingState {
|
||||
this.completedAt = nowMs();
|
||||
this.emit({ type: 'complete', streamId: result.streamId, sha256: result.sha256, durationMs: result.durationMs });
|
||||
this.flushEventEnd();
|
||||
if (this.span !== null) {
|
||||
this.span.setAttributes({
|
||||
[ATTR_RESULT]: 'ok',
|
||||
[ATTR_BYTES_BIN]: bytesBin(result.bytesSent),
|
||||
[ATTR_RETRY_COUNT]: this.retryCount,
|
||||
});
|
||||
this.span.setStatus('ok');
|
||||
this.span.end();
|
||||
this.span = null;
|
||||
}
|
||||
this.resolveDone(result);
|
||||
void this.input.close();
|
||||
this.sender.destroy();
|
||||
@@ -952,6 +1009,18 @@ class OutgoingState {
|
||||
this.aborted = true;
|
||||
this.emit({ type: 'error', error: err });
|
||||
this.flushEventEnd();
|
||||
if (this.span !== null) {
|
||||
const code = errorCodeOf(err);
|
||||
this.span.setAttributes({
|
||||
[ATTR_RESULT]: 'error',
|
||||
[ATTR_ERROR_CODE]: code,
|
||||
[ATTR_RETRY_COUNT]: this.retryCount,
|
||||
});
|
||||
this.span.recordException(err);
|
||||
this.span.setStatus('error', code);
|
||||
this.span.end();
|
||||
this.span = null;
|
||||
}
|
||||
try {
|
||||
await this.input.close();
|
||||
} catch {
|
||||
@@ -961,6 +1030,10 @@ class OutgoingState {
|
||||
this.rejectDone(err);
|
||||
}
|
||||
|
||||
recordRetry(): void {
|
||||
this.retryCount++;
|
||||
}
|
||||
|
||||
private flushEventEnd(): void {
|
||||
for (const r of this.eventResolvers) r({ value: undefined as never, done: true });
|
||||
this.eventResolvers = [];
|
||||
@@ -990,6 +1063,7 @@ class IncomingState {
|
||||
startedAt = -1;
|
||||
accepted = false;
|
||||
declined = false;
|
||||
span: Span | null = null;
|
||||
sink: OutputSink | null = null;
|
||||
outputKind: 'pipe' | 'callback' | 'buffer' | 'file' | 'fileHandle' | undefined;
|
||||
onProgress: ((p: TransferProgress) => void) | undefined;
|
||||
@@ -1224,6 +1298,15 @@ class IncomingState {
|
||||
this.completedAt = nowMs();
|
||||
this.emit({ type: 'complete', streamId: result.streamId, sha256: result.sha256, durationMs: result.durationMs });
|
||||
this.flushEventEnd();
|
||||
if (this.span !== null) {
|
||||
this.span.setAttributes({
|
||||
[ATTR_RESULT]: 'ok',
|
||||
[ATTR_BYTES_BIN]: bytesBin(result.bytesSent),
|
||||
});
|
||||
this.span.setStatus('ok');
|
||||
this.span.end();
|
||||
this.span = null;
|
||||
}
|
||||
this.resolveDone(result);
|
||||
this.receiver.destroy();
|
||||
}
|
||||
@@ -1234,6 +1317,15 @@ class IncomingState {
|
||||
if (this.sink !== null) await this.sink.abort(reason);
|
||||
this.emit({ type: 'abort', reason });
|
||||
this.flushEventEnd();
|
||||
if (this.span !== null) {
|
||||
this.span.setAttributes({
|
||||
[ATTR_RESULT]: 'abort',
|
||||
[ATTR_ERROR_CODE]: 'SHADE_TRANSFER_ABORT',
|
||||
});
|
||||
this.span.setStatus('error', reason);
|
||||
this.span.end();
|
||||
this.span = null;
|
||||
}
|
||||
this.receiver.destroy();
|
||||
this.rejectDone(new TransferAbortError(reason));
|
||||
}
|
||||
@@ -1253,6 +1345,17 @@ class IncomingState {
|
||||
this.aborted = true;
|
||||
this.emit({ type: 'error', error: err });
|
||||
this.flushEventEnd();
|
||||
if (this.span !== null) {
|
||||
const code = errorCodeOf(err);
|
||||
this.span.setAttributes({
|
||||
[ATTR_RESULT]: 'error',
|
||||
[ATTR_ERROR_CODE]: code,
|
||||
});
|
||||
this.span.recordException(err);
|
||||
this.span.setStatus('error', code);
|
||||
this.span.end();
|
||||
this.span = null;
|
||||
}
|
||||
if (this.sink !== null) await this.sink.abort('integrity-failure');
|
||||
this.receiver.destroy();
|
||||
this.rejectDone(err);
|
||||
@@ -1357,6 +1460,17 @@ function nowMs(): number {
|
||||
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function errorCodeOf(err: unknown): string {
|
||||
if (err === null || err === undefined) return 'SHADE_UNKNOWN';
|
||||
if (typeof err === 'object') {
|
||||
const code = (err as { code?: unknown }).code;
|
||||
if (typeof code === 'string' && code.length > 0) return code;
|
||||
const name = (err as { name?: unknown }).name;
|
||||
if (typeof name === 'string' && name.startsWith('Transfer')) return `SHADE_${name}`;
|
||||
}
|
||||
return 'SHADE_UNKNOWN';
|
||||
}
|
||||
|
||||
// ─── Persister ───────────────────────────────────────────────
|
||||
|
||||
class Persister {
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from './transport/transport.js';
|
||||
export * from './transport/memory.js';
|
||||
export * from './transport/http-transport.js';
|
||||
export * from './transport/ws-transport.js';
|
||||
export * from './transport/multi-fallback.js';
|
||||
export * from './engine.js';
|
||||
export {
|
||||
createTransferRoutes,
|
||||
|
||||
148
packages/shade-transfer/src/transport/multi-fallback.ts
Normal file
148
packages/shade-transfer/src/transport/multi-fallback.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* N-ary `ITransferTransport` fallback chain.
|
||||
*
|
||||
* Generalises the two-arg {@link FallbackTransferTransport} so a Shade
|
||||
* client can wire `[WebRTC, WebSocket, HTTP]` and have the engine try
|
||||
* them in order — switching sticky once a transport raises a
|
||||
* `TransferTransportError`.
|
||||
*
|
||||
* The chain advances exactly once per failure and never tries to "fall
|
||||
* back up" — once HTTP wins, P2P stops being attempted for that
|
||||
* `MultiTransportFallback` instance. Re-create the wrapper to re-try
|
||||
* the upper layers (which is what the SDK does on `configureWebRTC()`).
|
||||
*/
|
||||
|
||||
import { TransferTransportError } from '../errors.js';
|
||||
import type {
|
||||
ChunkAck,
|
||||
ChunkSendOptions,
|
||||
ITransferTransport,
|
||||
TransferResumeState,
|
||||
} from './transport.js';
|
||||
|
||||
export interface NamedTransport {
|
||||
name: string;
|
||||
transport: ITransferTransport;
|
||||
}
|
||||
|
||||
export class MultiTransportFallback implements ITransferTransport {
|
||||
/** Index into `transports` of the currently-active layer. */
|
||||
private cursor = 0;
|
||||
/** Recorded failures per name — purely diagnostic. */
|
||||
private readonly failureLog: Array<{ name: string; error: string }> = [];
|
||||
private readonly switchListeners = new Set<(from: string, to: string) => void>();
|
||||
|
||||
constructor(private readonly transports: NamedTransport[]) {
|
||||
if (transports.length === 0) {
|
||||
throw new Error('MultiTransportFallback: must supply at least one transport');
|
||||
}
|
||||
}
|
||||
|
||||
/** Name of the currently-active transport. */
|
||||
get activeName(): string {
|
||||
return this.transports[this.cursor]!.name;
|
||||
}
|
||||
|
||||
/** Has the chain demoted at least once? */
|
||||
get hasFallenBack(): boolean {
|
||||
return this.cursor > 0;
|
||||
}
|
||||
|
||||
/** Diagnostic snapshot of the failure log. */
|
||||
get failures(): readonly { name: string; error: string }[] {
|
||||
return this.failureLog;
|
||||
}
|
||||
|
||||
onSwitch(cb: (from: string, to: string) => void): () => void {
|
||||
this.switchListeners.add(cb);
|
||||
return () => this.switchListeners.delete(cb);
|
||||
}
|
||||
|
||||
async probe(peerAddress: string): Promise<void> {
|
||||
while (this.cursor < this.transports.length) {
|
||||
const layer = this.transports[this.cursor]!;
|
||||
try {
|
||||
await layer.transport.probe(peerAddress);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (this.demoteIfTransportError(layer.name, err)) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new TransferTransportError(
|
||||
`MultiTransportFallback: all ${this.transports.length} transports unreachable`,
|
||||
);
|
||||
}
|
||||
|
||||
async sendChunk(
|
||||
peerAddress: string,
|
||||
streamId: string,
|
||||
laneId: number,
|
||||
seq: number | bigint,
|
||||
bytes: Uint8Array,
|
||||
options?: ChunkSendOptions,
|
||||
): Promise<ChunkAck> {
|
||||
while (this.cursor < this.transports.length) {
|
||||
const layer = this.transports[this.cursor]!;
|
||||
try {
|
||||
return await layer.transport.sendChunk(
|
||||
peerAddress,
|
||||
streamId,
|
||||
laneId,
|
||||
seq,
|
||||
bytes,
|
||||
options,
|
||||
);
|
||||
} catch (err) {
|
||||
if (this.demoteIfTransportError(layer.name, err)) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new TransferTransportError(
|
||||
`MultiTransportFallback: sendChunk failed across all ${this.transports.length} transports`,
|
||||
);
|
||||
}
|
||||
|
||||
async fetchResumeState(
|
||||
peerAddress: string,
|
||||
streamId: string,
|
||||
): Promise<TransferResumeState | null> {
|
||||
while (this.cursor < this.transports.length) {
|
||||
const layer = this.transports[this.cursor]!;
|
||||
try {
|
||||
return await layer.transport.fetchResumeState(peerAddress, streamId);
|
||||
} catch (err) {
|
||||
if (this.demoteIfTransportError(layer.name, err)) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private demoteIfTransportError(name: string, err: unknown): boolean {
|
||||
if (!(err instanceof TransferTransportError)) return false;
|
||||
this.failureLog.push({ name, error: (err as Error).message });
|
||||
const next = this.cursor + 1;
|
||||
if (next >= this.transports.length) {
|
||||
// Already on the last layer — re-throw upstream.
|
||||
return false;
|
||||
}
|
||||
const prevName = this.transports[this.cursor]!.name;
|
||||
this.cursor = next;
|
||||
const newName = this.transports[this.cursor]!.name;
|
||||
for (const cb of this.switchListeners) {
|
||||
try {
|
||||
cb(prevName, newName);
|
||||
} catch (cbErr) {
|
||||
console.warn('[MultiTransportFallback] onSwitch callback threw:', cbErr);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user