Skip to content

fix: persist WASM channel workspace writes across callbacks#264

Merged
ilblackdragon merged 2 commits intomainfrom
fix/wasm-channel-workspace-persistence
Feb 20, 2026
Merged

fix: persist WASM channel workspace writes across callbacks#264
ilblackdragon merged 2 commits intomainfrom
fix/wasm-channel-workspace-persistence

Conversation

@serrrfirat
Copy link
Copy Markdown
Collaborator

Summary

  • WASM channel callbacks (on_poll, on_http_request, on_start) call workspace_write() to persist state (e.g., Telegram polling offset), but take_pending_writes() was never called after execution — writes were silently discarded
  • No WorkspaceReader was injected into channel capabilities, so workspace_read() always returned None
  • This caused Telegram's polling offset to reset to 0 on every tick, making getUpdates re-deliver already-processed messages and producing 2-4 duplicate LLM responses per user message

Fix

  • Add ChannelWorkspaceStore — an Arc-wrapped HashMap with std::sync::RwLock that persists across callback invocations within a channel's lifetime
  • Inject it as the WorkspaceReader into capabilities before each callback
  • Call take_pending_writes() and commit to the store after every callback (on_start, on_poll, on_http_request, execute_poll)

Files changed

File Change
src/channels/wasm/host.rs Add ChannelWorkspaceStore struct + impl WorkspaceReader + tests
src/channels/wasm/wrapper.rs Wire store into WasmChannel, inject reader + commit writes in all 4 callback paths

Test plan

  • cargo build compiles clean
  • Manual test: send Telegram message → exactly one LLM response (no duplicates)
  • Verified via debug logs: offset written after first poll, read correctly on subsequent polls
  • Unit test for ChannelWorkspaceStore commit and read behavior
  • cargo test — all existing tests pass
  • cargo clippy --all --all-features — no warnings

🤖 Generated with Claude Code

WASM channel callbacks (polling, webhooks, on_start) call
workspace_write() to persist state, but the host code never committed
these writes — take_pending_writes() was never called. Additionally,
no WorkspaceReader was injected into channel capabilities, so
workspace_read() always returned None.

This caused Telegram's polling offset to reset to 0 on every tick,
making getUpdates re-deliver already-processed messages and producing
2-4 duplicate LLM responses per user message.

Add ChannelWorkspaceStore (Arc-wrapped HashMap with std::sync::RwLock)
that persists across callback invocations within a channel's lifetime.
Inject it as the WorkspaceReader and commit pending writes after every
callback execution (on_start, on_poll, on_http_request, execute_poll).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions github-actions Bot added scope: channel/wasm WASM channel runtime size: M 50-199 changed lines risk: medium Business logic, config, or moderate-risk modules labels Feb 20, 2026
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @serrrfirat, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request resolves a significant issue where state changes made within WASM channel callbacks were not being persisted, leading to data loss and incorrect behavior, particularly with Telegram polling offsets. By introducing a dedicated in-memory workspace store and integrating its read and commit operations into the WASM channel's lifecycle, the system can now reliably maintain state. This enhancement ensures that WASM-based channels operate correctly and prevents undesirable outcomes such as duplicate message processing.

Highlights

  • Problem Addressed: State Persistence in WASM Channels: Previously, WASM channel workspace writes were not persisted across callback invocations, leading to silent discarding of state. This caused issues such as Telegram polling offsets resetting, resulting in duplicate message processing and LLM responses.
  • Solution Implemented: ChannelWorkspaceStore: An in-memory ChannelWorkspaceStore (an Arc-wrapped HashMap with std::sync::RwLock) was introduced to persistently store workspace data within a channel's lifetime, ensuring state is maintained between poll ticks.
  • Integration and Write Commitment: The ChannelWorkspaceStore is now injected as the WorkspaceReader into channel capabilities before each WASM callback (on_poll, on_http_request, on_start, execute_poll). Crucially, pending writes are explicitly committed to this store after each callback execution.
  • Impact and Resolution: This fix resolves critical issues like duplicate LLM responses by ensuring that stateful information, such as Telegram polling offsets, is correctly persisted and read across WASM callback invocations.
Changelog
  • src/channels/wasm/host.rs
    • Added ChannelWorkspaceStore struct for in-memory workspace persistence.
    • Implemented the WorkspaceReader trait for ChannelWorkspaceStore to enable reading stored data.
    • Included unit tests to verify the commit and read behavior of the ChannelWorkspaceStore.
  • src/channels/wasm/wrapper.rs
    • Introduced a workspace_store field to the WasmChannel struct to manage the ChannelWorkspaceStore.
    • Added a new helper function inject_workspace_reader to dynamically inject the workspace store as a reader into channel capabilities.
    • Modified the on_start, on_http_request, on_poll, and execute_poll methods to utilize the injected workspace reader and commit any pending workspace writes after their execution.
