Skip to content

Harden the plugin transport, codec and provider sessions - #11

Merged
naijun0403 merged 1 commit into
masterfrom
fix/stability
Oct 1, 2026
Merged

naijun0403 merged 1 commit into
masterfrom
fix/stability

Conversation

@naijun0403

Copy link
Copy Markdown
Member

Summary

A full review of the transport, codec and sessions for ways a call can hang, be lost, run twice or crash a process. Bots on engine plugins were failing with No answer to 'engine.dispatch' within 120000ms. The host side of that (the app was frozen without its foreground service) is fixed in MessengerBotR#275. This PR fixes what the contract itself did wrong. Every change has a test.

Waiting on things that cannot answer

  • Dead peers close the peer at once. New TransportClosedException: PluginTransport.send throws it when the other side is gone for good, and RpcPeer then closes and fails everything pending. Before, a dead process looked like a full Binder buffer: the frame was retried out of band, and pending requests waited for their timeouts (up to 120 s). Sending close or engine.interrupt to the dead side also logged errors.
    • HostSessionTransport and PluginSessionTransport tell the two cases apart with isBinderAlive. LoopbackTransport now throws on a closed side, as a dead Binder does.
  • An interrupt frees a script that waits on the host. EngineEndpoint answers in-flight blocking host.calls when interrupted, after interrupting the engine. Before, a script in msg.reply or Http.get ignored the interrupt for up to 30 s, and the host abandoned the engine thread. Checked with javet-node on the emulator: a script blocked in Http.get on an unreachable address stopped 5 ms after the interrupt.
  • Refused frames are retried for about 2 s instead of 260 ms.

Work the other side gave up on

  • Deadlines. engine.dispatch, provider.call and host.call now carry the time the asking side stops waiting (deadline, epoch ms; both processes share the device clock). Work picked up after it is answered unavailable instead of being run. This stops late replies to old messages after a freeze, and bot.send running twice when a script retries a call that timed out behind a slow one. Requests without a deadline, from older builds, never expire.

Provider sessions

  • Generations. provider.start carries a generation and emits carry it back. RemoteProvider drops emits from an earlier start: before, an emit racing a restart, or sent while a slow handler held up STOP, reached the new start with new tokens. Emits without a generation, from older plugins, are accepted.
  • A stopped provider answers what it still owes, unavailable, instead of leaving the host to time out. It refuses new calls instead of running them with empty options. Options are now read when a call runs, not when it arrives, so a restart queued ahead of it applies.
  • A provider that fails to start cannot emit, on either side. A stop() failure is reported instead of discarded.
  • Emitting after stop is reported, not only dropped. The TCK's nothingIsEmittedOnceStopped, which could not fail before, now does for such a provider (LateEmitterTest).
  • ProviderModule.callAsync/handle catch Errors such as TODO() in-process too, as the remote path already did. A stage completed with null, or a throw while answering, is answered instead of losing the reply.
  • An emit whose project is not a string is dropped, not broadcast to every project.

Codec and schema

  • Decoding cannot pre-allocate the budget many times over. Nested collections each declaring about 1M items used to allocate about 8 MB per level; now the declared counts share the frame's value budget.
  • The encoder enforces what the decoder checks: value count, shared bytes per frame, frame size. The failure happens before sending, so the caller gets an error right away instead of the other side dropping the frame and the caller waiting for its timeout.
  • At most 16 byte payloads per frame go out of band (MAX_OFFLOADS); the rest stay inline and the frame goes out of band as a whole. Before, a frame with 65 large byte values lost its first region to the receiver's eviction and was dropped. The receiver now keeps up to 256 regions waiting.
  • Type.check builds the path of a value only for an error. It was concatenated for every item, which was quadratic on large, deeply keyed values. Long keys are shortened in messages.
  • Lone surrogates encode as U+FFFD instead of ?. asLongOrNull no longer turns 2^63 into Long.MAX_VALUE. The protocol and module versions are range-checked instead of truncated to Int.

Android service

  • Sessions do not block each other. All sessions of a plugin share one IPluginService Binder object, whose one-way calls are delivered one at a time. Frames are now handled on each session's own thread, so a 60 MB frame or a slow handler in one project no longer holds up the others. Regions are still stored before the frame that uses them.
  • PluginService.close returns at once and closes the endpoint in the background, since closing runs plugin code (an engine's interrupt).
  • Only the app that opened a session can send to it or close it. Session IDs are sequential, and before, send, sendShared and close did not check the caller.
  • A non-RuntimeException thrown while opening is wrapped, so it reaches the host instead of killing the plugin process.
  • Closed transports no longer keep frames or shared regions that arrive late. Received regions are mapped only for the length the frame claims.
  • EngineEndpoint and ProviderEndpoint take onProtocolError, and PluginService logs lost frames, such as answers that could not be sent.

Compatibility

  • Wire format: only optional fields were added (deadline, generation). Older hosts and plugins ignore them, and their absence is accepted. No protocol or Binder version change, and no AIDL change.
  • Source: ProviderEndpoint's factory moved to the last parameter, after the new onProtocolError, so the usual ProviderEndpoint(transport) { provider } still compiles. A positional ProviderEndpoint(transport, factory) must name factory. Type gained checkAt and Type.Path; check is unchanged.
  • Behavior: calls to a provider that is not started now fail unavailable.

Testing

  • ./gradlew :contract:test :android:testDebugUnitTest :tck:test :android:assembleRelease: 95 tests, all pass. The contract suite passed 8 runs in a row.
    • New in contract: dead peer, interrupt during a host call, skipped late event and late host call, generations, stop answering owed calls, refused calls to a stopped provider, failed start, codec budgets, offload cap, surrogates, Type.check speed, Error in callAsync.
    • New in android (Robolectric): dead plugin and closed transport throw TransportClosedException; late regions are not kept; 300 large payloads in one frame; session ownership; Error while opening.
    • New in tck: LateEmitterTest.
  • With MessengerBotR (#275 branch), NodeJSPlugin and PluginFixtures built against this commit:
    • host unit tests: 133/133;
    • all APKs installed on the msgbot-test emulator, and the plugin and engine device tests in MessengerBotR's app (14 classes): 41/41;
    • the javet-node interrupt check described above.

The submodule bumps in MessengerBotR, NodeJSPlugin and PluginFixtures follow after this is merged.

@naijun0403
naijun0403 merged commit db37008 into master Oct 1, 2026
1 check passed
@naijun0403
naijun0403 deleted the fix/stability branch October 4, 2026 09:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant