fix(hooks): cut synchronous work from session start/end paths · Entire

fix(hooks): cut synchronous work from session start/end paths

2ed7153→main·

suhaanthayyil·6d ago·6 files·+280 added/-1 removed

Profiling (perf spans + live timing against a hanging/unreachable API host) traced the "Claude Code startup/exit is noticeably slower" report in #450 to a single hot spot: handleLifecycleSessionStart's trails- enablement cache refresh dialed the network synchronously on the SessionStart hook whenever the hourly cache was unknown/stale (first session in a repo, TTL expiry, or a changed auth key).

Before (10-run medians, session-start hook, DEBUG perf spans): - warm cache / fast-failing network: ~35-40ms - stale cache + unreachable/blackholed API host: ~1030ms every time (bounded by trailEnablementSessionStartRefreshTimeout=1s, since ResolveDataAPIToken's /.well-known/entire-api.json discovery dial has no loopback/offline fast path)

Stop and SessionEnd (exit) hooks were already fast (~40-50ms) since the refresh only runs on SessionStart.

Fix: the network refresh now runs in a detached subprocess (entire __refresh_trail_enablement), spawned only when the local, network-free cache check finds the value unknown — mirroring the existing detached-analytics pattern in cmd/entire/cli/telemetry. The "forge not supported" case still resolves locally (no network, no spawn). SessionStart itself now only resolves the scope (local git remote + auth-key lookup) and decides whether to spawn; it never blocks on TrailsEnabled.

After (same matrix): session-start stays ~35-40ms regardless of network reachability; the detached refresh still completes (or gives up) on its own bounded timeout, so the cache is still populated once the host responds.

Added a regression test asserting SessionStart never dials a sentinel httptest server synchronously and completes in under 1s even against a deliberately slow host, plus a test that the detached refresh itself is still time-bounded against a host that never responds.

Fixes #450

Changes

6

1 unmodified line

2
3
4
5
6
7
8
9
10
11
12
13
14
15
2218 unmodified lines

2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331

1 unmodified line

import (
    "context"
    "net"
    "net/http"
    "net/http/httptest"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
    "sync/atomic"
    "testing"
    "time"

2218 unmodified lines

t.Fatalf("back-to-back checkpoint B after stale hook = %d, want 3", got)
    }
}

// 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.

func TestHandleLifecycleSessionStart_NoSynchronousNetworkDialForTrailEnablement(t *testing.T) {
    setupStopTestRepo(t)
    runGitInDir(t, ".", "remote", "add", "origin", "https://github.com/entirehq/example.git")

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)
    }))
defersentinel.Close()
    }
    // TestRunTrailEnablementRefresh_BoundedByTimeoutAgainstUnresponsiveHost
// verifies the deferred work spawned for #450 still completes (or at least
// gives up) within its own bounded timeout when the API host never
// responds — the network work that used to block SessionStart must still
// happen, just out of the hook's critical path, and it must not hang forever.

func TestRunTrailEnablementRefresh_BoundedByTimeoutAgainstUnresponsiveHost(t *testing.T) {
    setupStopTestRepo(t)
    runGitInDir(t, ".", "remote", "add", "origin", "https://github.com/entirehq/example.git")

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
            }
            // Accept the connection but never write anything back (no TLS
            // handshake, no HTTP response) — simulates a blackholed/firewalled
            // host, which is what triggered the original 1s stall per call.
            _ = conn
        }
    }()
    Setenv("ENTIRE_API_BASE_URL", "https://"+ln.Addr().String())

start := time.Now()
    refreshErr := runTrailEnablementRefresh(context.Background())
    el elapsed := time.Since(start)

// Best-effort: network failure must not surface as a hard error.
    require.NoError(t, refreshErr)
    if elapsed > trailEnablementRefreshTimeout+2*time.Second {
        t.Fatalf("runTrailEnablementRefresh took %v, expected to give up within roughly %v", elapsed, trailEnablementRefreshTimeout)
    }
}

Mcmd/entire/cli/lifecycle_test.go+99

139 unmodified lines

140
141
142
143
144
145
146

139 unmodified lines

cmd.AddCommand(newRunnerCmd()) // 'runner' (setup/tune runners); hidden during maturation
    cmd.AddCommand(newSendAnalyticsCmd())
    cmd.AddCommand(newCurlBashPostInstallCmd())
    cmd.AddCommand(newRefreshTrailEnablementCmd())

cmd.SetVersionTemplate(versionString())

Mcmd/entire/cli/root.go+1

15 unmodified lines

