test(hooks): make #450 no-synchronous-dial assertion load-bearing · Entire

test(hooks): make #450 no-synchronous-dial assertion load-bearing

3aa23f4→main·

suhaanthayyil·6d ago·1 file·+44 added/-27 removed

The SessionStart guard pointed ENTIRE_API_BASE_URL at a plain-http sentinel, but api.RequireSecureURL rejects http before any dial — so a regression to inline dialing bailed pre-dial and the dialed/elapsed assertions could never fire; only the spawn-count check actually caught it. Point the API base at a blackholed https host that accepts the TCP connection but never answers: an inline dial now passes the security gate and stalls to the 1s session-start deadline, so a synchronous regression trips all three assertions (spawn hand-off, dialed>0, ~1s block). Verified by mutating the fix back to a synchronous refresh.

Changes

1

2 unmodified lines

3
4
5
6
7
6
7
8
2224 unmodified lines

2233
2234
2235
2238
2239
2240
2241
2242
2243
2236
2237
2238
2239
2240
2241
2242
2245
2246
2247
2248
2249
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2254
2255
2256
2257
2258
2259
2260
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
13 unmodified lines

2291
2292
2293
2280
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2287
2288
2289
2290
2291
2292
2310
2311
2312

2 unmodified lines

import (
    "context"
    "net"
    "net/http"
    "net/http/httptest"
    "os"
    "os/exec"
    "path/filepath"
2224 unmodified lines

}
}

// TestHandleLifecycleSessionStart_NoSynchronousNetworkDialForTrailEnablement
// guards against #450 (SessionStart hooks stalling Claude Code startup):
// the trails-enablement cache refresh must never dial the network from the
// SessionStart hook itself. A slow/unreachable API host previously added up
to trailEnablementSessionStartRefreshTimeout (1s) of synchronous latency to
// every session start once the hourly cache went stale.
// TestHandleLifecycleSessionStart_NoSynchronousNetworkForTrailEnablement
// guards against #450 (SessionStart hooks stalling Claude Code startup): the
// trails-enablement cache refresh must be handed off to a detached subprocess,
// never performed inline on the SessionStart hook path. A slow/unreachable API
// host previously added up to trailEnablementSessionStartRefreshTimeout (1s) of
// synchronous latency to every session start once the hourly cache went stale.
//
// The sentinel server responds slowly on purpose — if SessionStart ever dials
// it directly (regressing to the old synchronous behavior), this test proves
// it two ways: the sentinel is hit (dialed > 0) and/or the call takes far
// longer than a bare in-process hook should.
func TestHandleLifecycleSessionStart_NoSynchronousNetworkDialForTrailEnablement(t *testing.T) {
// The deterministic guarantee is the spawn seam: SessionStart must invoke the
// detached-refresh spawn exactly once and return without doing the network work
// itself. As a production-shaped backstop the API base points at a blackholed
// https host that accepts the TCP connection but never answers — so a
// regression that dials inline both contacts that host (dialed > 0) and burns
// the ~1s session-start budget instead of returning immediately. (Plain http
// would be rejected by api.RequireSecureURL before any dial, so the host must
// be https to actually exercise the synchronous-dial path.)
func TestHandleLifecycleSessionStart_NoSynchronousNetworkForTrailEnablement(t *testing.T) {
    setupStopTestRepo(t)
    runGitInDir(t, ".", "remote", "add", "origin", "https://github.com/entirehq/example.git")

// Blackhole https host: accept connections but never complete the TLS
    // handshake or respond, so an inline dial stalls until a timeout fires
    // (mirrors the unreachable-host case that motivated #450) rather than
    // failing fast.
    var dialed int32
    sentinel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
        atomic.AddInt32(&dialed, 1)
        time.Sleep(5 * time.Second)
        w.WriteHeader(http.StatusNotFound)
    }))
defer sentinel.Close()

t.Setenv("ENTIRE_API_BASE_URL", sentinel.URL)
    var lc net.ListenConfig
    ln, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0")
    require.NoError(t, err)
    defer ln.Close()
    go func() {
        for {
            conn, acceptErr := ln.Accept()
            if acceptErr != nil {
                return
            }
            atomic.AddInt32(&dialed, 1)
            _ = conn // hold open; never respond
        }
    }()

t.Setenv("ENTIRE_API_BASE_URL", "https://"+ln.Addr().String())

var spawnCount int32
    prevSpawn := trailRefreshSpawn
13 unmodified lines

}

start := time.Now()
    err := handleLifecycleSessionStart(context.Background(), ag, event)
    err = handleLifecycleSessionStart(context.Background(), ag, event)
    elapsed := time.Since(start)

require.NoError(t, err)
    // Deterministic guarantee: the network-capable refresh is delegated to the
    // detached spawn exactly once, never run inline.
    if got := atomic.LoadInt32(&spawnCount); got != 1 {
        
t.Fatalf("expected exactly one detached trail-enablement refresh spawn, got %d", got)
    }
    // Backstops: SessionStart neither contacted the API host nor blocked.
    if got := atomic.LoadInt32(&dialed); got != 0 {
        
t.Fatalf("SessionStart dialed the trails-enablement API synchronously (#450 regression); the refresh must run out of process")
    }
    if elapsed > time.Second {
        
t.Fatalf("handleLifecycleSessionStart took %v; trails-enablement refresh must be detached, not synchronous (#450)", elapsed)
    }
    if atomic.LoadInt32(&dialed) != 0 {
        
t.Fatalf("SessionStart dialed the trails-enablement API synchronously (#450 regression); the refresh must run out of process")
    }
    if atomic.LoadInt32(&spawnCount) != 1 {
        
t.Fatalf("expected exactly one detached trail-enablement refresh spawn, got %d", spawnCount)
    }
}

// TestRunTrailEnablementRefresh_BoundedByTimeoutAgainstUnresponsiveHost