chansrv: Stop XrdpIme's commit racing the client's own IME cleanup
Microsoft Remote Desktop for Mac was observed to garble Chinese text typed through its local Pinyin IME: the composed characters would land with pieces missing or extra characters deleted from the document. Root cause, confirmed with debug logging plus dbus-monitor on the live ibus session bus: the client forwards the raw pre-composition keystrokes over the normal RDP keyboard channel while composing (these reach XrdpIme's process-key-event handler and, since it never consumes anything, get typed into the document as literal ASCII), then once composition finishes sends a compensating burst of Backspace keystrokes sized to exactly undo them, interleaved with the final composed text arriving over the separate TS_UNICODE_KEYBOARD_EVENT side channel. The two channels have no ordering guarantee, so the side-channel commit could land in the middle of the client's own backspace cleanup and get partially deleted. Measured the backspace burst against the leaked raw-character count across several phrases (23-for-23, 29-for-29) - it's always exactly right, so this is a pure interleaving race, not a client miscount. That makes it fixable server-side: xrdp_input_queue_source_prepare/ _check now withhold a queued commit until raw key traffic on the engine has been quiet for XRDP_INPUT_COMMIT_QUIET_US (100ms), so the client's own cleanup has time to finish first, capped by XRDP_INPUT_COMMIT_MAX_WAIT_US (500ms) so back-to-back composition can't delay a commit indefinitely. This is an empirical mitigation tuned to observed client behaviour, not a protocol guarantee. Also fixes two latent data races found while reviewing this code against the cross-thread invariants already documented at the top of the file: g_engine and last_input_name are written by the IBus thread (engine enable/disable, an unexpected daemon disconnect) but were read and, for last_input_name, also written directly by xrdp_input_enable() on chansrv's own thread with no synchronization - unlike `bus`, which already gets this treatment. Both now follow the same publish/clear-under-state_mutex pattern as `bus`. Added IBUS-KEY/IBUS-COMMIT debug logging used to diagnose this. Verified live against a real Microsoft Remote Desktop for Mac client connected to an xrdp/ibus session: multiple Chinese phrases of varying length composed and committed cleanly with the debounce in place, after reliably reproducing the interleaved garbling without it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+183
-24
@@ -77,16 +77,45 @@ static gboolean ibus_thread_exited = TRUE;
|
|||||||
static gboolean ibus_shutting_down = FALSE;
|
static gboolean ibus_shutting_down = FALSE;
|
||||||
static int engine_id = 0;
|
static int engine_id = 0;
|
||||||
|
|
||||||
|
/* Debounce state for committing queued unicode text - IBus-thread only,
|
||||||
|
* same as g_engine et al. above. RDP clients that compose IME text
|
||||||
|
* locally (e.g. Microsoft Remote Desktop for Mac) have been observed to
|
||||||
|
* *also* forward the raw pre-composition keystrokes over the normal
|
||||||
|
* keyboard channel, then send a compensating burst of Backspace key
|
||||||
|
* events sized to exactly undo them once composition finishes - see the
|
||||||
|
* commit that introduced this debounce for the measurements (backspace
|
||||||
|
* count matched leaked-character count exactly, every time: 23-for-23,
|
||||||
|
* 29-for-29). Those backspaces and this engine's queued commit arrive
|
||||||
|
* over two independent channels with no ordering guarantee, so without
|
||||||
|
* this delay the commit can land in the middle of the client's own
|
||||||
|
* cleanup and get partially deleted. Waiting for raw key traffic on this
|
||||||
|
* engine to go quiet before committing sidesteps that race entirely,
|
||||||
|
* since it's the same fix as "wait for the client's known-correct
|
||||||
|
* cleanup to finish" without needing to trust the client to order things
|
||||||
|
* itself. This is an empirical mitigation, not a protocol guarantee -
|
||||||
|
* a client that pauses mid-burst for longer than the quiet window could
|
||||||
|
* still race it, hence the absolute cap below. */
|
||||||
|
static gint64 last_raw_key_time = 0;
|
||||||
|
static gint64 queue_wait_since = 0;
|
||||||
|
#define XRDP_INPUT_COMMIT_QUIET_US ((gint64)100 * 1000)
|
||||||
|
#define XRDP_INPUT_COMMIT_MAX_WAIT_US ((gint64)500 * 1000)
|
||||||
|
|
||||||
/*****************************************************************************/
|
/*****************************************************************************/
|
||||||
static void
|
static void
|
||||||
xrdp_input_ibus_engine_enable(IBusEngine *engine)
|
xrdp_input_ibus_engine_enable(IBusEngine *engine)
|
||||||
{
|
{
|
||||||
|
IBusEngine *old_engine = NULL;
|
||||||
|
|
||||||
LOG(LOG_LEVEL_INFO,
|
LOG(LOG_LEVEL_INFO,
|
||||||
"xrdp_ibus_engine_enable: IM enabled, engine=%p", engine);
|
"xrdp_ibus_engine_enable: IM enabled, engine=%p", engine);
|
||||||
|
|
||||||
|
/* g_engine is read cross-thread (unlocked) by xrdp_input_enable() on
|
||||||
|
* chansrv's own thread - see the comment there. Publish it under
|
||||||
|
* state_mutex, same pattern as `bus`. */
|
||||||
|
g_mutex_lock(&state_mutex);
|
||||||
if (g_engine != NULL && g_engine != engine)
|
if (g_engine != NULL && g_engine != engine)
|
||||||
{
|
{
|
||||||
g_object_unref(g_engine);
|
old_engine = g_engine;
|
||||||
g_engine = NULL;
|
g_engine = NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +123,12 @@ xrdp_input_ibus_engine_enable(IBusEngine *engine)
|
|||||||
{
|
{
|
||||||
g_engine = g_object_ref(engine);
|
g_engine = g_object_ref(engine);
|
||||||
}
|
}
|
||||||
|
g_mutex_unlock(&state_mutex);
|
||||||
|
|
||||||
|
if (old_engine != NULL)
|
||||||
|
{
|
||||||
|
g_object_unref(old_engine);
|
||||||
|
}
|
||||||
|
|
||||||
if (ibus_context != NULL)
|
if (ibus_context != NULL)
|
||||||
{
|
{
|
||||||
@@ -105,14 +140,23 @@ xrdp_input_ibus_engine_enable(IBusEngine *engine)
|
|||||||
static void
|
static void
|
||||||
xrdp_input_ibus_engine_disable(IBusEngine *engine)
|
xrdp_input_ibus_engine_disable(IBusEngine *engine)
|
||||||
{
|
{
|
||||||
|
IBusEngine *old_engine = NULL;
|
||||||
|
|
||||||
LOG(LOG_LEVEL_INFO,
|
LOG(LOG_LEVEL_INFO,
|
||||||
"xrdp_ibus_engine_disable: IM disabled, engine=%p", engine);
|
"xrdp_ibus_engine_disable: IM disabled, engine=%p", engine);
|
||||||
|
|
||||||
|
g_mutex_lock(&state_mutex);
|
||||||
if (g_engine == engine)
|
if (g_engine == engine)
|
||||||
{
|
{
|
||||||
g_object_unref(g_engine);
|
old_engine = g_engine;
|
||||||
g_engine = NULL;
|
g_engine = NULL;
|
||||||
}
|
}
|
||||||
|
g_mutex_unlock(&state_mutex);
|
||||||
|
|
||||||
|
if (old_engine != NULL)
|
||||||
|
{
|
||||||
|
g_object_unref(old_engine);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*****************************************************************************/
|
/*****************************************************************************/
|
||||||
@@ -122,10 +166,21 @@ engine_process_key_event_cb(IBusEngine *engine,
|
|||||||
guint keycode,
|
guint keycode,
|
||||||
guint state)
|
guint state)
|
||||||
{
|
{
|
||||||
/* XrdpIme is a Unicode commit bridge, not a keyboard-event IME. */
|
last_raw_key_time = g_get_monotonic_time();
|
||||||
|
|
||||||
|
LOG(LOG_LEVEL_DEBUG,
|
||||||
|
"IBUS-KEY: engine=%p keyval=0x%08X keycode=%u state=0x%08X",
|
||||||
|
engine,
|
||||||
|
keyval,
|
||||||
|
keycode,
|
||||||
|
state);
|
||||||
|
|
||||||
|
/* XrdpIme is a Unicode commit bridge, not a keyboard-event IME - it
|
||||||
|
* never consumes keys itself. last_raw_key_time above is what keeps
|
||||||
|
* this from racing the client's own compensating keystrokes; see the
|
||||||
|
* comment on it. */
|
||||||
return FALSE;
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*****************************************************************************/
|
/*****************************************************************************/
|
||||||
static IBusEngine *
|
static IBusEngine *
|
||||||
xrdp_input_ibus_create_engine(IBusFactory *factory,
|
xrdp_input_ibus_create_engine(IBusFactory *factory,
|
||||||
@@ -180,6 +235,8 @@ xrdp_input_enable(void)
|
|||||||
const gchar *name;
|
const gchar *name;
|
||||||
IBusBus *local_bus;
|
IBusBus *local_bus;
|
||||||
gboolean result;
|
gboolean result;
|
||||||
|
gboolean engine_ready;
|
||||||
|
gchar *last_input_name_snapshot;
|
||||||
|
|
||||||
g_mutex_lock(&state_mutex);
|
g_mutex_lock(&state_mutex);
|
||||||
local_bus = (bus != NULL) ? g_object_ref(bus) : NULL;
|
local_bus = (bus != NULL) ? g_object_ref(bus) : NULL;
|
||||||
@@ -193,13 +250,27 @@ xrdp_input_enable(void)
|
|||||||
return FALSE;
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* g_engine and last_input_name are otherwise IBus-thread-only (see
|
||||||
|
* the comment at their declarations) - snapshot both under
|
||||||
|
* state_mutex here rather than reading them directly, since this
|
||||||
|
* function runs on chansrv's own thread and the IBus thread can
|
||||||
|
* write either of them at any time (engine enable/disable, an
|
||||||
|
* unexpected daemon disconnect tearing both down). Working from a
|
||||||
|
* private copy of the name avoids racing a concurrent free of it. */
|
||||||
|
g_mutex_lock(&state_mutex);
|
||||||
|
engine_ready = (g_engine != NULL);
|
||||||
|
last_input_name_snapshot =
|
||||||
|
(last_input_name != NULL) ? g_strdup(last_input_name) : NULL;
|
||||||
|
g_mutex_unlock(&state_mutex);
|
||||||
|
|
||||||
desc = ibus_bus_get_global_engine(local_bus);
|
desc = ibus_bus_get_global_engine(local_bus);
|
||||||
name = desc != NULL ? ibus_engine_desc_get_name(desc) : NULL;
|
name = desc != NULL ? ibus_engine_desc_get_name(desc) : NULL;
|
||||||
|
|
||||||
if (name != NULL && g_ascii_strcasecmp(name, "XrdpIme") == 0)
|
if (name != NULL && g_ascii_strcasecmp(name, "XrdpIme") == 0)
|
||||||
{
|
{
|
||||||
if (g_engine != NULL)
|
if (engine_ready)
|
||||||
{
|
{
|
||||||
|
g_free(last_input_name_snapshot);
|
||||||
g_object_unref(desc);
|
g_object_unref(desc);
|
||||||
g_object_unref(local_bus);
|
g_object_unref(local_bus);
|
||||||
return TRUE;
|
return TRUE;
|
||||||
@@ -217,8 +288,8 @@ xrdp_input_enable(void)
|
|||||||
* succession, was most of them). Fall through and let the
|
* succession, was most of them). Fall through and let the
|
||||||
* queue/prepare-check mechanism wait for g_engine as usual. */
|
* queue/prepare-check mechanism wait for g_engine as usual. */
|
||||||
}
|
}
|
||||||
else if (last_input_name != NULL && name != NULL &&
|
else if (last_input_name_snapshot != NULL && name != NULL &&
|
||||||
g_ascii_strcasecmp(name, last_input_name) != 0)
|
g_ascii_strcasecmp(name, last_input_name_snapshot) != 0)
|
||||||
{
|
{
|
||||||
/* Only reclaim XrdpIme if the current engine is either unset,
|
/* Only reclaim XrdpIme if the current engine is either unset,
|
||||||
* or is the original one we remembered before ever switching
|
* or is the original one we remembered before ever switching
|
||||||
@@ -237,21 +308,28 @@ xrdp_input_enable(void)
|
|||||||
"xrdp_input_enable: global engine is \"%s\", not XrdpIme or "
|
"xrdp_input_enable: global engine is \"%s\", not XrdpIme or "
|
||||||
"the original \"%s\" - leaving it alone rather than "
|
"the original \"%s\" - leaving it alone rather than "
|
||||||
"reclaiming it, dropping this commit",
|
"reclaiming it, dropping this commit",
|
||||||
name, last_input_name);
|
name, last_input_name_snapshot);
|
||||||
|
g_free(last_input_name_snapshot);
|
||||||
g_object_unref(desc);
|
g_object_unref(desc);
|
||||||
g_object_unref(local_bus);
|
g_object_unref(local_bus);
|
||||||
return FALSE;
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remember the user's engine only the first time we replace it. */
|
/* Remember the user's engine only the first time we replace it. */
|
||||||
if (last_input_name == NULL && name != NULL)
|
if (last_input_name_snapshot == NULL && name != NULL)
|
||||||
{
|
{
|
||||||
last_input_name = g_strdup(name);
|
g_mutex_lock(&state_mutex);
|
||||||
|
if (last_input_name == NULL)
|
||||||
|
{
|
||||||
|
last_input_name = g_strdup(name);
|
||||||
|
}
|
||||||
|
g_mutex_unlock(&state_mutex);
|
||||||
LOG(LOG_LEVEL_INFO,
|
LOG(LOG_LEVEL_INFO,
|
||||||
"xrdp_input_enable: saving original IBus engine: %s",
|
"xrdp_input_enable: saving original IBus engine: %s", name);
|
||||||
last_input_name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g_free(last_input_name_snapshot);
|
||||||
|
|
||||||
if (desc != NULL)
|
if (desc != NULL)
|
||||||
{
|
{
|
||||||
g_object_unref(desc);
|
g_object_unref(desc);
|
||||||
@@ -282,6 +360,10 @@ xrdp_input_process_unicode_queue(gpointer data)
|
|||||||
|
|
||||||
if (text != NULL)
|
if (text != NULL)
|
||||||
{
|
{
|
||||||
|
LOG(LOG_LEVEL_DEBUG,
|
||||||
|
"IBUS-COMMIT: engine=%p committing U+%04X",
|
||||||
|
g_engine, event->unicode);
|
||||||
|
|
||||||
/* ibus_engine_commit_text() sinks and releases `text` itself
|
/* ibus_engine_commit_text() sinks and releases `text` itself
|
||||||
* since it's still floating (IBusText derives from
|
* since it's still floating (IBusText derives from
|
||||||
* GInitiallyUnowned) - do not unref it again here, that
|
* GInitiallyUnowned) - do not unref it again here, that
|
||||||
@@ -295,19 +377,70 @@ xrdp_input_process_unicode_queue(gpointer data)
|
|||||||
return G_SOURCE_CONTINUE;
|
return G_SOURCE_CONTINUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*****************************************************************************/
|
||||||
|
/* TRUE once queued unicode text is safe to commit: either raw key traffic
|
||||||
|
* on this engine has been quiet for XRDP_INPUT_COMMIT_QUIET_US (the
|
||||||
|
* client's own compensating keystrokes, if any, have had time to land),
|
||||||
|
* or XRDP_INPUT_COMMIT_MAX_WAIT_US has elapsed since the queue first had
|
||||||
|
* something pending, whichever comes first. If not yet ready, *wait_us
|
||||||
|
* (when non-NULL) is set to how much longer until it will be. */
|
||||||
|
static gboolean
|
||||||
|
xrdp_input_queue_ready(gint64 *wait_us)
|
||||||
|
{
|
||||||
|
gint64 now;
|
||||||
|
gint64 deadline;
|
||||||
|
|
||||||
|
if (g_engine == NULL || g_async_queue_length(unicode_queue) <= 0)
|
||||||
|
{
|
||||||
|
queue_wait_since = 0;
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
now = g_get_monotonic_time();
|
||||||
|
if (queue_wait_since == 0)
|
||||||
|
{
|
||||||
|
queue_wait_since = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline = MIN(last_raw_key_time + XRDP_INPUT_COMMIT_QUIET_US,
|
||||||
|
queue_wait_since + XRDP_INPUT_COMMIT_MAX_WAIT_US);
|
||||||
|
|
||||||
|
if (now >= deadline)
|
||||||
|
{
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wait_us != NULL)
|
||||||
|
{
|
||||||
|
*wait_us = deadline - now;
|
||||||
|
}
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
/*****************************************************************************/
|
/*****************************************************************************/
|
||||||
static gboolean
|
static gboolean
|
||||||
xrdp_input_queue_source_prepare(GSource *source, gint *timeout)
|
xrdp_input_queue_source_prepare(GSource *source, gint *timeout)
|
||||||
{
|
{
|
||||||
*timeout = -1;
|
gint64 wait_us = 0;
|
||||||
return g_engine != NULL && g_async_queue_length(unicode_queue) > 0;
|
|
||||||
|
if (xrdp_input_queue_ready(&wait_us))
|
||||||
|
{
|
||||||
|
*timeout = 0;
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Either nothing pending (wait_us left at 0 - block until the next
|
||||||
|
* g_main_context_wakeup(), same as the old *timeout = -1 behaviour),
|
||||||
|
* or pending but not quiet yet (wake us up once it should be). */
|
||||||
|
*timeout = (wait_us > 0) ? (gint)((wait_us / 1000) + 1) : -1;
|
||||||
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*****************************************************************************/
|
/*****************************************************************************/
|
||||||
static gboolean
|
static gboolean
|
||||||
xrdp_input_queue_source_check(GSource *source)
|
xrdp_input_queue_source_check(GSource *source)
|
||||||
{
|
{
|
||||||
return g_engine != NULL && g_async_queue_length(unicode_queue) > 0;
|
return xrdp_input_queue_ready(NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*****************************************************************************/
|
/*****************************************************************************/
|
||||||
@@ -347,25 +480,35 @@ static gboolean
|
|||||||
xrdp_input_shutdown_cb(gpointer data)
|
xrdp_input_shutdown_cb(gpointer data)
|
||||||
{
|
{
|
||||||
XrdpUnicodeEvent *event;
|
XrdpUnicodeEvent *event;
|
||||||
|
gchar *original_engine_name;
|
||||||
|
|
||||||
LOG(LOG_LEVEL_INFO, "xrdp_input: shutting down IBus");
|
LOG(LOG_LEVEL_INFO, "xrdp_input: shutting down IBus");
|
||||||
|
|
||||||
ibus_shutting_down = TRUE;
|
ibus_shutting_down = TRUE;
|
||||||
|
|
||||||
if (last_input_name != NULL && bus != NULL &&
|
/* Take ownership of last_input_name under state_mutex rather than
|
||||||
|
* reading then freeing it directly - xrdp_input_enable() on
|
||||||
|
* chansrv's own thread can be reading or (re)writing it concurrently
|
||||||
|
* (see the comment there), and this runs on the IBus thread. */
|
||||||
|
g_mutex_lock(&state_mutex);
|
||||||
|
original_engine_name = last_input_name;
|
||||||
|
last_input_name = NULL;
|
||||||
|
g_mutex_unlock(&state_mutex);
|
||||||
|
|
||||||
|
if (original_engine_name != NULL && bus != NULL &&
|
||||||
ibus_bus_is_connected(bus))
|
ibus_bus_is_connected(bus))
|
||||||
{
|
{
|
||||||
LOG(LOG_LEVEL_INFO,
|
LOG(LOG_LEVEL_INFO,
|
||||||
"xrdp_input: restoring original IBus engine: %s",
|
"xrdp_input: restoring original IBus engine: %s",
|
||||||
last_input_name);
|
original_engine_name);
|
||||||
if (!ibus_bus_set_global_engine(bus, last_input_name))
|
if (!ibus_bus_set_global_engine(bus, original_engine_name))
|
||||||
{
|
{
|
||||||
LOG(LOG_LEVEL_WARNING,
|
LOG(LOG_LEVEL_WARNING,
|
||||||
"xrdp_input: failed to restore original IBus engine");
|
"xrdp_input: failed to restore original IBus engine");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
g_clear_pointer(&last_input_name, g_free);
|
g_free(original_engine_name);
|
||||||
|
|
||||||
while ((event = g_async_queue_try_pop(unicode_queue)) != NULL)
|
while ((event = g_async_queue_try_pop(unicode_queue)) != NULL)
|
||||||
{
|
{
|
||||||
@@ -556,13 +699,29 @@ thread_cleanup:
|
|||||||
thread_default_pushed = FALSE;
|
thread_default_pushed = FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (g_engine != NULL)
|
|
||||||
{
|
{
|
||||||
g_object_unref(g_engine);
|
/* Same reasoning as the `bus` clear below: g_engine and
|
||||||
g_engine = NULL;
|
* last_input_name are read cross-thread (unlocked) by
|
||||||
}
|
* xrdp_input_enable() on chansrv's own thread, and this can run
|
||||||
|
* on an unexpected daemon disconnect with no coordination from
|
||||||
|
* that thread at all - clear the shared pointers under lock
|
||||||
|
* before releasing what they pointed to. */
|
||||||
|
IBusEngine *old_engine;
|
||||||
|
gchar *old_last_input_name;
|
||||||
|
|
||||||
g_clear_pointer(&last_input_name, g_free);
|
g_mutex_lock(&state_mutex);
|
||||||
|
old_engine = g_engine;
|
||||||
|
g_engine = NULL;
|
||||||
|
old_last_input_name = last_input_name;
|
||||||
|
last_input_name = NULL;
|
||||||
|
g_mutex_unlock(&state_mutex);
|
||||||
|
|
||||||
|
if (old_engine != NULL)
|
||||||
|
{
|
||||||
|
g_object_unref(old_engine);
|
||||||
|
}
|
||||||
|
g_free(old_last_input_name);
|
||||||
|
}
|
||||||
|
|
||||||
if (desc != NULL)
|
if (desc != NULL)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user