Browse Source

fix(api): rename the failure marker, harden cross-realm discrimination, and mark the packed-record violation

- protocol: isDSHRemoteGatewayError -> isDSHRemoteError (the class is
  protocol-wide, not Gateway-specific); remoteErrorOf drops its
  instanceof Error precondition and tests the marker plus a string code
  structurally, so a marked failure from another realm no longer reads
  as a local defect.
- session-controller: the live-follow packed-record protocol violation
  now throws a marked RemoteError('gateway/internal'), landing the
  session in openState=error instead of an unhandled rejection;
  pinned at the transport and session levels.
- util-time: correct the invariant companion's @module name.
- regenerate the tool-cordis catalog for the marker rename.
imccyu 1 month ago
parent
commit
674a1e95a3

+ 2 - 2
packages/api/gateway/tests/control-retry.client.spec.ts

@@ -108,7 +108,7 @@ describe('RemoteStream', () => {
     ], carrierFailed)
 
     await expect(stream[Symbol.asyncIterator]().next()).rejects.toMatchObject({
-      isDSHRemoteGatewayError: true,
+      isDSHRemoteError: true,
       code: 'gateway/internal',
       message: 'isolated retry failed',
       details: {},
@@ -130,7 +130,7 @@ describe('RemoteStream', () => {
     })
 
     await expect(stream[Symbol.asyncIterator]().next()).rejects.toMatchObject({
-      isDSHRemoteGatewayError: true,
+      isDSHRemoteError: true,
       code: 'gateway/internal',
       message: 'generation exploded',
     })

+ 6 - 1
packages/api/session-controller/src/client/transport.ts

@@ -1,6 +1,7 @@
 /** Session-specific adapters for Gateway-owned Remote stream lifecycles. */
 
 import type {} from '@deepseek-ai/dsh-api-session-controller/remote'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import {
   RemoteJournalStream,
   RemoteSnapshotStream,
@@ -60,7 +61,11 @@ function toSessionJournalChange(
       return { ...change, entries: historyEntries(change.entries) }
     case 'append': {
       if (change.entry.type !== 'event') {
-        throw new Error('session live stream emitted a packed history record')
+        throw new RemoteError(
+          'gateway/internal',
+          'session live stream emitted a packed history record',
+          {},
+        )
       }
       return {
         type: 'append',

+ 23 - 0
packages/api/session-controller/tests/session.client.spec.ts

@@ -99,6 +99,29 @@ describe('Session open', () => {
     expect(api.followStarts).toHaveLength(2)
   })
 
+  it('lands a packed live record in openState=error instead of crashing the stream loop', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
+    await session.open()
+    expect(session.getSnapshot().openState).toBe('open')
+
+    // The live tail may carry only events; a packed record breaks that contract.
+    await api.pushFollow(SID, {
+      type: 'chunks',
+      event: {
+        type: 'chunkrow/text-chunks',
+        seq: 6,
+        time: 6,
+        data: { turn: 1, step: 1, index: 0, texts: ['a'], dt: [] },
+      },
+    } as never)
+
+    await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
+    expect(session.getSnapshot().openError).toMatchObject({
+      code: 'gateway/internal', message: 'session live stream emitted a packed history record',
+    })
+  })
+
   it('lands a Gateway-marked stream failure in openState=error', async () => {
     const { api, session } = makeSession()
     api.onHistory = () => Promise.reject(new Error('socket died'))

+ 5 - 1
packages/api/session-controller/tests/transport.client.spec.ts

@@ -1,5 +1,6 @@
 import { describe, expect, it, vi } from 'vitest'
 import {
+  isRemoteFailure,
   RemoteStream,
   RemoteStreamCarrierError,
   type RemoteStreamOptions,
@@ -181,7 +182,10 @@ describe('Session Client stream adapters', () => {
 
     await stream.open({})
     await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() })
-    expect(failed.mock.calls[0]?.[0]).toMatchObject({
+    const violation: unknown = failed.mock.calls[0]?.[0]
+    expect(isRemoteFailure(violation)).toBe(true)
+    expect(violation).toMatchObject({
+      code: 'gateway/internal',
       message: 'session live stream emitted a packed history record',
     })
     await stream.dispose()

+ 1 - 1
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -4608,7 +4608,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'RemoteError',
-    declaration: 'export class RemoteError<Code extends RemoteErrorCode = RemoteErrorCode> extends Error {\n    readonly isDSHRemoteGatewayError: true;\n    constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions);\n}',
+    declaration: 'export class RemoteError<Code extends RemoteErrorCode = RemoteErrorCode> extends Error {\n    readonly isDSHRemoteError: true;\n    constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions);\n}',
   },
   {
     name: 'RemoteErrorCode',

+ 6 - 3
packages/typert/protocol/src/remote-error.ts

@@ -11,7 +11,7 @@ import type { RemoteErrorCode, RemoteErrorDetailsMap, RemoteFailure } from './ty
  */
 export class RemoteError<Code extends RemoteErrorCode = RemoteErrorCode> extends Error {
   /** Structural marker: cross-realm/bundle identification never uses instanceof. */
-  readonly isDSHRemoteGatewayError: true = true
+  readonly isDSHRemoteError: true = true
 
   /**
    * @param code - stable failure code declared in {@link RemoteErrorDetailsMap}.
@@ -38,8 +38,11 @@ export class RemoteError<Code extends RemoteErrorCode = RemoteErrorCode> extends
  * @returns the failure when the marker matches, otherwise undefined.
  */
 export function remoteErrorOf(value: unknown): RemoteFailure | undefined {
-  if (value instanceof Error
-    && (value as { isDSHRemoteGatewayError?: unknown }).isDSHRemoteGatewayError === true) {
+  // Structural, not instanceof: an Error thrown in another realm (iframe, VM)
+  // fails instanceof Error here, so the marker plus the code field is the test.
+  if (typeof value === 'object' && value !== null
+    && (value as { isDSHRemoteError?: unknown }).isDSHRemoteError === true
+    && typeof (value as { code?: unknown }).code === 'string') {
     return value as unknown as RemoteFailure
   }
   return undefined

+ 2 - 2
packages/util/time/src/invariant.ts

@@ -1,6 +1,6 @@
 /**
- * Package-owned invariant companion for `@deepseek-ai/dsh-time`.
- * @module @deepseek-ai/dsh-time/invariant
+ * Package-owned invariant companion for `@deepseek-ai/dsh-util-time`.
+ * @module @deepseek-ai/dsh-util-time/invariant
  */
 
 /* jscpd:ignore-start */