Activity
  • Confirmed that cargo build compiles cleanly.
  • Performed manual testing to verify that sending a Telegram message results in exactly one LLM response, eliminating duplicates.
  • Verified via debug logs that the Telegram polling offset is correctly written after the initial poll and accurately read in subsequent polls.
  • Added and passed unit tests specifically for the ChannelWorkspaceStore's commit and read functionality.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request correctly addresses a critical issue where WASM channel workspace writes were not being persisted, leading to problems like message re-delivery. The introduction of ChannelWorkspaceStore to maintain state across callback invocations is a solid approach. The changes are well-implemented across all relevant callback paths (on_start, on_poll, on_http_request, and execute_poll), ensuring that workspace reads and writes now function as expected. I've added a couple of suggestions to improve the robustness of the ChannelWorkspaceStore by explicitly handling potential RwLock poisoning, which will prevent silent failures in edge cases and aid in debugging by logging errors.

Comment thread src/channels/wasm/host.rs
Comment on lines +329 to +338
if let Ok(mut data) = self.data.write() {
for write in writes {
tracing::debug!(
path = %write.path,
content_len = write.content.len(),
"Committing workspace write to channel store"
);
data.insert(write.path.clone(), write.content.clone());
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation silently ignores a poisoned RwLock if self.data.write() returns an Err. This means if a panic occurs while another thread holds the write lock, subsequent writes will be silently dropped, which could lead to the same kind of data loss issues this PR is trying to fix. It's more robust to handle this case by logging an error to make debugging easier and prevent silent failures.

Suggested change
if let Ok(mut data) = self.data.write() {
for write in writes {
tracing::debug!(
path = %write.path,
content_len = write.content.len(),
"Committing workspace write to channel store"
);
data.insert(write.path.clone(), write.content.clone());
}
}
match self.data.write() {
Ok(mut data) => {
for write in writes {
tracing::debug!(
path = %write.path,
content_len = write.content.len(),
"Committing workspace write to channel store"
);
data.insert(write.path.clone(), write.content.clone());
}
}
Err(e) => {
tracing::error!(error = %e, "Failed to acquire write lock on ChannelWorkspaceStore, workspace writes will be lost. This may indicate a panic occurred in another thread.");
}
}
References
  1. When handling errors, it is good practice to log them to capture debugging information and prevent silent failures, similar to how JoinError from tokio::task::spawn_blocking should be logged.

Comment thread src/channels/wasm/host.rs
Comment on lines +343 to +345
fn read(&self, path: &str) -> Option<String> {
self.data.read().ok()?.get(path).cloned()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the commit_writes function, the read function will silently fail by returning None if the RwLock is poisoned (self.data.read().ok()?). This can mask underlying issues where a panic might have occurred during a write. It would be more robust to log an error in case of a poisoned lock to aid in debugging.

    fn read(&self, path: &str) -> Option<String> {
        match self.data.read() {
            Ok(data) => data.get(path).cloned(),
            Err(e) => {
                tracing::error!(error = %e, "Failed to acquire read lock on ChannelWorkspaceStore, read will fail. This may indicate a panic occurred in another thread.");
                None
            }
        }
    }
References
  1. When handling errors, it is good practice to log them to capture debugging information and prevent silent failures, similar to how JoinError from tokio::task::spawn_blocking should be logged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ilblackdragon ilblackdragon merged commit 7df356c into main Feb 20, 2026
4 checks passed
@ilblackdragon ilblackdragon deleted the fix/wasm-channel-workspace-persistence branch February 20, 2026 15:52
This was referenced Feb 20, 2026
jaswinder6991 pushed a commit to jaswinder6991/ironclaw that referenced this pull request Feb 26, 2026
* fix: persist WASM channel workspace writes across callbacks

WASM channel callbacks (polling, webhooks, on_start) call
workspace_write() to persist state, but the host code never committed
these writes — take_pending_writes() was never called. Additionally,
no WorkspaceReader was injected into channel capabilities, so
workspace_read() always returned None.

This caused Telegram's polling offset to reset to 0 on every tick,
making getUpdates re-deliver already-processed messages and producing
2-4 duplicate LLM responses per user message.

Add ChannelWorkspaceStore (Arc-wrapped HashMap with std::sync::RwLock)
that persists across callback invocations within a channel's lifetime.
Inject it as the WorkspaceReader and commit pending writes after every
callback execution (on_start, on_poll, on_http_request, execute_poll).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
bkutasi pushed a commit to bkutasi/ironclaw that referenced this pull request Mar 28, 2026
* fix: persist WASM channel workspace writes across callbacks

WASM channel callbacks (polling, webhooks, on_start) call
workspace_write() to persist state, but the host code never committed
these writes — take_pending_writes() was never called. Additionally,
no WorkspaceReader was injected into channel capabilities, so
workspace_read() always returned None.

This caused Telegram's polling offset to reset to 0 on every tick,
making getUpdates re-deliver already-processed messages and producing
2-4 duplicate LLM responses per user message.

Add ChannelWorkspaceStore (Arc-wrapped HashMap with std::sync::RwLock)
that persists across callback invocations within a channel's lifetime.
Inject it as the WorkspaceReader and commit pending writes after every
callback execution (on_start, on_poll, on_http_request, execute_poll).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: experienced 6-19 merged PRs risk: medium Business logic, config, or moderate-risk modules scope: channel/wasm WASM channel runtime size: M 50-199 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants