Skip to content

Commit 6aa17f7

Browse files
joe4devclaude
andcommitted
fix(init): keep container alive on hot-reload reset during init
A hot-reload code change detected while the init phase is still running fires Reset("HotReload"), which aborts init with ErrInitResetReceived. main.go treated this as terminal and returned, killing the container and failing the in-flight invocation. When hot reloading is enabled, wait for the in-flight reset to complete (ordering the reset's Server.Release before the ready signal so it cannot cancel the first invoke's reservation), then fall through to signal ready and keep the container alive, letting the next invoke re-init with the reloaded code via suppressed init. Non-hot-reload resets (sandbox teardown, standalone /test/reset) still exit as before. CustomInteropServer gains a buffered resetDone signal, fired non-blockingly after delegate.Reset returns, that the main flow waits on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5626043 commit 6aa17f7

3 files changed

Lines changed: 39 additions & 6 deletions

File tree

cmd/localstack/awsutil.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,15 @@ func resetListener(changeChannel <-chan bool, server *CustomInteropServer) {
156156

157157
}
158158

159+
// isHotReloadingEnabled reports whether hot reloading is configured. When
160+
// LOCALSTACK_HOT_RELOADING_PATHS is unset, strings.Split yields a single empty
161+
// element, which means disabled.
162+
func isHotReloadingEnabled(targetPaths []string) bool {
163+
return !(len(targetPaths) == 1 && targetPaths[0] == "")
164+
}
165+
159166
func RunHotReloadingListener(server *CustomInteropServer, targetPaths []string, ctx context.Context, fileWatcherStrategy string) {
160-
if len(targetPaths) == 1 && targetPaths[0] == "" {
167+
if !isHotReloadingEnabled(targetPaths) {
161168
log.Debugln("Hot reloading disabled.")
162169
return
163170
}

cmd/localstack/custom_interop.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ type CustomInteropServer struct {
3838
// LocalStack instead of a synthesized one. Written from the runtime API handler flow and
3939
// read from the main flow after init failed, hence atomic.
4040
initErrorPayload atomic.Value
41+
// resetDone signals (non-blocking, buffered-1) that a Reset has fully completed
42+
// — i.e. delegate.Reset returned after the underlying Server.Release. The main flow
43+
// waits on this when a hot-reload reset aborts the init phase, so the ready signal is
44+
// ordered after the reset's Release and cannot cancel the first invoke's reservation.
45+
resetDone chan struct{}
4146
}
4247

4348
type LocalStackAdapter struct {
@@ -109,6 +114,7 @@ func NewCustomInteropServer(lsOpts *LsOpts, delegate interop.Server, logCollecto
109114
RuntimeId: lsOpts.RuntimeId,
110115
},
111116
eventsAPI: eventsAPI,
117+
resetDone: make(chan struct{}, 1),
112118
}
113119

114120
// TODO: extract this
@@ -339,7 +345,15 @@ func (c *CustomInteropServer) Reserve(id string, traceID, lambdaSegmentID string
339345

340346
func (c *CustomInteropServer) Reset(reason string, timeoutMs int64) (*statejson.ResetDescription, error) {
341347
log.Traceln("Reset called")
342-
return c.delegate.Reset(reason, timeoutMs)
348+
resp, err := c.delegate.Reset(reason, timeoutMs)
349+
// delegate.Reset has returned, so the reset (including Server.Release) is complete.
350+
// Signal a waiter (the main flow on a hot-reload reset during init) without blocking
351+
// normal post-init reloads, which have no reader.
352+
select {
353+
case c.resetDone <- struct{}{}:
354+
default:
355+
}
356+
return resp, err
343357
}
344358

345359
func (c *CustomInteropServer) AwaitRelease() (*statejson.ReleaseResponse, error) {

cmd/localstack/main.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ func main() {
266266
if err != nil {
267267
log.Fatalln(err)
268268
}
269+
hotReloadEnabled := isHotReloadingEnabled(lsOpts.HotReloadingPaths)
269270
go RunHotReloadingListener(interopServer, lsOpts.HotReloadingPaths, fileWatcherContext, lsOpts.FileWatcherStrategy)
270271

271272
log.Debugf("Awaiting initialization of runtime api at %s.", runtimeAPIAddress)
@@ -352,10 +353,21 @@ func main() {
352353
// INIT_REPORT(phase=invoke) line for the retried (folded-in) init.
353354
log.Debugln("Init failed; deferring to first invocation (on-demand suppressed init).")
354355
case errors.Is(initErr, rapidcore.ErrInitResetReceived):
355-
// An external reset (e.g. hot reloading) aborted the init phase: exit without
356-
// reporting an init error; the container exit surfaces the failure.
357-
log.Errorln("Runtime init was reset before completing. Exiting.")
358-
return
356+
if !hotReloadEnabled {
357+
// A reset aborted the init phase (e.g. sandbox teardown or a standalone
358+
// /test/reset): exit without reporting an init error; the container exit
359+
// surfaces the failure.
360+
log.Errorln("Runtime init was reset before completing. Exiting.")
361+
return
362+
}
363+
// Hot reloading aborted the in-progress init. Reset("HotReload") has already
364+
// cleared rapidcore state (s.Clear + runtimeNotStarted) for a fresh suppressed
365+
// init at the first invocation. Wait for that reset to finish so its Server.Release
366+
// is ordered before the ready signal below (otherwise a late Release could cancel
367+
// the first invoke's reservation), then fall through to signal ready and keep the
368+
// container alive so the next invoke re-inits with the reloaded code.
369+
log.Infoln("Runtime init was reset by hot reloading; deferring to suppressed init on first invocation.")
370+
<-interopServer.resetDone
359371
default:
360372
// Provisioned concurrency / Managed Instances: report the failure now and exit,
361373
// failing the provisioning operation. ReportInitFailure forwards the runtime's own

0 commit comments

Comments
 (0)