langgenius / langgenius/dify-plugin-daemon
Endpoint plugin backwards invocation fails with "session not found" when HTTP callback times out
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 459
- Forks
- 313
- Avg merge
- 5h 17m
- Merged PRs (30d)
- 5
Description
Endpoint plugin backwards invocation fails with "session not found" when HTTP callback times out
Summary
When an endpoint plugin calls self.session.app.chat.invoke(response_mode="blocking") and the workflow execution time exceeds the HTTP callback timeout (e.g., WeChat Work's 5-second limit), the plugin daemon returns ErrSessionNotFound ("session not found"). This is because the HTTP request context cancellation triggers a cascade that removes the session from sessionToInstanceMap before the backwards invocation goroutine can write the response back.
This affects all webhook-based endpoint plugins (WeChat Work, DingTalk, Feishu, etc.) that use backwards invocations with long-running workflows.
Environment
- Plugin Daemon:
langgenius/dify-plugin-daemon:0.6.10-local - Dify API:
langgenius/dify-api:1.17.0 - Plugin:
anspire/wecom-bot:0.0.2(WeChat Work bot) - Runtime: Docker Compose, local runtime mode
- Workflow: advanced-chat app with agent node (execution time ~14-113 seconds)
Reproduction
- Install an endpoint plugin that receives webhooks from WeChat Work (or any service with a callback timeout)
- Configure the plugin to call
self.session.app.chat.invoke(response_mode="blocking")in the endpoint handler - Send a message from WeChat Work
- The workflow takes >5 seconds to complete
- WeChat Work closes the connection after 5 seconds (callback timeout)
- The plugin daemon logs:
backwards_invocation.write_error "session not found" - The plugin returns an error to the user
Root Cause Analysis
The cascade of events:
HTTP callback timeout (5s)
→ HTTP request context cancelled
→ context.AfterFunc(ctx, response.Close) fires
→ response stream closed
→ listener.OnClose callback fires
→ sessionToInstanceMap.Delete(sessionId)
→ backwards invocation goroutine tries to write response
→ LocalPluginRuntime.Write() looks up sessionId in sessionToInstanceMap
→ NOT FOUND → ErrSessionNotFound
→ WriteError/WriteResponse fails
→ "session not found" logged
Key code locations:
1. Session registration — internal/core/local_runtime/io.go:
func (r *LocalPluginRuntime) Listen(sessionId string) (...) {
instance, err := r.pickLowestLoadInstance()
r.sessionToInstanceMap.Store(sessionId, instance) // ← session registered here
listener.OnClose(func() {
instance.removeStdioHandlerListener(sessionId)
r.sessionToInstanceMap.Delete(sessionId) // ← session removed here
})
}
2. Context cancellation triggers cleanup — internal/core/io_tunnel/generic.go:
stopCloseOnCancel := context.AfterFunc(ctx, response.Close) // ← fires on timeout
3. Write fails after cleanup — internal/core/local_runtime/io.go:
func (r *LocalPluginRuntime) Write(sessionId string, ...) error {
instance, ok := r.sessionToInstanceMap.Load(sessionId)
if !ok {
return ErrSessionNotFound // ← "session not found"
}
// ...
}
4. Error logged but result lost — internal/core/io_tunnel/backwards_invocation/request.go:
func (bi *BackwardsInvocation) WriteError(err error) {
err1 := bi.writer.Write(...) // ← fails with "session not found"
if err1 != nil {
log.WarnContext(context.Background(), "backwards_invocation.write_error", err1)
}
}
The fundamental issue:
The backwards invocation is dispatched as an async goroutine (routine.Submit in task.go:InvokeDify), but the session lifecycle is tied to the HTTP request context. When the HTTP context is cancelled (callback timeout), the session is cleaned up, but the goroutine is still running. The goroutine's attempt to write the response back fails because the session no longer exists in the map.
This creates a race condition: the backwards invocation goroutine and the session cleanup are not coordinated.
Expected Behavior
The plugin daemon should either:
- Keep the session alive for the duration of the backwards invocation (even after the HTTP callback returns), so the goroutine can write the response back.
- Provide an alternative mechanism for endpoint plugins to make long-running invocations that aren't tied to the HTTP request lifecycle.
- At minimum, provide a clear error message that explains the timeout, rather than the confusing "session not found".
Actual Behavior
- Error:
backwards_invocation.write_error "session not found" - Error:
backwards_invocation.write_response error "session not found" - HTTP response status: 100 (Continue) — the default status code, indicating no response was sent
- The workflow continues running and succeeds in the database, but the result is lost
- WeChat Work shows no response or an error message
Workaround
We implemented a workaround by bypassing the backwards invocation mechanism entirely:
- The endpoint plugin returns
finish=Falseimmediately (within the callback timeout) - A background thread makes a direct HTTP call to the Dify API's internal endpoint (
/inner/api/invoke/app) usingurllib.request, which is not tied to the session lifecycle - Results are cached to a file (
/tmp/qw_bot_cache/) - Subsequent stream callbacks check the cache and return the result when ready
This workaround requires:
DIFY_INNER_API_URLandDIFY_INNER_API_KEYenvironment variables (already available in the plugin process)DIFY_TENANT_IDandDIFY_USER_IDenvironment variables (manually added)- Parsing the length-prefixed binary protocol used by the internal endpoint (14-byte header + JSON payload)
Log Evidence
22:30:42.541 session info cached session_id=ac1c8430-... plugin=anspire/wecom-bot:0.0.2
22:30:47.502 backwards_invocation.write_error "session not found"
22:30:47.502 backwards_invocation.write_response error "session not found"
22:30:47.502 session info deleted from cache session_id=ac1c8430-...
22:30:47.502 HTTP request status=100 latency_ms=4972 (5 second timeout)
The workflow itself succeeds (verified in workflow_runs table with succeeded status), but the result is never delivered to the plugin.
Suggested Fix Directions
-
Deferred session cleanup: Delay
sessionToInstanceMap.Delete(sessionId)until all pending backwards invocations for that session have completed. A reference counter orsync.WaitGroupcould track pending invocations. -
Separate invocation sessions: Create a separate session for the backwards invocation that's not tied to the HTTP request context. The invocation session would persist until the invocation completes.
-
Graceful degradation: When the HTTP context is cancelled but backwards invocations are still pending, log a warning and let the goroutine complete. The result may not be delivered to the HTTP client, but at least the workflow isn't left in an inconsistent state.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Read internal/core/local_runtime/io.go, internal/core/io_tunnel/generic.go, internal/core/io_tunnel/backwards_invocation/request.go, and task.go:InvokeDify, then reproduce the callback-timeout sequence described in the issue. Trace session cleanup against the pending backwards invocation; done means the timeout no longer produces a lost result with a misleading "session not found" error, or the resulting failure is explicitly handled.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100