Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,20 @@ jobs:
steps:
- uses: actions/checkout@v5

# Everything is built in Docker, so the runner needs nothing else. Both
# builds share layers, which is why the tests are cheap to gate on.
# Master fills the layer cache that pull requests read from, so a branch
# does not build whisper.cpp from source before its first check.
- uses: docker/setup-buildx-action@v3

# Everything is built in Docker, so the runner needs nothing else. The
# tests build from the sources rather than from the finished app, so the
# gate does not wait on whisper at all.
- name: Run the headless tests
run: ./scripts/test.sh

- name: Build the bundle
run: ./scripts/build.sh --build-arg PROFILE=nightly
run: |
./scripts/build.sh --build-arg PROFILE=nightly \
--cache-from type=gha --cache-to type=gha,mode=max

- name: Pack it
run: |
Expand Down
14 changes: 11 additions & 3 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,21 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}

# Everything is built in Docker, so the runner needs nothing else. Both
# builds share layers, which is why the tests are cheap to gate on.
# The bundle links whisper.cpp, which is built from source and is minutes
# of CPU. Nothing about it changes between pushes, so it is cached and
# only ever paid for once per pull request rather than once per push.
- uses: docker/setup-buildx-action@v3

# Everything is built in Docker, so the runner needs nothing else. The
# tests build from the sources rather than from the finished app, so the
# gate does not wait on whisper at all.
- name: Run the headless tests
run: ./scripts/test.sh

- name: Build the bundle
run: ./scripts/build.sh --build-arg PROFILE=nightly
run: |
./scripts/build.sh --build-arg PROFILE=nightly \
--cache-from type=gha --cache-to type=gha,mode=max


windows:
Expand Down
33 changes: 24 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,8 @@ RUN git clone --quiet --depth 1 --branch v1.9.1 \
&& cmake --build /whisper/build --parallel \
&& cmake --install /whisper/build

# --- stage 3: compile -------------------------------------------------------
FROM deps AS build

COPY --from=whisper /usr/local/ /usr/local/
ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig
# --- stage 3: the sources ---------------------------------------------------
FROM deps AS sources

# Which build this is: "nightly" gives the app its own id, settings, data
# directory and workspaces, so it installs beside a release rather than over
Expand All @@ -88,14 +85,32 @@ COPY data ./data
COPY src ./src
COPY tests ./tests

# --- stage 4: headless tests (CI gate: docker build --target test .) --------
#
# From the sources rather than from the compiled app, so the gate does not wait
# on whisper.cpp. Voice is the window's: nothing the tests touch links it, and
# meson leaves it out when it is not there.
FROM sources AS test

ARG PROFILE=default
ARG COMMIT=

RUN meson setup /build --prefix=/usr --buildtype=release \
-Dprofile="$PROFILE" -Dcommit="$COMMIT" \
&& meson compile -C /build
&& meson test -C /build --print-errorlogs

# --- stage 4: headless tests (CI gate: docker build --target test .) --------
FROM build AS test
# --- stage 5: compile the app, voice and all --------------------------------
FROM sources AS build

ARG PROFILE=default
ARG COMMIT=

COPY --from=whisper /usr/local/ /usr/local/
ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig

RUN meson test -C /build --print-errorlogs
RUN meson setup /build --prefix=/usr --buildtype=release \
-Dprofile="$PROFILE" -Dcommit="$COMMIT" \
&& meson compile -C /build

# --- stage 5: assemble the redistributable bundle ---------------------------
FROM build AS staging
Expand Down
32 changes: 27 additions & 5 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,19 @@ gnome = import('gnome')
cc = meson.get_compiler('c')
is_windows = host_machine.system() == 'windows'
is_macos = host_machine.system() == 'darwin'
has_server = not is_windows and not is_macos

#
# Every platform hosts the daemon.
#
# The client is about to stop having a second way to do anything: it adopts or
# spawns a daemon and talks to it, locally as well as remotely, so a machine
# that cannot serve is a machine xd cannot run on. The daemon's own code is
# already portable -- server.c and turn.c use nothing Unix-only -- and a shell
# is the single exception, which Windows takes as a stub the way its client
# already does.
#
has_server = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bundle OpenSSL before enabling the Windows server

On an installed Windows MSI without a separately installed OpenSSL executable, xd serve now reaches ensure_certificate(), which spawns openssl to create the initial certificate and exits before listening when that command is unavailable. The Windows release path in scripts/bundle-windows.sh packages xd.exe, runtime data, modules, and linked DLLs but no openssl.exe, so the newly enabled daemon is unusable on a clean Windows installation; package the generator or replace this runtime dependency before enabling the server there.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Port the daemon-turn tests before enabling them on Windows

Even after the terminal test is gated, enabling has_server on Windows runs several remote tests that create extensionless #!/bin/sh files named claude or git and expect GSubprocess to execute them directly. Native Windows process creation does not interpret shebang scripts, so the first such test (/remote/images-are-uploaded-to-the-daemon) cannot start its fake backend and the Windows test job still fails; use a portable test child executable or exclude these POSIX-only cases on Windows.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use a console-capable entry point for the Windows daemon

When the newly enabled Windows server is launched as xd.exe serve --pair, the executable is still linked with win_subsystem: 'windows' in src/meson.build, and there is no AttachConsole or equivalent setup. A Windows-subsystem process launched from a terminal therefore has no reliable console-backed stdout, so the pairing code printed by run_serve() is not available to the user; split out a console daemon executable or attach and initialize the parent console before enabling this CLI mode.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Detect installed bundles outside the Linux layout

On macOS and Windows, xd serve --auto-update always fails the installed-bundle check because daemon_install_dir() only reads /proc/self/exe, requires a sibling Linux xd.sh, and compares the result with ~/.local/opt/<name>. Neither the macOS .app nor the Windows MSI uses that layout, so enabling the daemon on those platforms exposes a documented option that unconditionally exits with “requires an installed bundle”; add platform-specific executable and installer-layout detection or suppress the option there.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject both Windows path separators before hosting

With has_server enabled on Windows, folder requests reach valid_folder_name() in src/remote/server.c, which rejects only G_DIR_SEPARATOR (\ on Windows), although Windows also accepts / as a separator. A paired client can therefore submit a name such as existing/../../outside; if the intermediate directory exists, g_file_make_directory() resolves the mixed-separator path outside the selected workspace parent and xd_folder_settings_ensure() writes metadata there. Validate every character with G_IS_DIR_SEPARATOR() (or explicitly reject both separators) before enabling these operations on Windows.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Create the default workspace root before serving

On a fresh macOS installation where the window has never run, enabling has_server lets xd serve start with a nonexistent default Workspaces directory: xd_app_workspaces_root() only computes the path, while directory creation currently happens in xd_fs_tree_new(), which the daemon does not call. watch_for_local_changes() consequently fails to install its monitor, and the first new-folder request fails because g_file_make_directory() cannot create a child under the missing parent. Ensure the server creates its root (with parents) before starting the listener and monitor.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Port diff reads before enabling the Windows server

On an installed Windows MSI, opening the diff pane for a chat hosted by this daemon immediately sends base and working-all diff-read requests, but handle_diff_read() implements both by spawning sh -c (src/remote/server.c:2631-2639). The Windows payload assembled by scripts/bundle-windows.sh contains only xd.exe, linked DLLs, and runtime data—not sh.exe—so these requests fail even when Git itself is installed (the normal Git-for-Windows PATH exposes git.exe, not its internal shell). Replace these scripts with portable subprocess calls or explicitly disable the diff endpoint on Windows.

Useful? React with 👍 / 👎.

has_daemon_terminal = not is_windows

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Gate the terminal integration test on daemon-terminal support

On Windows, has_server = true makes tests/meson.build build and run the entire remote suite, including /remote/terminal-is-shared-and-replayable. That test requires terminal-open to succeed, while the Windows terminal-stub.c deliberately returns G_IO_ERROR_NOT_SUPPORTED, so every Windows meson test run fails; register this test only when has_daemon_terminal is true, or add a Windows-specific refusal assertion.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Replace the Linux-only terminal cleanup on macOS

When a macOS client closes a daemon-hosted terminal whose shell or child ignores the PTY's implicit hangup, the explicit cleanup does nothing: xd_remote_terminal_close() and force_terminal_down() call visit_session_members(), but that function enumerates /proc, which macOS does not provide. The two-second callback then emits closed without signaling any process, leaving the shell or its jobs running after the terminal is removed from the server. Use a macOS process/session enumeration mechanism or at least signal the recorded session/process group before enabling daemon terminals there.

Useful? React with 👍 / 👎.

has_voice = not is_windows and not is_macos

glib_dep = dependency('glib-2.0', version: '>= 2.72')
Expand All @@ -52,10 +64,20 @@ pango_dep = dependency('pango')
sqlite_dep = dependency('sqlite3')
cmark_dep = dependency('libcmark', version: '>= 0.30')

#
# Voice is the window's, and only the window's.
#
# Nothing under test needs it -- util/voice-data.c is core and parses; it is
# chat/voice-input.c that listens and transcribes. Asking for these as
# required would make every build that only wants the tests compile
# whisper.cpp first, which is minutes of CPU for a library it never links.
#
if has_voice
pulse_simple_dep = dependency('libpulse-simple')
soup_dep = dependency('libsoup-3.0')
whisper_dep = dependency('whisper', version: '>= 1.9.1')
pulse_simple_dep = dependency('libpulse-simple', required: false)
soup_dep = dependency('libsoup-3.0', required: false)
whisper_dep = dependency('whisper', version: '>= 1.9.1', required: false)

has_voice = pulse_simple_dep.found() and soup_dep.found() and whisper_dep.found()
endif

if is_windows
Expand All @@ -66,7 +88,7 @@ else
vte_dep = dependency('vte-2.91-gtk4')
endif

if has_server
if has_daemon_terminal
util_dep = cc.find_library('util')
else
util_dep = declare_dependency()
Expand Down
8 changes: 8 additions & 0 deletions src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,13 @@ print_version (void)
static gboolean
repair_daemon_cwd (GError **error)
{
#ifdef G_OS_WIN32
/*
* Windows holds a directory open while it is anyone's working directory, so
* the deleted-cwd this repairs cannot arise there.
*/
return TRUE;
#else
g_autofree char *cwd = getcwd (NULL, 0);
int saved_errno;

Expand All @@ -508,6 +515,7 @@ repair_daemon_cwd (GError **error)
"Cannot select a working directory for the daemon: %s",
g_strerror (saved_errno));
return FALSE;
#endif
}
#endif

Expand Down
9 changes: 8 additions & 1 deletion src/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,15 @@ if has_server
xd_core_sources += files(
'remote/server.c',
'remote/turn.c',
'remote/terminal.c',
)

# A pty is the daemon's only platform-specific part; Windows serves every
# other op and refuses this one, which is what the client already does.
if has_daemon_terminal
xd_core_sources += files('remote/terminal.c')
else
xd_core_sources += files('remote/terminal-stub.c')
endif
endif

# Pango, but not GTK: the Markdown converter validates the markup it produces,
Expand Down
79 changes: 71 additions & 8 deletions src/remote/server.c
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ typedef struct
GIOStream *stream;
GQueue *outgoing; /* complete JSON lines, oldest first */
gsize outgoing_bytes;

/*
* What stops an in-flight read or write before the stream under it closes.
*
* Without this the fd went away while GIO still had a source polling it.
* Linux reports that as POLLNVAL on the one entry and carries on, so it was
* invisible there; BSD fails the whole poll with EBADF, which GLib treats as
* fatal -- so the daemon only died of it on macOS.
*/
GCancellable *cancellable;
guint refs;
gboolean authed;
gboolean writing;
Expand Down Expand Up @@ -121,6 +131,7 @@ connection_unref (Connection *connection)
return;

g_clear_pointer (&connection->outgoing, outgoing_free);
g_clear_object (&connection->cancellable);
g_clear_object (&connection->in);
g_clear_object (&connection->stream);
g_free (connection);
Expand Down Expand Up @@ -161,8 +172,8 @@ write_next_json (Connection *connection)

connection->writing = TRUE;
g_output_stream_write_all_async (
connection->out, line, strlen (line), G_PRIORITY_DEFAULT, NULL,
on_json_written, connection_ref (connection));
connection->out, line, strlen (line), G_PRIORITY_DEFAULT,
connection->cancellable, on_json_written, connection_ref (connection));
}

