| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
|
|
||
| initStart, initEnd := InitHandler(sandbox, functionVersion, timeout, bs) | ||
| initStart := InitHandler(sandbox, functionVersion, timeout, bs) | ||
| sandbox.AwaitInitCompletion() |
There was a problem hiding this comment.
[BUG] Waiting here is unbounded, which removes the only timeout enforcement RIE has over the init phase.
The init phase has no deadline of its own: initContext.Wait() is documented as "Timeout handling is managed upstream entirely" (internal/lambda/rapidcore/sandbox_api.go), and rapid.handleInit blocks inside doRuntimeDomainInit until the runtime calls /next, the process exits, or a Reset cancels the flow. The only thing that issues that Reset in RIE is the timeout goroutine started inside Server.Invoke:
go func() {
select {
case <-time.After(s.GetInvokeTimeout()):
timeoutChan <- ErrInvokeTimeout
...Because AwaitInitCompletion() now runs before sandbox.Invoke(...), that clock is never armed while init is pending. Concretely:
Bounding the wait keeps the fix while preserving the timeout path — on expiry, control falls through to Invoke, which arms its timer and drives the Reset/timeout response as before:
// rapidcore
func (s Server) AwaitInitCompletion(timeout time.Duration) {
select {
case <-s.initCompleted:
case <-time.After(timeout):
// init still running; Invoke() enforces the timeout and triggers the Reset
}
}// rie
initStart := InitHandler(sandbox, functionVersion, timeout, bs)
sandbox.AwaitInitCompletion(timeoutDuration)Note the reported value is already clamped with math.Min(..., timeoutDuration), so capping the wait does not change what gets printed in the successful case.
Sorry, something went wrong.
| } | ||
|
|
||
| func (s *Server) AwaitInitCompletion() { | ||
| <-s.initCompleted |
There was a problem hiding this comment.
[CONCURRENCY] initCompleted is the only channel field on Server accessed without the mutex, and both of its unguarded accesses are reachable from different goroutines: it is assigned in Init (line 521), closed by the awaitInitCompletion goroutine (line 217), and received from here.
Two concrete problems:
Following the existing setInitFailuresChan/getInitFailuresChan pattern and capturing the channel locally fixes both:
func (s Server) setInitCompletedChan() {
s.mutex.Lock()
defer s.mutex.Unlock()
s.initCompleted = make(chan struct{})
}
func (s Server) getInitCompletedChan() chan struct{} {
s.mutex.Lock()
defer s.mutex.Unlock()
return s.initCompleted
}
func (s Server) awaitInitCompletion() {
initCompleted := s.getInitCompletedChan() // capture before Wait()
initSuccess, initFailure := s.initContext.Wait()
close(initCompleted) // never closes a channel created by a later Init()
...
}
func (s Server) AwaitInitCompletion() {
if initCompleted := s.getInitCompletedChan(); initCompleted != nil {
<-initCompleted
}
}
Sorry, something went wrong.
|
Thanks for catching these. Both points are addressed in 1668238. For the timeout case, I moved the completion read until after Invoke instead of adding another timed wait before it. That keeps the existing invoke timer and reset path active while initialization is running, so there is still only one timeout window. The init completion time is captured when initContext.Wait() returns and used later when the report is printed. For the concurrency case, each init now owns its completion object, and the goroutine captures that object along with its init context and failure channel. Publishing the current completion object is mutex-protected, and calling AwaitInitCompletion before Init returns a zero time instead of blocking on a nil channel. The RIE initDone check is also protected so concurrent first requests cannot start initialization twice. Validation after the change:
|
Sorry, something went wrong.
There was a problem hiding this comment.
Reviewed: ddf2bc4..1668238
Files: 5
Comments: 2
Comments on lines outside the diff:
[internal/lambda/rie/handlers.go:133] [GENERAL] Now that Init Duration is measured correctly, the Duration field on the same REPORT line double counts it, so the report becomes self-inconsistent for the cold invoke.
invokeStart is taken immediately after init is kicked off, not after it completes. sandbox.Invoke then blocks on init internally: Server.Invoke waits in s.awaitInitialized() before calling FastInvoke. So printEndReports computes time.Now().Sub(invokeStart) over init time plus handler time. With the PR's own e2e example (1.2 s module import), the line reads roughly Init Duration: 1302.32 ms Duration: ~1305 ms, whereas Lambda's Duration covers only the invocation.
Since the completion timestamp is now available, the invoke window can start from it:
initEnd := sandbox.AwaitInitCompletion()
// ...
if !initStart.IsZero() && initEnd.After(invokeStart) {
invokeStart = initEnd
}Note this requires the wait to happen before printEndReports rather than inline in its argument list, so formatInitDuration would need to return the timestamp (or take it) instead of calling AwaitInitCompletion itself. If you'd rather keep this PR narrowly scoped to Init Duration, that's reasonable — but the overlap is worth calling out explicitly, since the fix is what makes it visible.
Sorry, something went wrong.
| memorySize := GetenvWithDefault("AWS_LAMBDA_FUNCTION_MEMORY_SIZE", "3008") | ||
|
|
||
| var initStart time.Time | ||
| initMutex.Lock() |
There was a problem hiding this comment.
[CONCURRENCY] The global initMutex is locked and unlocked without defer, with a call into the whole init path in between. net/http recovers panics in handler goroutines, so a panic anywhere inside InitHandler would kill only this request and leave initMutex locked forever — every subsequent invoke would then block on initMutex.Lock() and the emulator would be permanently wedged with no error surfaced.
This is not hypothetical for InitHandler: it indexes envVar[1] on strings.SplitN(env, "=", 2) for every entry of os.Environ(), which panics on an environ entry with no =.
Scoping the lock so it is always released is enough:
func startInitOnce(sandbox Sandbox, functionVersion string, timeout int64, bs interop.Bootstrap) time.Time {
initMutex.Lock()
defer initMutex.Unlock()
if initDone {
return time.Time{}
}
// Set initDone so next invokes do not try to Init the function again
initDone = true
return InitHandler(sandbox, functionVersion, timeout, bs)
}Then the call site becomes initStart := startInitOnce(sandbox, functionVersion, timeout, bs), and the zero-time return keeps working with the existing initStart.IsZero() check in formatInitDuration.
Sorry, something went wrong.
|
Addressed both follow-up comments in 2c52c1e. startInitOnce now holds the init-once lock with defer, so a panic cannot leave later requests blocked. initDone is set only after InitHandler returns successfully. For successful cold invokes, the captured init completion timestamp now ends Init Duration and starts the invocation Duration window. Timeout-during-init keeps the existing timeout duration behavior. Validation after this update:
|
Sorry, something went wrong.
| case rapidcore.ErrInvokeTimeout: | ||
| printEndReports(invokePayload.ID, initDuration, memorySize, invokeStart, timeoutDuration) | ||
| initEnd := sandbox.AwaitInitCompletion() | ||
| printEndReports(invokePayload.ID, formatInitDuration(initStart, initEnd, timeoutDuration), memorySize, invokeStart, timeoutDuration) |
There was a problem hiding this comment.
[GENERAL] The timeout branch still passes the pre-init invokeStart to printEndReports, so this REPORT line counts the init window twice now that formatInitDuration measures real init completion.
invokeStart is captured at line 134, right after init is kicked off, and sandbox.Invoke blocks through the entire init phase. The success path compensates for that at line 227 by rebasing invokeStart to initEnd; this branch does not.
For a cold invoke that times out during init, Server.Invoke returns ErrInvokeTimeout only after Reset has cancelled the init flow (HandleReset calls CancelFlows before taking handlerExecutionMutex, so initContext.Wait() has already returned an InitFailure). AwaitInitCompletion() therefore returns the time init was aborted, roughly initStart + timeout. formatInitDuration caps that at timeoutDuration, and printEndReports independently caps now - invokeStart at the same value, so a 300 s timeout prints:
REPORT RequestId: ...Init Duration: 300000.00 msDuration: 300000.00 msBilled Duration: 300000 ms
The same elapsed wall clock is reported in both fields, and Init Duration is presented as a completed init when init was actually cancelled. Before this change the branch printed Init Duration: ~0.00 ms, so this is a new inconsistency specific to the timeout path.
Note that simply rebasing invokeStart here (as the success path does) would make Duration collapse to the reset time, which contradicts the Task timed out after N seconds body written two lines below. The cleaner fix is to distinguish a completed init from an aborted one — for example, have the completion signal carry whether init succeeded, and omit Init Duration (or report only the portion that elapsed before the deadline) when the init phase was terminated by the timeout reset — so that Init Duration and Duration never describe the same interval.
Sorry, something went wrong.
|
Addressed the latest timeout-reporting finding in 9ea5783. The init completion result now includes whether initialization actually succeeded. RIE prints Init Duration only for successful initialization, so a timeout reset no longer presents an aborted init as completed or duplicates the timeout interval across both fields. I also audited the changed path for repeated waits, publication ordering, panic handling, and concurrent access. The timestamp and success flag are written before the completion channel closes, and repeated readers receive the same cached result. Validation:
|
Sorry, something went wrong.
| case rapidcore.ErrInvokeTimeout: | ||
| initEnd, initSucceeded := sandbox.AwaitInitCompletion() | ||
| initDuration := "" | ||
| if initSucceeded { |
There was a problem hiding this comment.
[GENERAL] Gating on initSucceeded drops Init Duration from the REPORT line entirely when the invoke times out during init, so the cold-start REPORT loses a field it has always had.
Tracing where this gate actually changes output: on the post-switch path (line 231), initSucceeded == false together with a non-zero initStart is unreachable — every init-failure path returns early through ErrInitDoneFailed or ErrInvokeDoneFailed, and warm invokes are already filtered by the initStart.IsZero() check inside formatInitDuration. That leaves this timeout branch as the only place the gate has an observable effect, and TestInvokeHandlerOmitsInitDurationWhenInitTimesOut locks the omission in.
Before this change RIE always printed an Init Duration on the first invoke (a near-zero value, since it stopped the clock when InitHandler returned). After it, a timeout during init prints:
REPORT RequestId: ...Duration: 1000.00 msBilled Duration: 1000 ms...
with no Init Duration at all, which diverges from the cloud REPORT line that RIE aims for parity with and can break anything parsing that field on a cold invoke.
The values needed are already valid here: awaitInitCompletion sets completedAt = time.Now() before closing done even on the reset-cancelled path, so initEnd is non-zero, and formatInitDuration already clamps to timeoutDuration. Dropping the gate yields the clamped init window rather than nothing:
case rapidcore.ErrInvokeTimeout:
initEnd, := sandbox.AwaitInitCompletion()
initDuration := formatInitDuration(initStart, initEnd, timeoutDuration)
printEndReports(invokePayload.ID, initDuration, memorySize, invokeStart, timeoutDuration)If omitting the field is deliberate, it is worth saying so explicitly — the reasoning in the PR discussion covered the Duration value for this branch, not the removal of Init Duration.
The core fix checks out: initContext.Wait() returns when the runtime calls /next, so Init Duration now spans runtime startup and user module loading. The earlier double-close, mutex-on-panic, and success-path Duration double-count findings are all genuinely resolved, and the completion signal is ordered before every blocking send in awaitInitCompletion, so I could not reproduce the unbounded-wait scenario in the current code.
Sorry, something went wrong.
|
Addressed in 8bc27a7. I restored Init Duration for a first invocation that times out during initialization, so the cold-start REPORT keeps the established schema. The value uses the recorded init abort/completion timestamp and is capped at the configured invocation timeout. I also removed the success flag that was only needed to suppress this field. Validation:
|
Sorry, something went wrong.
| return | ||
| case rapidcore.ErrInvokeTimeout: | ||
| initEnd := sandbox.AwaitInitCompletion() | ||
| initDuration := formatInitDuration(initStart, initEnd, timeoutDuration) |
There was a problem hiding this comment.
[BUG] The timeout branch still reports Duration from the pre-init invokeStart, so a cold invoke that times out counts the init window twice on the same REPORT line.
invokeStart is captured at line 134, immediately after startInitOnce kicks off init, and sandbox.Invoke blocks through the entire init phase. The success path compensates for this at lines 228-230 by rebasing invokeStart to initEnd; this branch does not. Since printEndReports clamps to timeoutDuration and formatInitDuration does too, a cold start that times out during init now prints:
REPORT RequestId: ...Init Duration: 1000.00 msDuration: 1000.00 msBilled Duration: 1000 ms...
for a request whose total wall time was ~1 s. Before this change Init Duration was sub-millisecond (it only measured sandbox.Init returning), so the overlap was invisible; now that it measures real init completion the two fields overlap fully. The same line also presents an init that was aborted by the timeout reset as a completed init — initEnd here is the moment CancelFlows tore init down, not the moment the runtime finished loading.
Making the timeout branch use the same rebase keeps both fields inside a single timeout window (Init Duration + Duration ≈ timeout):
case rapidcore.ErrInvokeTimeout:
initEnd := sandbox.AwaitInitCompletion()
initDuration := formatInitDuration(initStart, initEnd, timeoutDuration)
if !initStart.IsZero() && initEnd.After(invokeStart) {
invokeStart = initEnd
}
printEndReports(invokePayload.ID, initDuration, memorySize, invokeStart, timeoutDuration)If you would rather not report a duration for an init that never completed, the alternative is to suppress Init Duration on this branch instead — but the current combination of "full init duration" plus "full timeout duration" is the one outcome that cannot be reconciled with the elapsed time. Note that TestInvokeHandlerReportsInitDurationWhenInitTimesOut won't catch either variant today: it only asserts Init Duration is in (0, 1000] and that a Duration field is present, so it's worth asserting the relationship between the two values.
Sorry, something went wrong.
|
Addressed in fe7881b. The timeout path now rebases Duration to the recorded init completion/abort timestamp, just like the successful cold-start path. This keeps Init Duration in the cold-start REPORT without counting the init interval again in Duration. Warm timeouts are unchanged. I expanded the regression coverage before pushing:
Validation:
Docker E2E with the Python 3.12 Lambda runtime:
|
Sorry, something went wrong.
| } | ||
| } | ||
|
|
||
| initEnd := sandbox.AwaitInitCompletion() |
There was a problem hiding this comment.
[GENERAL] The init-report block is now duplicated verbatim in two places — the timeout branch at lines 214-218 and here at 228-232:
initEnd := sandbox.AwaitInitCompletion()
initDuration := formatInitDuration(initStart, initEnd, timeoutDuration)
if !initStart.IsZero() && initEnd.After(invokeStart) {
invokeStart = initEnd
}
printEndReports(invokePayload.ID, initDuration, memorySize, invokeStart, timeoutDuration)This duplication is what produced the divergences already found in this PR's earlier revisions (the timeout branch printed a pre-init invokeStart while the success path rebased it, and later the two paths disagreed on whether Init Duration was emitted at all). Any future change to the REPORT line has to be applied twice, and the compiler cannot catch a missed one — the symptom is a silently wrong number in a log line, which is exactly the class of bug this PR fixes.
Consolidating both callers into one helper keeps the two REPORT paths structurally unable to drift:
// printInvokeReport emits the END/REPORT lines, attributing the init window to
// Init Duration and the remainder to Duration.
func printInvokeReport(sandbox Sandbox, invokeID string, initStart time.Time, invokeStart time.Time, memorySize string, timeoutDuration time.Duration) {
initEnd := sandbox.AwaitInitCompletion()
initDuration := formatInitDuration(initStart, initEnd, timeoutDuration)
if !initStart.IsZero() && initEnd.After(invokeStart) {
invokeStart = initEnd
}
printEndReports(invokeID, initDuration, memorySize, invokeStart, timeoutDuration)
}Both call sites then become a single line, and the timeout branch no longer needs to reassign invokeStart in the enclosing scope.
Sorry, something went wrong.
| durationMilliseconds, err := strconv.ParseFloat(durationMatches[1], 64) | ||
| require.NoError(t, err) | ||
| require.GreaterOrEqual(t, durationMilliseconds, float64(40)) | ||
| require.Less(t, durationMilliseconds, float64(90)) |
There was a problem hiding this comment.
[GENERAL] This assertion bounds a wall-clock measurement of a 50 ms sleep to under 90 ms, leaving only ~40 ms of headroom:
sandbox := &delayedInitSandbox{
initDelay: 50 time.Millisecond,
invokeDelay: 50 time.Millisecond,
invokeErr: rapidcore.ErrInvokeTimeout,
}
...
require.GreaterOrEqual(t, durationMilliseconds, float64(40))
require.Less(t, durationMilliseconds, float64(90))time.Sleep only guarantees a lower bound. On a CPU-throttled container or a loaded CI runner, a 50 ms sleep plus goroutine rescheduling, GC, and the pipe/regexp work in the same measured window routinely overshoots by tens of milliseconds, so this will fail intermittently on an unrelated change. The test's actual intent — that the invocation window excludes the init window — is fully captured by the lower bound plus the existing sum check against the 1 s timeout.
Dropping the tight ceiling keeps the assertion meaningful without the timing dependency:
require.GreaterOrEqual(t, durationMilliseconds, float64(40))
require.LessOrEqual(t, initDurationMilliseconds+durationMilliseconds, float64(1020))If an upper bound is still wanted to prove init time is not being double counted, comparing it against initDurationMilliseconds is more robust than a fixed millisecond ceiling, since both values scale together under load.
Sorry, something went wrong.
|
Addressed both cleanup comments in 8c18e35. The success and timeout paths now use one printInvokeReport helper, so init attribution and Duration rebasing cannot drift between the two call sites. I also replaced the fixed wall-clock ceilings with assertions relative to the measured Init Duration; they still catch init being counted in Duration without assuming a loaded CI runner completes within a fixed number of milliseconds. Validation before pushing:
|
Sorry, something went wrong.
| durationMilliseconds, err := strconv.ParseFloat(durationMatches[1], 64) | ||
| require.NoError(t, err) | ||
| require.GreaterOrEqual(t, durationMilliseconds, float64(40)) | ||
| require.Less(t, durationMilliseconds, initDurationMilliseconds*1.5) |
There was a problem hiding this comment.
[GENERAL] This assertion couples two independent wall-clock measurements with a 1.5x ratio, which makes the test load-sensitive:
sandbox := &delayedInitSandbox{
initDelay: 50 time.Millisecond,
invokeDelay: 50 time.Millisecond,
invokeErr: rapidcore.ErrInvokeTimeout,
}
...
require.GreaterOrEqual(t, durationMilliseconds, float64(40))
require.Less(t, durationMilliseconds, initDurationMilliseconds*1.5)initDurationMilliseconds measures the initDelay sleep and durationMilliseconds measures the invokeDelay sleep. Both nominally land at ~50 ms, so the upper bound is effectively ~75 ms against a 50 ms measurement — roughly 25 ms of headroom. time.Sleep only guarantees a lower bound, and overshoot is not correlated between the two sleeps: on a contended CI runner (or under -race) the second sleep can overshoot by more than the first, failing the assertion even though the reporting logic is correct. The GreaterOrEqual bounds are safe because sleeps never undershoot; only this relative upper bound is fragile.
The property this test actually cares about is that Init Duration and Duration cover disjoint windows rather than double-counting init. That can be asserted against measured total elapsed time instead of a fixed ratio:
start := time.Now()
InvokeHandler(response, request, sandbox, nil)
elapsed := float64(time.Since(start)) / float64(time.Millisecond)
...
// no double counting: the two reported windows must fit inside real elapsed time
require.LessOrEqual(t, initDurationMilliseconds+durationMilliseconds, elapsed)That keeps the regression coverage for the double-counting bug this PR fixes without depending on how precisely the two sleeps track each other.
Sorry, something went wrong.
|
Addressed in 427bcfb. The test now measures elapsed wall time around InvokeHandler and asserts that the reported Init Duration + Duration fits inside that real window. I increased both simulated phases to 200 ms so the handler's existing 100 ms post-timeout delay cannot mask the old double-counting behavior. Validation:
|
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
Issue #, if available:
Fixes #106
Description of changes:
RIE currently stops measuring init time as soon as InitHandler returns. That only starts sandbox initialization; runtime startup and user module loading continue asynchronously, so the reported Init Duration can be much too small.
This change waits for actual runtime initialization to finish before calculating the duration. It uses a separate completion signal instead of consuming the existing init result, so the current success and failure handling stays unchanged.
I tested this with:
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.