Skip to content

GUACAMOLE-288: Six fixes for the multi-monitor branch (incl. two WIP items)#3

Open
ciroiriarte wants to merge 21 commits into
corentin-soriano:GUACAMOLE-288_multi_screenfrom
ciroiriarte:GUACAMOLE-288_fixes
Open

GUACAMOLE-288: Six fixes for the multi-monitor branch (incl. two WIP items)#3
ciroiriarte wants to merge 21 commits into
corentin-soriano:GUACAMOLE-288_multi_screenfrom
ciroiriarte:GUACAMOLE-288_fixes

Conversation

@ciroiriarte

Copy link
Copy Markdown

Fixes for the GUACAMOLE-288 multi-monitor branch

Base: corentin-soriano:GUACAMOLE-288_multi_screen

I've been running your multi-monitor work as the basis for multi-monitor support in
the SPICE protocol client (apache#1227, apache/guacamole-server#560
is the corresponding server work). Doing so surfaced six issues in the shared
multi-monitor code, two of which are on your own WIP list. All six are
protocol-agnostic and apply to RDP as much as to SPICE, so they belong on your branch
rather than mine.

Anything SPICE-specific I needed has stayed on my branch — nothing here mentions
SPICE or assumes it.

The changes

1. Per-connection BroadcastChannel namespacing

The most significant one, and the reason I'd suggest looking at this PR before the
others. The channel name is the fixed string guac_monitors, so all connections open
in a browser share one channel. With two connections active, a secondary window's
keyboard and mouse events reach the other connection's guacd stream — including
anything typed into a credential prompt — and display instructions are mirrored
across the boundary. broadcast.onmessage also dispatches straight into
messageHandlers, so any same-origin page can reach client.runHandler().

This resolves the // TODO: ensure that there is no mixing of data between multiple connections you'd already left in guacManageMonitor.js, so I assume it was on your
list. The approach: a per-connection id from crypto.randomUUID() used in both the
channel name and a _cid tag on each message, with non-matching messages dropped
before dispatch. Secondaries inherit the id via the window URL rather than generating
their own, which changes the route to /secondaryMonitor/:id/:channel?.

2. Absolute left offset, and tolerance for transient layout gaps

getOffsetX() sums the rendered widths of all lower-positioned monitors. That's
correct for a gapless left-to-right row — likely every arrangement you tested — but
wrong for a vertical stack or any layout with a gap, and asymmetric with
getOffsetY(), which already uses absolute geometry. Changed to use the absolute
left coordinate normalised against the leftmost monitor.

Separately, onmultimonlayout() closes a monitor the first time it's absent from a
layout update. A guest that briefly reports a reduced layout while renegotiating
heads destroys a window the user opened. Now requires 8 consecutive misses.

3. Crop the display on monitor size change

Display.setMonitorSize() records the dimensions but doesn't act on them. If the
server has already sized the default layer to the full combined desktop and sends no
further resize, the window renders the whole desktop rather than its own region.

Adds DisplaySpec.js with two specs. The first (resize, then setMonitorSize) is the
one that exercises the fix — it fails against the unpatched Display.js with
Expected 5120 to be 2560. The second (setMonitorSize, then a larger resize) passes
either way, since the existing clamping inside resize() already handled that
ordering; it's there as a regression guard for that path rather than as coverage of
the new code.

4. Debounce resize requests, suppress remote resize echoes

A resize request comes back as a remote display resize → rescales the display element
→ changes the container's measured size → fires the resize sensor → sends another
request. Requests were sent synchronously per sensor event, so one drag produced a
burst and the display didn't settle. Now debounced 250ms, with a 700ms suppression
window after a server-driven resize. A request arriving inside that window is
rescheduled rather than dropped.

5. Validate multimon-layout before use

Malformed JSON throws out of JSON.parse inside the instruction handler, aborting the
handler loop and tearing down the client; non-finite geometry propagates as NaN into
the offset math. Client.js now guards parse and dispatch; guacManageMonitor.js
validates each entry, treating a bad one the same as an absent monitor so it flows
into the tolerance added in (2).

Adds ClientSpec.js for the transport-level guard — malformed JSON, JSON null,
non-object values, a throwing handler, and an unassigned handler. Five of the seven
specs fail against the unguarded parser; the other two are a positive control and a
pre-existing null-handler guard.

The guacManageMonitor.js half is untested, because guacamole/src/main/frontend has
no test harness at all — no karma, no jasmine, no specs. Same gap applies to (1), (2)
and (4), and to (6). I'd be glad to propose adding one under a separate issue if you
think that would be welcome; it seemed like too much to fold into this PR.

6. Order monitors by screen position rather than opening order

This is the "reorder monitors vertically if needed" item on your WIP list.

updateMonitorsInfos() assigns each monitor's position by iterating the monitors
object, so the position is the order the window happened to be opened. That position
is what sendAllSizes() reports to guacd as the monitor index, so opening a window
and then moving it left of an existing one produces an arrangement that doesn't match
what's on screen. A vertical stack has no meaningful opening order at all, which I
think is why the item was still outstanding.

Secondary monitors are now ordered by their reported geometry, left then top, which
handles a row, a stack, or any mix. The main window stays at position 0. A monitor
that hasn't reported a size yet keeps its relative opening order and sorts last,
taking its place once a size arrives.

It also closes a gap in the position sequence when a monitor is removed. Positions
were assigned before the closing monitor's entry was deleted, so closing the middle of
three left positions 1 and 3 with 2 missing. Since the closing monitor now has no
geometry to sort by it is placed last, and the survivors keep a contiguous sequence.
That matters at least for SPICE, where guacd rejects a monitor index greater than the
current monitor count to keep the layout contiguous, so the gap silently dropped a
later resize; I haven't checked whether the RDP path is equally strict.

Notes

  • Commits 1–4 and 6 are independent and can be taken, dropped or reordered freely. Commit 5
    is the one exception: it depends on commit 2, because it rewrites the
    if (!layout[pos]) check into if (!isValidGeometry(layout[pos])), and that check
    only takes its current form after commit 2. Taking 5 without 2 conflicts in
    guacManageMonitor.js. Verified by cherry-picking each of the six onto this
    branch's base in isolation.
  • No new dependencies; the only public API change is the secondaryMonitor route
    gaining an optional :channel segment.

Testing, run against this branch:

  • guacamole-common-js karma suite: 24/24 pass under headless Firefox (17 pre-existing
    plus 7 added here).
  • guacamole/src/main/frontend webpack build: succeeds, no errors (the two
    entrypoint-size warnings are pre-existing and unrelated).

Two environment notes you may already know about, neither caused by these commits but
both hit when building this branch from a clean checkout:

  • The build needs NODE_OPTIONS=--openssl-legacy-provider on OpenSSL 3, because the
    pinned css-minimizer-webpack-plugin still asks for an MD4 hash.
  • apache/main has since moved from angular-templatecache-webpack-plugin to
    angular-templatecache-webpack5-plugin, so this branch and current main no longer
    share a dependency tree.

Both suggest a rebase onto current main is due before the ASF reviewers pick apache#1061 back
up. Happy to redo these six commits on top of a rebased branch if that's useful.

corentin-soriano and others added 21 commits November 10, 2025 09:55
The multi-monitor BroadcastChannel was created with the fixed name
"guac_monitors", shared by every connection open in the browser. With two
connections active at once, a secondary monitor window sent its keyboard and
mouse events onto the same channel the other connection's windows were
listening on, so input intended for one session -- including anything typed
into a credential prompt -- reached the other session's guacd stream, and
display instructions were mirrored across the connection boundary.

Separately, broadcast.onmessage was wired directly to the monitor type's
handler table, so any same-origin page could reach client.runHandler() by
posting to a channel whose name it could guess.

The primary window now generates a per-connection channel id
(crypto.randomUUID(), with a timestamp+random fallback for insecure
contexts) and uses it both in the channel name and as a _cid tag on every
message; messages whose _cid does not match are dropped before dispatch.
Secondary windows inherit the id through the window URL rather than
generating their own, so the route becomes
/secondaryMonitor/:id/:channel?.

This resolves the "ensure that there is no mixing of data between multiple
connections" TODO in guacManageMonitor.js.
… gaps.

getOffsetX() derived a monitor's horizontal offset by summing the rendered
widths of every monitor at a lower position. That is correct only when the
monitors form a gapless left-to-right row, which is the common arrangement
but not the only one: a vertical stack, a gap between monitors, or any
arrangement where a monitor is not immediately adjacent to its predecessor
produced a wrong offset for both draw instructions and mouse coordinates. It
was also asymmetric with getOffsetY(), which already used absolute geometry.

getOffsetX() now reads the monitor's absolute left coordinate and normalises
it against the leftmost monitor, via a new getLowestLeftOffset() helper that
mirrors the existing getLowestTopOffset().

onmultimonlayout() also closed a monitor as soon as it was absent from a
single layout update. A guest that briefly reports a reduced layout while
renegotiating its heads would therefore destroy a window the user had opened,
with no way to recover it. Absence is now counted and closeMonitor() fires
only after 8 consecutive misses, with the counter reset whenever the monitor
reappears.