16
17
18
19
20
21
22
23
24
25
26
27
170 unmodified lines

198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
1 unmodified line

214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
207
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285

15 unmodified lines

"github.com/entireio/cli/cmd/entire/cli/gitremote"
    "github.com/entireio/cli/cmd/entire/cli/jsonutil"
    "github.com/entireio/cli/cmd/entire/cli/logging"
    "github.com/entireio/cli/cmd/entire/cli/paths"
    "github.com/entireio/cli/cmd/entire/cli/session"
    "github.com/entireio/cli/cmd/entire/cli/settings"
    "github.com/entireio/cli/cmd/entire/cli/validation"

"github.com/spf13/cobra"
)

const (
170 unmodified lines

return nil
}

// refreshTrailsEnabledCacheIfStaleForScope refreshes the trails-enablement
// cache when it's unknown/expired for scope. Callers on hot, latency-sensitive
// paths (SessionStart) must not block on this: resolving the API token and
// dialing TrailsEnabled can stall for seconds when the host is slow or
// unreachable (VPN, firewall, offline — see #450). Instead of doing that
// network work inline, hand it off to a detached `__refresh_trail_enablement`
// subprocess and return immediately; a later SessionStart will observe the
// freshly written cache once the subprocess completes. The "not supported"
// case is answered locally (no network) since it's free.
func refreshTrailsEnabledCacheIfStaleForScope(ctx context.Context, scope trailEnablementScope) error {
    if cachedTrailsEnablementForScope(ctx, scope, time.Now()) != trailEnablementCacheUnknown {
        return nil
    }

if !scope.Supported {
        return saveTrailsEnabledForScope(ctx, scope, false, time.Now())
    }
    spawnDetachedTrailEnablementRefresh(ctx)
    return nil
}

// runTrailEnablementRefresh performs the actual (potentially slow) network
// refresh. It is invoked from the detached `__refresh_trail_enablement`
// subprocess spawned by refreshTrailsEnabledCacheIfStaleForScope, never
// synchronously from a hook path.
func runTrailEnablementRefresh(ctx context.Context) error {
    ctx, cancel := context.WithTimeout(ctx, trailEnablementRefreshTimeout)
    defer cancel()

scope, err := currentTrailEnablementScope(ctx)
    if err != nil {
        return nil
    }
    // Another process (e.g. a fast-following SessionStart, or a concurrent
    // refresh already in flight) may have populated the cache first.
    if cachedTrailsEnablementForScope(ctx, scope, time.Now()) != trailEnablementCacheUnknown {
        return nil
    }
    if !scope.Supported {
        return saveTrailsEnabledForScope(ctx, scope, false, time.Now())
    }
    client, err := NewAuthenticatedAPIClient(ctx, false)
    if err != nil {
        return err
    }
    _, err = refreshTrailsEnabledCacheForScope(ctx, client, scope)
    return err
}

// trailRefreshSpawn is the process-spawn seam used by
// spawnDetachedTrailEnablementRefresh. Swapped in tests so they can assert
// SessionStart never blocks on it without forking a real subprocess (a real
// `go test` binary doesn't understand `__refresh_trail_enablement` as an
// argument). Production code always uses spawnDetachedTrailRefreshProcess.
var trailRefreshSpawn = spawnDetachedTrailRefreshProcess

// spawnDetachedTrailEnablementRefresh starts a detached child process that
// runs runTrailEnablementRefresh in the background. Best-effort: if the
// worktree root can't be resolved or the subprocess can't be spawned, the
// cache simply stays unknown and the next SessionStart tries again.
func spawnDetachedTrailEnablementRefresh(ctx context.Context) {
    worktreeRoot, err := paths.WorktreeRoot(ctx)
    if err != nil {
        return
    }
    trailRefreshSpawn(worktreeRoot)
}

// newRefreshTrailEnablementCmd creates the hidden command that performs the
// (potentially slow) trails-enablement network refresh out of band. It is
// invoked by spawnDetachedTrailEnablementRefresh from a detached subprocess
// and should not be called directly.
func newRefreshTrailEnablementCmd() *cobra.Command {
    return &cobra.Command{
        Use:    "__refresh_trail_enablement",
        Hidden: true,
        Args:   cobra.NoArgs,
        RunE: func(cmd *cobra.Command, _ []string) error {
            return runTrailEnablementRefresh(cmd.Context())
        },
    }
}

func refreshTrailsEnabledCache(ctx context.Context, client *api.Client) (bool, error) {
    scope, err := currentTrailEnablementScope(ctx)
    if err != nil {
        return nil
    }