/*
Expand Down Expand Up @@ -999,6 +1010,18 @@ handle_new_chat (Connection *connection,
}

send_done (connection, chat_id);

/*
* Said here rather than waited for.
*
* Every other change to the tree announces itself this way; this one left it
* to the watch on the database directory, which sees the write the chat
* became and broadcasts from there. That works where the watch reports a
* write promptly, which is to say on inotify -- the kqueue and Windows
* backends do not, and the device that asked for the chat waited for a tree
* that never came.
*/
broadcast_tree (self);
}

static void
Expand Down Expand Up @@ -3473,8 +3496,20 @@ connection_close (Connection *connection)
if (connection->server != NULL)
g_ptr_array_remove_fast (connection->server->connections, connection);

if (connection->stream != NULL)
g_io_stream_close (connection->stream, NULL, NULL);
/*
* Cancelled, not closed.
*
* Cancelling does not finish the read in flight; that lands on a later
* iteration, and the source polling the descriptor lives until it does.
* Closing here would take the descriptor away underneath that source --
* which Linux reports as POLLNVAL on the one entry and carries on from, and
* BSD turns into an EBADF that fails the whole poll and is fatal to GLib.
*
* The stream closes when the last reference to it goes, which is after the
* cancelled read has completed and let go of the connection. Nothing is
* leaked by waiting, and nothing polls a descriptor that has gone.
*/
g_cancellable_cancel (connection->cancellable);
}

static void
Expand Down Expand Up @@ -3627,7 +3662,8 @@ static void
read_next_request (Connection *connection)
{
g_data_input_stream_read_line_async (connection->in, G_PRIORITY_DEFAULT,
NULL, on_line_read, connection);
connection->cancellable,
on_line_read, connection);
}

static void
Expand Down Expand Up @@ -3664,14 +3700,16 @@ on_incoming (GSocketService *service,
connection = g_new0 (Connection, 1);
connection->refs = 1;
connection->server = self;
connection->cancellable = g_cancellable_new ();
connection->outgoing = g_queue_new ();
g_ptr_array_add (self->connections, connection);
connection->stream = g_object_ref (tls);
connection->in = g_data_input_stream_new (g_io_stream_get_input_stream (tls));
connection->out = g_io_stream_get_output_stream (tls);

g_tls_connection_handshake_async (G_TLS_CONNECTION (tls), G_PRIORITY_DEFAULT,
NULL, on_handshake, connection);
connection->cancellable, on_handshake,
connection);

return TRUE;
}
Expand Down Expand Up @@ -3839,6 +3877,18 @@ xd_remote_server_dispose (GObject *object)
{
XdRemoteServer *self = XD_REMOTE_SERVER (object);

/*
* Stopped, not closed -- the same reason the connections below are only
* cancelled.
*
* Stopping ends the accepting; it does not take the listening sockets'
* sources out of the main context, which happens when the service is
* finalized. Closing the descriptors before that left sources watching
* descriptors that had gone: POLLNVAL on Linux, which carries on, and EBADF
* on BSD, which fails the whole poll and is fatal to GLib.
*
* Letting go of the service does both in the right order.
*/
if (self->service != NULL)
g_socket_service_stop (self->service);

Expand Down Expand Up @@ -3868,12 +3918,25 @@ xd_remote_server_dispose (GObject *object)
connection->authed = FALSE;
connection->closed = TRUE;

if (connection->stream != NULL)
g_io_stream_close (connection->stream, NULL, NULL);
/* connection_close would remove from the array being walked, so it
* is done by hand here. Cancelled rather than closed, for the same
* reason: the read in flight still owns the descriptor's source. */
g_cancellable_cancel (connection->cancellable);
}
}

g_clear_handle_id (&self->local_change_id, g_source_remove);
/*
* Cancelled before it is dropped, which is the only way to stop a monitor.
*
* Letting go of the reference alone leaves the backend to come apart on its
* own schedule. Linux watches a whole tree through one inotify descriptor
* and never noticed; macOS gives each watch a kqueue descriptor of its own,
* closes it as the monitor goes, and leaves the source watching it -- which
* is an EBADF out of the next poll, and fatal to GLib.
*/
if (self->tree_watch != NULL)
g_file_monitor_cancel (self->tree_watch);
g_clear_object (&self->tree_watch);
if (self->storage != NULL)
{
Expand Down
Loading
Loading