Adds getMonitorsInfos() so the resize handlers can consult the current layout.
Display.setMonitorSize() recorded monitorWidth and monitorHeight but did not
act on them. When the server had already sized the default layer to the full
combined desktop and sent no further resize after the per-window monitor
rectangle became known, the window kept rendering the entire desktop instead
of its own region.

setMonitorSize() now re-runs the resize of the default layer whenever the
current display size differs from the monitor size, re-entering the existing
clamping path so the visible area is constrained to the monitor bounds.

Adds DisplaySpec.js covering both orderings: resize followed by
setMonitorSize, and setMonitorSize followed by a later oversized resize.
…hoes.

Resizing a monitor window produced a feedback loop. A resize request sent to
the server came back as a remote display resize, which rescaled the display
element, which changed the measured size of the container, which fired the
element-resize sensor again, which sent a further request. Because requests
were sent synchronously on every sensor event, a single user drag generated a
burst of requests and the combined display did not settle.

Resize requests are now debounced by 250ms, and requests are suppressed for
700ms after the display is resized by the server, since element resizes in
that window are echoes of the remote resize rather than user intent. A
request arriving inside the suppression window is rescheduled for the
remainder of that window rather than dropped, so a genuine pending resize is
not lost.

The primary window detects remote resizes through the existing
managedDisplay.size watch; the secondary window gains a display.onresize
handler for the same purpose. Both handlers also now guard against a missing
client, display, or zero-size container, and the secondary no longer assumes
.client-main is present in the document.
The multimon-layout layer parameter is parsed and consumed without
validation. A malformed payload throws out of JSON.parse inside the
instruction handler, which aborts the handler loop and tears down the client,
and geometry values that are not finite numbers propagate into the offset and
size calculations as NaN, leaving monitor windows positioned at NaN with no
error surfaced.

Client.js now guards both the parse and the callback dispatch, and requires
the parsed value to be an object before invoking the layout handler, so the
transport layer is self-protecting regardless of what a consumer does with
the value.

guacManageMonitor.js validates each entry: width, height, top and left must
be finite, with width and height strictly positive. Negative top and left
remain valid, since a monitor may sit above or to the left of the primary. An
entry that fails validation is treated the same as an absent monitor and
therefore flows into the existing missing-count tolerance rather than
immediately closing the window.

Adds ClientSpec.js covering the transport-level guard: malformed JSON, JSON
null, non-object JSON values, a handler that throws, and an unassigned
handler. Five of the seven specs fail against the unguarded parser.
…order.

The monitor map assigned positions by iterating the monitors object, so a
monitor's position was the order its window happened to be opened. That
position is what sendAllSizes() reports to guacd as the monitor index, so a
user who opens a window and then moves it to the left of an existing one gets
an arrangement that does not match what is on screen. It also prevented the
vertical arrangement noted as outstanding in the pull request, since a
stacked layout has no meaningful opening order.

Secondary monitors are now ordered by their reported geometry, left then top,
which orders a horizontal row, a vertical stack, and any combination of the
two. The main window remains position 0. A monitor that has not yet reported a
size keeps its relative opening order and sorts last, taking its place once a
size arrives.

This also closes a gap in the position sequence when a monitor is removed.
Positions were assigned before the closing monitor's entry was deleted, so
closing the middle of three left positions 1 and 3 with 2 missing. Since the
closing monitor now has no geometry to sort by, it is placed last and the
survivors keep a contiguous sequence. Guacd rejects a monitor index greater
than the current monitor count in order to keep the layout contiguous, so the
gap silently dropped a subsequent resize for the monitor that landed above it.

Replaces the "use the left value to reorder monitors if needed" TODO.
@corentin-soriano
corentin-soriano force-pushed the GUACAMOLE-288_multi_screen branch from 18d3783 to a36a6c3 Compare July 21, 2026 13:17

@corentin-soriano corentin-soriano left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for your work!

I've rebased my branch as you requested.

I have a few quick comments, even without having reviewed it in detail.
I'll try to test it quickly!

}

// Close additional monitors when window is unloaded
// Close additional monitors when window is unloaded

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

mistake?

Comment on lines +130 to +131


Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Extra blank line

Comment on lines +455 to +463
/**
* Sends the current size of the main element (the display container)
* to the Guacamole server, requesting that the remote display be
* resized. If the Guacamole client is not yet connected, it will be
* connected and the current size will sent through the initial
* handshake. If the size of the main element is not yet known, this
* function may need to be invoked multiple times until the size is
* known and the client may be connected.
*/

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Is anything missing from this documentation?

@@ -0,0 +1 @@
/home/ciro/code/guacamole-client_proto-spice/guacamole-common-js/node_modules No newline at end of file

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I think this line is not part of the PR.

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.

2 participants