From 884de47607adf4cef0c4a03f9746f5373d5f08a1 Mon Sep 17 00:00:00 2001 From: Asher Feldman <59994+asher@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:16:16 -0700 Subject: [PATCH 1/2] fix(completion): image generate and bench complete the image models of the config --- CHANGELOG.md | 2 ++ gmlx/commands/completion.py | 24 +++++++++++++++++++++++- tests/commands/test_completion.py | 24 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce510072d..215e397ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,8 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- Tab completion after `gmlx image generate` and `gmlx image bench` offered + only files, not the image models of your configuration. - `gmlx distill gen` stopped with Ctrl-C could leave out a reply that had already arrived. - A chat template with a `{% generation %}` block was refused by diff --git a/gmlx/commands/completion.py b/gmlx/commands/completion.py index 596c5a49c..8872dc401 100644 --- a/gmlx/commands/completion.py +++ b/gmlx/commands/completion.py @@ -245,6 +245,25 @@ def _model_candidates(words: list[str]) -> list[str]: return out +def _image_model_candidates(words: list[str]) -> list[str]: + """``iddit file`` lines for every ``image_models`` entry in the + resolved config.""" + path = _config_path_from(words) + if not path or not os.path.exists(os.path.expanduser(path)): + return [] + import gmlx.config as cfgmod + + try: + cfg = cfgmod.load_config(os.path.expanduser(path)) + except Exception: # noqa: BLE001 - a malformed config just yields no model names + return [] + out: list[str] = [] + for mid, m in cfg.image_models.items(): + base = os.path.basename(getattr(m, "path", "") or "") + out.append(f"{mid}\t{base}" if base else mid) + return out + + # Launch targets that are not coding agents, grouped as launch.py groups them. _HARNESS_KINDS = { "hermes": "agent runtime", "goose": "agent runtime", @@ -472,8 +491,11 @@ def _positional_candidates(verb: str, after_verb: list[str]) -> list[str]: return [] if filled else [f"{a}\timage action" for a in _IMAGE_ACTIONS] if verb == "comfyui": return [] if filled else [f"{a}\tcomfyui action" for a in _COMFYUI_ACTIONS] + if verb in ("image generate", "image bench"): + # an image_models id of the config, or a DiT GGUF + return [] if filled else ["::files", *_image_model_candidates(after_verb)] if verb.startswith("image "): - # generate and bench take a DiT GGUF, pack-vae a safetensors VAE + # pack-vae takes a safetensors VAE return [] if filled else ["::files"] return [] diff --git a/tests/commands/test_completion.py b/tests/commands/test_completion.py index 673a30444..41d5d0807 100644 --- a/tests/commands/test_completion.py +++ b/tests/commands/test_completion.py @@ -160,6 +160,30 @@ def test_model_positional_drops_off_once_model_given(tmp_path): assert completion._complete(["run", "--config", cfg, "qwen-fast", ""]) == [] +def test_image_generate_and_bench_list_image_model_ids_and_files(tmp_path): + cfg = tmp_path / "models.yaml" + cfg.write_text(textwrap.dedent(""" + server: + model_dirs: [] + models: + qwen-fast: + path: /tmp/qwen.gguf + image_models: + moss-image-q4: + path: /tmp/MOSS-Image-SFT-Q4_K_M.gguf + encoder: qwen-fast + vae: /tmp/moss-vae.gguf + """).strip()) + for action in ("generate", "bench"): + lines = completion._complete(["image", action, "--config", str(cfg), ""]) + assert lines[0] == "::files" + assert "moss-image-q4\tMOSS-Image-SFT-Q4_K_M.gguf" in lines + assert "qwen-fast" not in _vals(lines) # chat models are not image models + assert completion._complete( + ["image", action, "--config", str(cfg), "moss-image-q4", ""]) == [] + assert completion._complete(["image", "pack-vae", ""]) == ["::files"] + + def test_talk_positional_lists_model_ids_without_files(tmp_path): cfg = _write_cfg(tmp_path) lines = completion._complete(["talk", "--config", cfg, ""]) From b32c7f33c74e1f8aab0af9a94fb1bb2c0bb254a5 Mon Sep 17 00:00:00 2001 From: Asher Feldman <59994+asher@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:29:27 -0700 Subject: [PATCH 2/2] fix(container): the relay keeps a stream whose send to the client the socket refuses for now --- CHANGELOG.md | 2 ++ gmlx/container/relay.py | 7 ++++-- tests/container/test_container_relay.py | 33 +++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 215e397ee..652061b73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,8 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Tab completion after `gmlx image generate` and `gmlx image bench` offered only files, not the image models of your configuration. +- A large answer relayed into a `gmlx launch --container` session could end + early when the client read it slowly. - `gmlx distill gen` stopped with Ctrl-C could leave out a reply that had already arrived. - A chat template with a `{% generation %}` block was refused by diff --git a/gmlx/container/relay.py b/gmlx/container/relay.py index 412ff6170..dee6103fe 100644 --- a/gmlx/container/relay.py +++ b/gmlx/container/relay.py @@ -769,8 +769,11 @@ def _on_down(self, mask: int) -> None: if not self.checking: self._note_down(data) if mask & _WRITE and self.to_down: - sent = self.down.send(self.to_down) - del self.to_down[:sent] + # A Unix socket can refuse a send that select called ready. + try: + del self.to_down[:self.down.send(self.to_down)] + except (BlockingIOError, InterruptedError): + pass except OSError: self.close() return diff --git a/tests/container/test_container_relay.py b/tests/container/test_container_relay.py index 2814493b5..03ebbfc65 100644 --- a/tests/container/test_container_relay.py +++ b/tests/container/test_container_relay.py @@ -292,6 +292,39 @@ def flood(): assert got == total +def test_a_refused_send_to_the_client_keeps_the_stream(loop, tmp_path): + """macOS can refuse a send on a Unix socket that select reported ready. + The relay keeps the bytes and sends them later instead of closing.""" + target = relay.listen_socket(str(tmp_path / "t.sock")) + down, client = socket.socketpair() + try: + down.setblocking(False) + filled = 0 + while True: # fill the client buffer + try: + filled += down.send(b"x" * 65536) + except BlockingIOError: + break + pair = _in_loop(loop, lambda: relay._Pair(loop, down, [str(tmp_path / "t.sock")], + "forward 1")) + + def refused_send(): + pair.to_down += b"y" * 4096 + pair._on_down(relay._WRITE) + return pair.closed, len(pair.to_down) + assert _in_loop(loop, refused_send) == (False, 4096) + client.settimeout(10) + got = b"" + while len(got) < filled + 4096: + chunk = client.recv(1 << 20) + assert chunk + got += chunk + assert got[filled:] == b"y" * 4096 + finally: + client.close() + target.close() + + def test_forward_never_falls_back_to_ipv6_loopback(loop, tmp_path): """A program on ::1 at the forwarded port can be another one than the Mac service the user named, so the forward reaches 127.0.0.1 only."""