From d02b5a45d12fe29a451ce0a34ba3d44153bdb021 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 29 May 2026 05:27:42 +0000 Subject: [PATCH] fix(websocket): keep upgraded WebSocket tunnels on dedicated lifecycle timeouts --- changelog.md | 7 + pnpm-lock.yaml | 157 +++--------------- .../rustproxy-http/src/proxy_service.rs | 34 +++- .../rustproxy-passthrough/src/tcp_listener.rs | 15 +- test/test.websocket-e2e.ts | 64 +++++++ 5 files changed, 127 insertions(+), 150 deletions(-) diff --git a/changelog.md b/changelog.md index 9e5557c..6c6026e 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,13 @@ ## Pending +### Fixes + +- keep upgraded WebSocket tunnels on dedicated WebSocket lifecycle timeouts instead of the HTTP socket timeout (rustproxy) +- keep upgraded WebSocket tunnels on dedicated lifecycle timeouts (websocket) + - Track active upgraded tunnels so HTTP idle and max-lifetime watchdogs do not terminate WebSocket connections + - Use dedicated default WebSocket inactivity and max-lifetime timeouts in rustproxy passthrough listeners + - Add end-to-end coverage for idle WebSockets surviving short HTTP socket timeouts ## 2026-05-24 - 27.11.0 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e296425..9d1044f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1008,9 +1008,6 @@ packages: engines: {node: '>=18'} hasBin: true - '@push.rocks/consolecolor@2.0.3': - resolution: {integrity: sha512-hA+m0BMqEwZNSAS7c2aQFfoPkpX/dNdsHzkdLdeERUOy7BLacb9ItTUofGtjtginP0yDj4NSpqSjNYyX3Y8Y/w==} - '@push.rocks/consolecolor@2.0.4': resolution: {integrity: sha512-rQJfuSJLzm117PBpsfyemX8Q/rpKh8ZVc2AqDVu6RXJMJkmGkKsADe0/rnttuHZYss8IP7yJIN9E6Vnx+jyy0A==} @@ -1023,9 +1020,6 @@ packages: '@push.rocks/levelcache@3.2.2': resolution: {integrity: sha512-g44xp3XmtSPlcTHQ8qoaNV0AK7w4cuLd6h7sGXXxldN3NLgjOUpUqnnyDBU9i5hpIIxqssxe8WRQz10bi9W+tA==} - '@push.rocks/lik@6.3.1': - resolution: {integrity: sha512-UWDwGBaVx5yPtAFXqDDBtQZCzETUOA/7myQIXb+YBsuiIw4yQuhNZ23uY2ChQH2Zn6DLqdNSgQcYC0WywMZBNQ==} - '@push.rocks/lik@6.4.1': resolution: {integrity: sha512-W5M2zoJWUxYnCVqUB7jaxMB4W1kfhs1P6SXvWGqwDpJAjMjCnZeAXD+w0akECgSBY1zCCT2qMj7YK4Gza0t25g==} @@ -1062,9 +1056,6 @@ packages: '@push.rocks/smartdata@7.1.7': resolution: {integrity: sha512-HDI/Q9dKybfsJ68oCzlE+S63Xpij9qXnMfi28yznKP0Li1ECVZZMDDGIW5IjsXlHjO+Q+RJMcVd72Pjt3QLY5Q==} - '@push.rocks/smartdelay@3.0.5': - resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==} - '@push.rocks/smartdelay@3.1.0': resolution: {integrity: sha512-59xveBMbWmbFhh/rqhQnYG/klg/VONG9hV8+RQ7ftqsNRkcmUT+VM5etAbODgAUvsF4lxK+xVR0tbZOo0kGhRQ==} @@ -1074,9 +1065,6 @@ packages: '@push.rocks/smartenv@5.0.13': resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==} - '@push.rocks/smartenv@6.0.0': - resolution: {integrity: sha512-ktW5MqOFs0492sB4vrvl4lgRFQ/sQ4AyREgB+sCIzGqszHWGVvGXR95Y2a3z66jkLPYML2CUWHzmMlfv8fkG+A==} - '@push.rocks/smartenv@6.1.0': resolution: {integrity: sha512-pKm5knYEkcHHc9XaYJ41Ya8/WfZB6fy1ZDB+TSLC85lvMrrRFLSsujjDehdDXl/mJr3MqecauTh2QzQIszTrjQ==} @@ -1098,9 +1086,6 @@ packages: '@push.rocks/smartguard@3.1.0': resolution: {integrity: sha512-J23q84f1O+TwFGmd4lrO9XLHUh2DaLXo9PN/9VmTWYzTkQDv5JehmifXVI0esophXcCIfbdIu6hbt7/aHlDF4A==} - '@push.rocks/smarthash@3.2.6': - resolution: {integrity: sha512-Mq/WNX0Tjjes3X1gHd/ZBwOOKSrAG/Z3Xoc0OcCm3P20WKpniihkMpsnlE7wGjvpHLi/ZRe/XkB3KC3d5r9X4g==} - '@push.rocks/smarthash@3.2.7': resolution: {integrity: sha512-y6iyu9l8Hslsa8W4e8UktX5d0yFZqipNgxxIik6NT0yHUM1zagx2cjemUtdV49uq1u+086Wr7nvrzLROWDzReA==} @@ -1110,9 +1095,6 @@ packages: '@push.rocks/smartjimp@1.2.1': resolution: {integrity: sha512-tIVS2sEqBjZTPX5U7a+dDBSZ+kfz7CdQwkEIhW6DEl6cuJ9uz2eH+pnPY0oZhw4g3q8hyW9Lf6lb8+nMmTyudw==} - '@push.rocks/smartjson@5.2.0': - resolution: {integrity: sha512-710e8UwovRfPgUtaBHcd6unaODUjV5fjxtGcGCqtaTcmvOV6VpasdVfT66xMDzQmWH2E9ZfHDJeso9HdDQzNQA==} - '@push.rocks/smartjson@6.0.1': resolution: {integrity: sha512-iIw860jpjBcl83bLtq97QrjJxQkgxIKkhrX53EnpsVsZVNBgPCymLp0xNqY2jMpak5MKCEIWUVXkrmWVXj/TlQ==} @@ -1158,9 +1140,6 @@ packages: '@push.rocks/smartpdf@4.2.2': resolution: {integrity: sha512-xQWRChCLcM/sUrRuanvIcND/dKrnCYfL8Rr3kzSIPgSoDSmdDbd4kz7lLAHEPTsCezIwg2VqxFidW+zMNZ5Z1Q==} - '@push.rocks/smartpromise@4.2.3': - resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} - '@push.rocks/smartpromise@4.2.4': resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==} @@ -1200,9 +1179,6 @@ packages: '@push.rocks/smartstream@3.4.2': resolution: {integrity: sha512-JsjFjaNIlCBUglciM/IrXH0mH+oOQTLYQ6UMwqsew2XSUTXxER3ev2NeKMDBV6ONf2HF21EPnOZuKfgvtNGnUg==} - '@push.rocks/smartstring@4.1.0': - resolution: {integrity: sha512-Q4py/Nm3KTDhQ9EiC75yBtSTLR0KLMwhKM+8gGcutgKotZT6wJ3gncjmtD8LKFfNhb4lSaFMgPJgLrCHTOH6Iw==} - '@push.rocks/smartstring@4.1.1': resolution: {integrity: sha512-FlEpp2PcQ819ymmxjWb5/2gD8uPic/+IvOrSP2+KTdXLHOI4GSyK9YW/YBF541LVGl0GC3VGFmypcPNUzkPfYw==} @@ -1234,9 +1210,6 @@ packages: '@push.rocks/websetup@3.0.20': resolution: {integrity: sha512-7TJ2ryFEpuSocGQwhhdEL6x8d7H0q3N4MJIJS46nc7r5XM5oXAXaIj/8gX2/TSNQWUt35CNSpJPkznoLpp95Jw==} - '@push.rocks/webstore@2.0.20': - resolution: {integrity: sha512-Z3L4OHGcw/Gs9aXpMUwebEPTh0nK/C7R6YwPfCLcGVu9yd/ZShaQ8QZEYE243Cu9J1Mn+CEtz4jpPLnHiizHQA==} - '@push.rocks/webstore@2.0.22': resolution: {integrity: sha512-EdWfcNo0m6adSgTq7NtZusvmubUtRiCRADfFIbbgGZhCr9xLxmyB1nCtO/wzUrWZEbnR+Q9+fYkJFnDFOmZ4wA==} @@ -1580,9 +1553,6 @@ packages: resolution: {integrity: sha512-G/gWDykZNL0NVcd1qXkoKm45jxJECp6q53DSomM5QKMsyAMEsGksVq+HwgonqYxfFJEzzHi6ljtWKXVS1pl0/Q==} engines: {node: '>=18.0.0'} - '@tempfix/idb@8.0.3': - resolution: {integrity: sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==} - '@tempfix/lenis@1.3.20': resolution: {integrity: sha512-ypeB0FuHLHOCQXW4d0RQ69txPJJH+1CHcpsZIUdcv2t1vR0IVyQr2vHihtde9UOXhjzqEnUphWon/UcJNsa0YA==} peerDependencies: @@ -1637,9 +1607,6 @@ packages: '@types/mime-types@2.1.4': resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} - '@types/minimatch@5.1.2': - resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -2092,10 +2059,6 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true - fake-indexeddb@5.0.2: - resolution: {integrity: sha512-cB507r5T3D55DfclY01GLkninZLfU7HXV/mhVRTnTRm5k2u+fY7Fof2dBkr80p5t7G7dlA/G5dI87QiMdPpMCQ==} - engines: {node: '>=18'} - fake-indexeddb@6.2.5: resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} engines: {node: '>=18'} @@ -2230,10 +2193,6 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - hasown@2.0.3: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} @@ -2376,9 +2335,6 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} - lodash.clonedeep@4.5.0: - resolution: {integrity: sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=} - longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -2650,10 +2606,6 @@ packages: no-case@2.3.2: resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==} - node-forge@1.3.3: - resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} - engines: {node: '>= 6.13.0'} - node-forge@1.4.0: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} @@ -4698,10 +4650,6 @@ snapshots: - react-native-b4a - supports-color - '@push.rocks/consolecolor@2.0.3': - dependencies: - ansi-256-colors: 1.1.0 - '@push.rocks/consolecolor@2.0.4': dependencies: ansi-256-colors: 1.1.0 @@ -4725,17 +4673,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@push.rocks/lik@6.3.1': - dependencies: - '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartmatch': 2.0.0 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smartrx': 3.0.10 - '@push.rocks/smarttime': 4.2.3 - '@types/minimatch': 5.1.2 - '@types/symbol-tree': 3.2.5 - symbol-tree: 3.2.4 - '@push.rocks/lik@6.4.1': dependencies: '@push.rocks/smartdelay': 3.1.0 @@ -4838,9 +4775,9 @@ snapshots: '@push.rocks/smartclickhouse@2.2.0': dependencies: - '@push.rocks/smartdelay': 3.0.5 + '@push.rocks/smartdelay': 3.1.0 '@push.rocks/smartobject': 1.0.12 - '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartpromise': 4.2.4 '@push.rocks/smartrx': 3.0.10 '@push.rocks/smarturl': 3.1.0 '@push.rocks/webrequest': 4.0.5 @@ -4864,9 +4801,9 @@ snapshots: '@push.rocks/smartcrypto@2.0.4': dependencies: - '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartpromise': 4.2.4 '@types/node-forge': 1.3.14 - node-forge: 1.3.3 + node-forge: 1.4.0 '@push.rocks/smartdata@7.1.7(socks@2.8.9)': dependencies: @@ -4898,10 +4835,6 @@ snapshots: - supports-color - vue - '@push.rocks/smartdelay@3.0.5': - dependencies: - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smartdelay@3.1.0': dependencies: '@push.rocks/smartpromise': 4.2.4 @@ -4920,11 +4853,7 @@ snapshots: '@push.rocks/smartenv@5.0.13': dependencies: - '@push.rocks/smartpromise': 4.2.3 - - '@push.rocks/smartenv@6.0.0': - dependencies: - '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartpromise': 4.2.4 '@push.rocks/smartenv@6.1.0': dependencies: @@ -4970,14 +4899,6 @@ snapshots: '@push.rocks/smartpromise': 4.2.4 '@push.rocks/smartrequest': 2.1.0 - '@push.rocks/smarthash@3.2.6': - dependencies: - '@push.rocks/smartenv': 5.0.13 - '@push.rocks/smartjson': 5.2.0 - '@push.rocks/smartpromise': 4.2.3 - '@types/through2': 2.0.41 - through2: 4.0.2 - '@push.rocks/smarthash@3.2.7': dependencies: '@push.rocks/smartenv': 6.1.0 @@ -5006,17 +4927,10 @@ snapshots: - aws-crt - supports-color - '@push.rocks/smartjson@5.2.0': - dependencies: - '@push.rocks/smartenv': 5.0.13 - '@push.rocks/smartstring': 4.1.0 - fast-json-stable-stringify: 2.1.0 - lodash.clonedeep: 4.5.0 - '@push.rocks/smartjson@6.0.1': dependencies: - '@push.rocks/smartenv': 6.0.0 - '@push.rocks/smartstring': 4.1.0 + '@push.rocks/smartenv': 6.1.0 + '@push.rocks/smartstring': 4.1.1 fast-json-stable-stringify: 2.1.0 '@push.rocks/smartlog-destination-local@9.0.2': @@ -5033,11 +4947,11 @@ snapshots: '@push.rocks/smartlog@3.2.2': dependencies: '@api.global/typedrequest-interfaces': 3.0.19 - '@push.rocks/consolecolor': 2.0.3 + '@push.rocks/consolecolor': 2.0.4 '@push.rocks/isounique': 1.0.5 '@push.rocks/smartclickhouse': 2.2.0 - '@push.rocks/smarthash': 3.2.6 - '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smarthash': 3.2.7 + '@push.rocks/smartpromise': 4.2.4 '@push.rocks/smarttime': 4.2.3 '@push.rocks/webrequest': 4.0.5 '@tsclass/tsclass': 9.5.1 @@ -5128,7 +5042,7 @@ snapshots: '@push.rocks/smartnftables@1.2.0': dependencies: '@push.rocks/smartlog': 3.2.2 - '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartpromise': 4.2.4 '@push.rocks/smartnpm@2.1.0': dependencies: @@ -5182,8 +5096,6 @@ snapshots: - typescript - utf-8-validate - '@push.rocks/smartpromise@4.2.3': {} - '@push.rocks/smartpromise@4.2.4': {} '@push.rocks/smartpuppeteer@2.0.6(typescript@6.0.3)': @@ -5229,7 +5141,7 @@ snapshots: '@push.rocks/smartrx@3.0.10': dependencies: - '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartpromise': 4.2.4 rxjs: 7.8.2 '@push.rocks/smartserve@2.0.4': @@ -5283,19 +5195,15 @@ snapshots: '@push.rocks/smartpromise': 4.2.4 '@push.rocks/smartrx': 3.0.10 - '@push.rocks/smartstring@4.1.0': - dependencies: - '@push.rocks/isounique': 1.0.5 - '@push.rocks/smartstring@4.1.1': dependencies: '@push.rocks/isounique': 1.0.5 '@push.rocks/smarttime@4.2.3': dependencies: - '@push.rocks/lik': 6.3.1 - '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/lik': 6.4.1 + '@push.rocks/smartdelay': 3.1.0 + '@push.rocks/smartpromise': 4.2.4 croner: 10.0.1 date-fns: 4.1.0 dayjs: 1.11.20 @@ -5347,28 +5255,17 @@ snapshots: '@push.rocks/webrequest@4.0.5': dependencies: - '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartenv': 6.0.0 + '@push.rocks/smartdelay': 3.1.0 + '@push.rocks/smartenv': 6.1.0 '@push.rocks/smartjson': 6.0.1 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/webstore': 2.0.20 + '@push.rocks/smartpromise': 4.2.4 + '@push.rocks/webstore': 2.0.22 '@push.rocks/websetup@3.0.20': dependencies: '@push.rocks/smartpromise': 4.2.4 '@tsclass/tsclass': 9.5.1 - '@push.rocks/webstore@2.0.20': - dependencies: - '@api.global/typedrequest-interfaces': 3.0.19 - '@push.rocks/lik': 6.3.1 - '@push.rocks/smartenv': 5.0.13 - '@push.rocks/smartjson': 5.2.0 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smartrx': 3.0.10 - '@tempfix/idb': 8.0.3 - fake-indexeddb: 5.0.2 - '@push.rocks/webstore@2.0.22': dependencies: '@api.global/typedrequest-interfaces': 3.0.19 @@ -5702,8 +5599,6 @@ snapshots: '@smithy/core': 3.24.1 tslib: 2.8.1 - '@tempfix/idb@8.0.3': {} - '@tempfix/lenis@1.3.20': {} '@tokenizer/inflate@0.4.1': @@ -5757,8 +5652,6 @@ snapshots: '@types/mime-types@2.1.4': {} - '@types/minimatch@5.1.2': {} - '@types/ms@2.1.0': {} '@types/mute-stream@0.0.4': @@ -6221,8 +6114,6 @@ snapshots: transitivePeerDependencies: - supports-color - fake-indexeddb@5.0.2: {} - fake-indexeddb@6.2.5: {} fast-deep-equal@3.1.3: {} @@ -6329,7 +6220,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 math-intrinsics: 1.1.0 get-proto@1.0.1: @@ -6383,10 +6274,6 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - hasown@2.0.3: dependencies: function-bind: 1.1.2 @@ -6570,8 +6457,6 @@ snapshots: dependencies: p-locate: 4.1.0 - lodash.clonedeep@4.5.0: {} - longest-streak@3.1.0: {} lower-case@1.1.4: {} @@ -7027,8 +6912,6 @@ snapshots: dependencies: lower-case: 1.1.4 - node-forge@1.3.3: {} - node-forge@1.4.0: {} object-keys@1.1.1: {} diff --git a/rust/crates/rustproxy-http/src/proxy_service.rs b/rust/crates/rustproxy-http/src/proxy_service.rs index 764e9fe..271b7d0 100644 --- a/rust/crates/rustproxy-http/src/proxy_service.rs +++ b/rust/crates/rustproxy-http/src/proxy_service.rs @@ -45,6 +45,9 @@ pub struct ConnActivity { /// increments on creation and decrements on Drop, keeping the watchdog aware that /// a response body is still streaming after the request handler has returned. active_requests: Option>, + /// Active upgraded tunnel counter. When set, upgraded WebSocket streams keep the + /// HTTP keep-alive/lifetime watchdog out of the tunnel lifecycle. + active_upgrades: Option>, /// Protocol cache key for Alt-Svc discovery. When set, `build_streaming_response` /// checks the backend's original response headers for Alt-Svc before our /// ResponseFilter injects its own. None when not in auto-detect mode or after H3 failure. @@ -61,6 +64,7 @@ impl ConnActivity { last_activity: Arc::new(AtomicU64::new(0)), start: std::time::Instant::now(), active_requests: None, + active_upgrades: None, alt_svc_cache_key: None, alt_svc_request_url: None, } @@ -488,6 +492,7 @@ impl HttpProxyService { // (no request in progress and none started recently). let last_activity = Arc::new(AtomicU64::new(0)); let active_requests = Arc::new(AtomicU64::new(0)); + let active_upgrades = Arc::new(AtomicU64::new(0)); let start = std::time::Instant::now(); // Connection-level frontend protocol tracker: the first request detects @@ -498,6 +503,7 @@ impl HttpProxyService { let la_inner = Arc::clone(&last_activity); let ar_inner = Arc::clone(&active_requests); + let au_inner = Arc::clone(&active_upgrades); let cancel_inner = cancel.clone(); let vpn_info = Arc::new(vpn_info); let service = hyper::service::service_fn(move |req: Request| { @@ -522,6 +528,7 @@ impl HttpProxyService { last_activity: Arc::clone(&la_inner), start, active_requests: Some(Arc::clone(&ar_inner)), + active_upgrades: Some(Arc::clone(&au_inner)), alt_svc_cache_key: None, alt_svc_request_url: None, }; @@ -572,8 +579,14 @@ impl HttpProxyService { loop { tokio::time::sleep(check_interval).await; - // Check max connection lifetime (unconditional — even active connections - // must eventually be recycled to prevent resource accumulation). + // Upgraded tunnels have their own WebSocket watchdog and lifetime. + if active_upgrades.load(Ordering::Relaxed) > 0 { + last_seen = last_activity.load(Ordering::Relaxed); + continue; + } + + // Check max connection lifetime (unconditional for regular HTTP — even active + // connections must eventually be recycled to prevent resource accumulation). if start.elapsed() >= max_lifetime { debug!("HTTP connection exceeded max lifetime ({}s) from {}", max_lifetime.as_secs(), peer_addr); @@ -789,11 +802,7 @@ impl HttpProxyService { cancel, &ip_str, is_h2_websocket, - if is_h2_websocket { - Some(conn_activity.clone()) - } else { - None - }, + Some(conn_activity.clone()), ) .await; // Note: for WebSocket, connection_ended is called inside @@ -3286,8 +3295,19 @@ impl HttpProxyService { let upstream_key_owned = upstream_key.to_string(); let ws_inactivity_timeout = self.ws_inactivity_timeout; let ws_max_lifetime = self.ws_max_lifetime; + let ws_request_guard = conn_activity + .as_ref() + .and_then(|ca| ca.active_requests.as_ref()) + .map(|counter| ActiveRequestGuard::new(Arc::clone(counter))); + let ws_upgrade_guard = conn_activity + .as_ref() + .and_then(|ca| ca.active_upgrades.as_ref()) + .map(|counter| ActiveRequestGuard::new(Arc::clone(counter))); tokio::spawn(async move { + let _ws_request_guard = ws_request_guard; + let _ws_upgrade_guard = ws_upgrade_guard; + // RAII guard: ensures connection_ended is called even if this task panics struct WsUpstreamGuard { selector: UpstreamSelector, diff --git a/rust/crates/rustproxy-passthrough/src/tcp_listener.rs b/rust/crates/rustproxy-passthrough/src/tcp_listener.rs index b12f4d4..740a2b2 100644 --- a/rust/crates/rustproxy-passthrough/src/tcp_listener.rs +++ b/rust/crates/rustproxy-passthrough/src/tcp_listener.rs @@ -154,6 +154,9 @@ pub struct ConnectionConfig { pub max_connections: u64, } +const DEFAULT_WS_INACTIVITY_TIMEOUT_MS: u64 = 3_600_000; +const DEFAULT_WS_MAX_LIFETIME_MS: u64 = 86_400_000; + impl Default for ConnectionConfig { fn default() -> Self { Self { @@ -225,8 +228,8 @@ impl TcpListenerManager { http_proxy_svc.set_connection_timeouts( std::time::Duration::from_millis(conn_config.socket_timeout_ms), std::time::Duration::from_millis(conn_config.max_connection_lifetime_ms), - std::time::Duration::from_millis(conn_config.socket_timeout_ms), - std::time::Duration::from_millis(conn_config.max_connection_lifetime_ms), + std::time::Duration::from_millis(DEFAULT_WS_INACTIVITY_TIMEOUT_MS), + std::time::Duration::from_millis(DEFAULT_WS_MAX_LIFETIME_MS), ); let http_proxy = Arc::new(http_proxy_svc); let conn_tracker = Arc::new(ConnectionTracker::new( @@ -266,8 +269,8 @@ impl TcpListenerManager { http_proxy_svc.set_connection_timeouts( std::time::Duration::from_millis(conn_config.socket_timeout_ms), std::time::Duration::from_millis(conn_config.max_connection_lifetime_ms), - std::time::Duration::from_millis(conn_config.socket_timeout_ms), - std::time::Duration::from_millis(conn_config.max_connection_lifetime_ms), + std::time::Duration::from_millis(DEFAULT_WS_INACTIVITY_TIMEOUT_MS), + std::time::Duration::from_millis(DEFAULT_WS_MAX_LIFETIME_MS), ); let http_proxy = Arc::new(http_proxy_svc); let conn_tracker = Arc::new(ConnectionTracker::new( @@ -313,8 +316,8 @@ impl TcpListenerManager { http_proxy_svc.set_connection_timeouts( std::time::Duration::from_millis(config.socket_timeout_ms), std::time::Duration::from_millis(config.max_connection_lifetime_ms), - std::time::Duration::from_millis(config.socket_timeout_ms), - std::time::Duration::from_millis(config.max_connection_lifetime_ms), + std::time::Duration::from_millis(DEFAULT_WS_INACTIVITY_TIMEOUT_MS), + std::time::Duration::from_millis(DEFAULT_WS_MAX_LIFETIME_MS), ); self.http_proxy = Arc::new(http_proxy_svc); diff --git a/test/test.websocket-e2e.ts b/test/test.websocket-e2e.ts index 434dc86..fb8c76c 100644 --- a/test/test.websocket-e2e.ts +++ b/test/test.websocket-e2e.ts @@ -415,4 +415,68 @@ tap.test('should handle large WebSocket messages', async () => { await assertPortsFree([PROXY_PORT, BACKEND_PORT]); }); +// ─── Test 7: Idle WebSocket outlives short HTTP socket timeout ─── +tap.test('should keep idle WebSocket open beyond HTTP socket timeout', async (tools) => { + tools.timeout(15000); + + const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2); + let proxy: SmartProxy | undefined; + let ws: WebSocket | undefined; + + const backendServer = http.createServer(); + const wss = new WebSocketServer({ server: backendServer }); + + wss.on('connection', (backendWs) => { + backendWs.on('message', (data) => { + backendWs.send(`echo: ${data.toString()}`); + }); + }); + + try { + await new Promise((resolve) => { + backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve()); + }); + + proxy = new SmartProxy({ + socketTimeout: 1000, + maxConnectionLifetime: 1000, + routes: [{ + name: 'ws-idle-timeout-route', + match: { ports: PROXY_PORT }, + action: { + type: 'forward', + targets: [{ host: '127.0.0.1', port: BACKEND_PORT }], + websocket: { enabled: true }, + }, + }], + }); + await proxy.start(); + + const connection = connectWs( + `ws://127.0.0.1:${PROXY_PORT}/`, + { Host: 'test.local' }, + ); + ws = connection.ws; + await connection.opened; + + await new Promise((resolve) => setTimeout(resolve, 6500)); + expect(ws.readyState).toEqual(WebSocket.OPEN); + + ws.send('still open'); + await waitFor(() => connection.messages.length >= 1); + expect(connection.messages[0]).toEqual('echo: still open'); + } finally { + if (ws && ws.readyState !== WebSocket.CLOSED) { + await closeWs(ws); + } + if (proxy) { + await proxy.stop(); + } + wss.close(); + await new Promise((resolve) => backendServer.close(() => resolve())); + await new Promise((r) => setTimeout(r, 500)); + await assertPortsFree([PROXY_PORT, BACKEND_PORT]); + } +}); + export default tap.start();