* Body of the transport's setOnClose callback, hoisted to initBridgeCore * scope so /bridge-kick can fire it directly. setOnClose wraps this with * a stale-transport guard; debugFireClose calls it bare. * * With autoReconnect:true, this only fires on: clean close (1000), * permanent s
(closeCode: number | undefined)
| 885 | * exhaustion. Transient drops are retried internally by the transport. |
| 886 | */ |
| 887 | function handleTransportPermanentClose(closeCode: number | undefined): void { |
| 888 | logForDebugging( |
| 889 | `[bridge:repl] Transport permanently closed: code=${closeCode}`, |
| 890 | ) |
| 891 | logEvent('tengu_bridge_repl_ws_closed', { |
| 892 | code: closeCode, |
| 893 | }) |
| 894 | // Capture SSE seq high-water mark before nulling. When called from |
| 895 | // setOnClose the guard guarantees transport !== null; when fired from |
| 896 | // /bridge-kick it may already be null (e.g. fired twice) — skip. |
| 897 | if (transport) { |
| 898 | const closedSeq = transport.getLastSequenceNum() |
| 899 | if (closedSeq > lastTransportSequenceNum) { |
| 900 | lastTransportSequenceNum = closedSeq |
| 901 | } |
| 902 | transport = null |
| 903 | } |
| 904 | // Transport is gone — wake the poll loop out of its at-capacity |
| 905 | // heartbeat sleep so it's fast-polling by the time the reconnect |
| 906 | // below completes and the server re-queues work. |
| 907 | wakePollLoop() |
| 908 | // Reset flush state so writeMessages() hits the !transport guard |
| 909 | // (with a warning log) instead of silently queuing into a buffer |
| 910 | // that will never be drained. Unlike onWorkReceived (which |
| 911 | // preserves pending messages for the new transport), onClose is |
| 912 | // a permanent close — no new transport will drain these. |
| 913 | const dropped = flushGate.drop() |
| 914 | if (dropped > 0) { |
| 915 | logForDebugging( |
| 916 | `[bridge:repl] Dropping ${dropped} pending message(s) on transport close (code=${closeCode})`, |
| 917 | { level: 'warn' }, |
| 918 | ) |
| 919 | } |
| 920 | |
| 921 | if (closeCode === 1000) { |
| 922 | // Clean close — session ended normally. Tear down the bridge. |
| 923 | onStateChange?.('failed', 'session ended') |
| 924 | pollController.abort() |
| 925 | triggerTeardown() |
| 926 | return |
| 927 | } |
| 928 | |
| 929 | // Transport reconnect budget exhausted or permanent server |
| 930 | // rejection. By this point the env has usually been reaped |
| 931 | // server-side (BQ 2026-03-12: ~98% of ws_closed never recover |
| 932 | // via poll alone). stopWork(force=false) can't re-dispatch work |
| 933 | // from an archived env; reconnectEnvironmentWithSession can |
| 934 | // re-activate it via POST /bridge/reconnect, or fall through |
| 935 | // to a fresh session if the env is truly gone. The poll loop |
| 936 | // (already woken above) picks up the re-queued work once |
| 937 | // doReconnect completes. |
| 938 | onStateChange?.( |
| 939 | 'reconnecting', |
| 940 | `Remote Control connection lost (code ${closeCode})`, |
| 941 | ) |
| 942 | logForDebugging( |
| 943 | `[bridge:repl] Transport reconnect budget exhausted (code=${closeCode}), attempting env reconnect`, |
| 944 | ) |
no test coverage detected