From 4b805c864c4c735e765d098d5b26ca975a16644d Mon Sep 17 00:00:00 2001 From: ru-code-dev <53821477+ru-code-dev@users.noreply.github.com> Date: Fri, 19 Jun 2026 07:42:42 +0300 Subject: [PATCH] feat(ru-code): makes connection more stable --- .../src/components/ThreadTerminalDrawer.tsx | 14 ++++++++++--- apps/web/src/rpc/wsTransport.ts | 11 ++++++++++ .../contracts/src/ru-fork/terminalErrors.ts | 20 +++++++++++++++++++ patches/effect@4.0.0-beta.59.patch | 3 ++- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index c0da1b4b146..88b92df5cec 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -4,6 +4,7 @@ import { type ResolvedKeybindingsConfig, type ScopedThreadRef, type TerminalEvent, + describeUnknownTerminalError, terminalErrorMessage, type TerminalSessionSnapshot, type ThreadId, @@ -703,12 +704,19 @@ export function TerminalViewport({ } } catch (err) { if (disposed) return; - // ru-fork: rebuild the real reason from the wire-decoded terminal error - // (the tagged error's `message` getter is lost over the RPC boundary), - // falling back to a plain Error message, then a generic string. + // ru-fork: dump the raw error first — when it isn't one of the 4 typed + // terminal tags (e.g. an RPC/transport/decode failure) both the + // formatter and `err.message` are empty, and the real reason was being + // discarded into a generic string. + console.error("[terminal] open failed", err); + // rebuild the real reason from the wire-decoded terminal error (the + // tagged error's `message` getter is lost over the RPC boundary), + // falling back to a plain Error message, then whatever the unknown wire + // object carries, then a generic string. const detail = terminalErrorMessage(err as { _tag?: string }) ?? (err instanceof Error ? err.message : undefined) ?? + describeUnknownTerminalError(err) ?? "Не удалось открыть терминал."; writeSystemMessage(terminal, detail); } diff --git a/apps/web/src/rpc/wsTransport.ts b/apps/web/src/rpc/wsTransport.ts index 8e88582f907..6c467644537 100644 --- a/apps/web/src/rpc/wsTransport.ts +++ b/apps/web/src/rpc/wsTransport.ts @@ -265,6 +265,17 @@ export class WsTransport { this.lastHeartbeatPongAt = Date.now(); this.lifecycleHandlers?.onHeartbeatPong?.(); }, + // ru-fork: a ping timeout surfaces as a Socket *Open* error, which + // effect's makeProtocolSocket swallows under retryTransientErrors — it + // silently reopens the socket WITHOUT replaying the subscription + // requests, so every live stream dies while unary calls (browse, + // dispatchCommand) keep working. Force our own session swap so the + // subscribe() loop sees session !== this.session and re-attaches every + // stream on the fresh socket. + onHeartbeatTimeout: () => { + this.lifecycleHandlers?.onHeartbeatTimeout?.(); + void this.reconnect().catch(() => undefined); + }, }), ); const clientScope = runtime.runSync(Scope.make()); diff --git a/packages/contracts/src/ru-fork/terminalErrors.ts b/packages/contracts/src/ru-fork/terminalErrors.ts index 97c2cf24e9d..e0bb23239fa 100644 --- a/packages/contracts/src/ru-fork/terminalErrors.ts +++ b/packages/contracts/src/ru-fork/terminalErrors.ts @@ -49,3 +49,23 @@ export function terminalErrorMessage(error: TerminalErrorShape): string | undefi return undefined; } } + +// ru-fork: last-resort detail for an error that is NOT a typed terminal error +// (RPC/transport failure, decode mismatch, a defect) — surface whatever the +// wire object carries instead of collapsing to a generic string. +export function describeUnknownTerminalError(error: unknown): string | undefined { + if (typeof error !== "object" || error === null) return undefined; + const shape = error as { + _tag?: string; + reason?: string; + message?: unknown; + cause?: unknown; + }; + const tag = typeof shape._tag === "string" ? shape._tag : undefined; + const cause = + shape.cause && typeof shape.cause === "object" && "message" in shape.cause + ? String((shape.cause as { message?: unknown }).message) + : undefined; + const parts = [tag, shape.reason, cause].filter(Boolean); + return parts.length > 0 ? `Терминал не открылся: ${parts.join(" · ")}` : undefined; +} diff --git a/patches/effect@4.0.0-beta.59.patch b/patches/effect@4.0.0-beta.59.patch index 479004acf33..02c1b671ad3 100644 --- a/patches/effect@4.0.0-beta.59.patch +++ b/patches/effect@4.0.0-beta.59.patch @@ -260,7 +260,8 @@ index 951dfc2d667a9136cfa714dcd3195e6575917fde..599c0345873bedd72bc93afe80eab0c6 recievedPong = false; - return writePing; + return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); - }).pipe(Effect.delay("5 seconds"), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped); +- }).pipe(Effect.delay("5 seconds"), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped); ++ }).pipe(Effect.delay("10 seconds"), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped); return { timeout: latch.await, @@ -773,6 +802,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun