11import * as os from "os" ;
22import * as path from "path" ;
3- import { getControlPath } from "./sshConnectionPool" ;
4- import type { SSHRuntimeConfig } from "./SSHRuntime" ;
3+ import { getControlPath , SSHConnectionPool , type SSHRuntimeConfig } from "./sshConnectionPool" ;
54
65describe ( "sshConnectionPool" , ( ) => {
76 describe ( "getControlPath" , ( ) => {
@@ -59,7 +58,9 @@ describe("sshConnectionPool", () => {
5958 expect ( getControlPath ( config1 ) ) . not . toBe ( getControlPath ( config2 ) ) ;
6059 } ) ;
6160
62- test ( "different srcBaseDirs produce different controlPaths" , ( ) => {
61+ test ( "different srcBaseDirs produce same controlPaths (connection shared)" , ( ) => {
62+ // srcBaseDir is intentionally excluded from connection key -
63+ // workspaces on the same host share health tracking and multiplexing
6364 const config1 : SSHRuntimeConfig = {
6465 host : "test.com" ,
6566 srcBaseDir : "/work1" ,
@@ -69,7 +70,7 @@ describe("sshConnectionPool", () => {
6970 srcBaseDir : "/work2" ,
7071 } ;
7172
72- expect ( getControlPath ( config1 ) ) . not . toBe ( getControlPath ( config2 ) ) ;
73+ expect ( getControlPath ( config1 ) ) . toBe ( getControlPath ( config2 ) ) ;
7374 } ) ;
7475
7576 test ( "controlPath is in tmpdir with expected format" , ( ) => {
@@ -134,3 +135,151 @@ describe("username isolation", () => {
134135 expect ( controlPath ) . toMatch ( / m u x - s s h - [ a - f 0 - 9 ] { 12 } $ / ) ;
135136 } ) ;
136137} ) ;
138+
139+ describe ( "SSHConnectionPool" , ( ) => {
140+ describe ( "health tracking" , ( ) => {
141+ test ( "getConnectionHealth returns undefined for unknown connection" , ( ) => {
142+ const pool = new SSHConnectionPool ( ) ;
143+ const config : SSHRuntimeConfig = {
144+ host : "unknown.example.com" ,
145+ srcBaseDir : "/work" ,
146+ } ;
147+
148+ expect ( pool . getConnectionHealth ( config ) ) . toBeUndefined ( ) ;
149+ } ) ;
150+
151+ test ( "markHealthy sets connection to healthy state" , ( ) => {
152+ const pool = new SSHConnectionPool ( ) ;
153+ const config : SSHRuntimeConfig = {
154+ host : "test.example.com" ,
155+ srcBaseDir : "/work" ,
156+ } ;
157+
158+ pool . markHealthy ( config ) ;
159+ const health = pool . getConnectionHealth ( config ) ;
160+
161+ expect ( health ) . toBeDefined ( ) ;
162+ expect ( health ! . status ) . toBe ( "healthy" ) ;
163+ expect ( health ! . consecutiveFailures ) . toBe ( 0 ) ;
164+ expect ( health ! . lastSuccess ) . toBeInstanceOf ( Date ) ;
165+ } ) ;
166+
167+ test ( "reportFailure puts connection into backoff" , ( ) => {
168+ const pool = new SSHConnectionPool ( ) ;
169+ const config : SSHRuntimeConfig = {
170+ host : "test.example.com" ,
171+ srcBaseDir : "/work" ,
172+ } ;
173+
174+ // Mark healthy first
175+ pool . markHealthy ( config ) ;
176+ expect ( pool . getConnectionHealth ( config ) ?. status ) . toBe ( "healthy" ) ;
177+
178+ // Report a failure
179+ pool . reportFailure ( config , "Connection refused" ) ;
180+ const health = pool . getConnectionHealth ( config ) ;
181+
182+ expect ( health ?. status ) . toBe ( "unhealthy" ) ;
183+ expect ( health ?. consecutiveFailures ) . toBe ( 1 ) ;
184+ expect ( health ?. lastError ) . toBe ( "Connection refused" ) ;
185+ expect ( health ?. backoffUntil ) . toBeDefined ( ) ;
186+ } ) ;
187+
188+ test ( "resetBackoff clears backoff state after failed probe" , async ( ) => {
189+ const pool = new SSHConnectionPool ( ) ;
190+ const config : SSHRuntimeConfig = {
191+ host : "nonexistent.invalid.host.test" ,
192+ srcBaseDir : "/work" ,
193+ } ;
194+
195+ // Trigger a failure via acquireConnection (will fail to connect)
196+ await expect ( pool . acquireConnection ( config , 1000 ) ) . rejects . toThrow ( ) ;
197+
198+ // Verify we're now in backoff
199+ const healthBefore = pool . getConnectionHealth ( config ) ;
200+ expect ( healthBefore ?. status ) . toBe ( "unhealthy" ) ;
201+ expect ( healthBefore ?. backoffUntil ) . toBeDefined ( ) ;
202+
203+ // Reset backoff
204+ pool . resetBackoff ( config ) ;
205+ const healthAfter = pool . getConnectionHealth ( config ) ;
206+
207+ expect ( healthAfter ) . toBeDefined ( ) ;
208+ expect ( healthAfter ! . status ) . toBe ( "unknown" ) ;
209+ expect ( healthAfter ! . consecutiveFailures ) . toBe ( 0 ) ;
210+ expect ( healthAfter ! . backoffUntil ) . toBeUndefined ( ) ;
211+ } ) ;
212+ } ) ;
213+
214+ describe ( "acquireConnection" , ( ) => {
215+ test ( "returns immediately for known healthy connection" , async ( ) => {
216+ const pool = new SSHConnectionPool ( ) ;
217+ const config : SSHRuntimeConfig = {
218+ host : "test.example.com" ,
219+ srcBaseDir : "/work" ,
220+ } ;
221+
222+ // Mark as healthy first
223+ pool . markHealthy ( config ) ;
224+
225+ // Should return immediately without probing
226+ const start = Date . now ( ) ;
227+ await pool . acquireConnection ( config ) ;
228+ const elapsed = Date . now ( ) - start ;
229+
230+ // Should be nearly instant (< 50ms)
231+ expect ( elapsed ) . toBeLessThan ( 50 ) ;
232+ } ) ;
233+
234+ test ( "throws immediately when in backoff" , async ( ) => {
235+ const pool = new SSHConnectionPool ( ) ;
236+ const config : SSHRuntimeConfig = {
237+ host : "nonexistent.invalid.host.test" ,
238+ srcBaseDir : "/work" ,
239+ } ;
240+
241+ // Trigger a failure to put connection in backoff
242+ await expect ( pool . acquireConnection ( config , 1000 ) ) . rejects . toThrow ( ) ;
243+
244+ // Second call should throw immediately with backoff message
245+ await expect ( pool . acquireConnection ( config ) ) . rejects . toThrow ( / i n b a c k o f f / ) ;
246+ } ) ;
247+
248+ test ( "getControlPath returns deterministic path" , ( ) => {
249+ const pool = new SSHConnectionPool ( ) ;
250+ const config : SSHRuntimeConfig = {
251+ host : "test.example.com" ,
252+ srcBaseDir : "/work" ,
253+ } ;
254+
255+ const path1 = pool . getControlPath ( config ) ;
256+ const path2 = pool . getControlPath ( config ) ;
257+
258+ expect ( path1 ) . toBe ( path2 ) ;
259+ expect ( path1 ) . toBe ( getControlPath ( config ) ) ;
260+ } ) ;
261+ } ) ;
262+
263+ describe ( "singleflighting" , ( ) => {
264+ test ( "concurrent acquireConnection calls share same probe" , async ( ) => {
265+ const pool = new SSHConnectionPool ( ) ;
266+ const config : SSHRuntimeConfig = {
267+ host : "nonexistent.invalid.host.test" ,
268+ srcBaseDir : "/work" ,
269+ } ;
270+
271+ // All concurrent calls should share the same probe and get same result
272+ const results = await Promise . allSettled ( [
273+ pool . acquireConnection ( config , 1000 ) ,
274+ pool . acquireConnection ( config , 1000 ) ,
275+ pool . acquireConnection ( config , 1000 ) ,
276+ ] ) ;
277+
278+ // All should be rejected (connection fails)
279+ expect ( results . every ( ( r ) => r . status === "rejected" ) ) . toBe ( true ) ;
280+
281+ // Only 1 failure should be recorded (not 3) - proves singleflighting worked
282+ expect ( pool . getConnectionHealth ( config ) ?. consecutiveFailures ) . toBe ( 1 ) ;
283+ } ) ;
284+ } ) ;
285+ } ) ;
0 commit comments