Skip to content
Merged
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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ GitHub Release body. The format follows

### ✨ Added

- **`resoio launch` runs multiple instances**: `launch` no longer refuses when a
Resonite is already running — start as many as you like by giving each its own
`--data-path` (so they don't share a database). `--name <label>` is a
convenience that auto-allocates an isolated data tree under
`~/.resonite-io/instances/<label>/` — a separate WINEPREFIX, `-DataPath` /
`-CachePath` / `-LogsPath`, and a private Camera IPC queue token (so concurrent
instances neither share Resonite data nor cross-talk on the renderer frame
queue). Explicit `--prefix` / `--data-path` still win over the label defaults;
`--name` imposes no guard. Target a specific instance with
`--socket ~/.resonite-io/resonite-{pid}.sock`.
- **`resoio terminate-all`**: stop every running Resonite instance in one call
(`SIGTERM` → `SIGKILL` per process), printing one engine/renderer pair per
instance (`--format human|json`). The multi-instance counterpart to the
single-instance `terminate` (whose pid-only output is unchanged).
- **Typed Resonite launch options (`resoio.LaunchOptions`)**: pass Resonite's
own command-line launch arguments to `launch(options=...)` as a typed,
immutable value object — `data_path` (`-DataPath`), `cache_path` (`-CachePath`),
Expand All @@ -33,6 +47,22 @@ GitHub Release body. The format follows

### 🐛 Fixed

- **Camera froze right after the engine and renderer came up (no `--name`, and
on direct Gale / Steam launches)**: the engine self-generated a Camera IPC
queue token at runtime that the renderer (a separately-spawned Wine child)
never inherited, so the two bound different queues and the client hung waiting
for frames. The engine no longer self-generates a token. Instead the token is
always placed in the environment **before exec**: `resoio launch` injects a
unique one per instance (named or not), and a direct Gale / Steam launch sets
nothing, so the engine and renderer both fall back to the same fixed default
queue name. Either way both ends agree on one queue. (`--name` was already
unaffected because it injected the token before exec.)
- **`resoio terminate-all` reported a single instance as two**: it paired an
engine with its renderer only when the renderer appeared in the engine's host
process subtree, but Wine often reparents the renderer out of it, so one
instance came back as two `pid=0` rows. Pairing now keys off the Camera IPC
queue token the engine and renderer share (falling back to the process tree),
so a single instance is reported as one engine/renderer pair.
- **`resoio launch` hung on a host install under `$HOME`**: the renderer never
started (and the engine waited forever) when launching from a Steam install
beneath `$HOME`. umu/Proton's game-drive feature maps `$HOME` onto a Wine drive
Expand Down
36 changes: 27 additions & 9 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,29 @@ resoio --help
| `resoio contact` | Contact | unary | Browse and manage contacts (friends) via nested subcommands: `list` (`--search` / `--filter all\|accepted\|requests` / `--include-hidden`), `get`, `search` (`--exact`), `add` (`--username`), `accept`, `remove`. `list` hides dash-hidden (ignored / blocked) contacts by default; `--include-hidden` shows them. The mutating ops (`add` / `accept` / `remove`) write the real cloud contact list. |
| `resoio auth` | Auth | unary | Resonite cloud sign-in via nested subcommands: `login` (credential positional; password via env/stdin/prompt, never a flag), `logout`, `status`. |
| `resoio cursor` | Cursor | unary | Set / center / get / release the desktop cursor. `set` and `center` hold the position until `release`. |
| `resoio launch` | — (umu-launcher) | local process | Start Resonite (engine + renderer) via umu-launcher and print both host PIDs. `-e/--exe` / `RESONITE_EXE` and `-p/--profile` / `MOD_PATH` select the install + mod profile; `--vanilla` skips the mod. `--prefix` (`WINEPREFIX`) and `--proton-path` (`PROTONPATH`, default `GE-Proton`) pick the Wine prefix and Proton build. `--data-path` / `--cache-path` / `--logs-path` relocate Resonite's database / cache / logs; any other Resonite launch option goes after `--` (e.g. `-- -Screen -Verbose`). Non-gRPC. |
| `resoio terminate` | — (signals) | local process | Force-stop Resonite by killing the engine + renderer (`SIGTERM` → `SIGKILL`). Takes `[resonite_pid] [renderer_pid]` (from `launch`) or auto-detects the single running instance. Non-gRPC. |
| `resoio launch` | — (umu-launcher) | local process | Start Resonite (engine + renderer) via umu-launcher and print both host PIDs. `-e/--exe` / `RESONITE_EXE` and `-p/--profile` / `MOD_PATH` select the install + mod profile; `--vanilla` skips the mod. `--prefix` (`WINEPREFIX`) and `--proton-path` (`PROTONPATH`, default `GE-Proton`) pick the Wine prefix and Proton build. `--data-path` / `--cache-path` / `--logs-path` relocate Resonite's database / cache / logs; any other Resonite launch option goes after `--` (e.g. `-- -Screen -Verbose`). Never refuses when an instance is already running — run several at once by giving each a distinct `--data-path` (and prefix), or use `--name <label>` to auto-allocate an isolated tree under `~/.resonite-io/instances/<label>/` (own WINEPREFIX + data + Camera queue). Non-gRPC. |
| `resoio terminate` | — (signals) | local process | Force-stop a single Resonite instance by killing the engine + renderer (`SIGTERM` → `SIGKILL`). Takes `[resonite_pid] [renderer_pid]` (from `launch`) or auto-detects the single running instance. Non-gRPC. |
| `resoio terminate-all` | — (signals) | local process | Force-stop **every** running Resonite instance (`SIGTERM` → `SIGKILL` per process); prints one engine/renderer pair per instance (`--format human\|json`). The multi-instance counterpart to `terminate`. Non-gRPC. |
| `resoio shutdown` | Lifecycle | unary | Ask the engine to quit gracefully (`Lifecycle.Shutdown`). Best-effort — on Linux the engine often hangs during teardown and never exits, so follow up with `terminate` when you need a guaranteed stop. Prints the engine's host PID (from `Info`). |

`record` is the Resonite → Python capture command (it pulls Camera and Speaker), while `mic`
is its independent Python → Resonite counterpart.

`launch` / `terminate` are **local process control** (no gRPC). `launch` spawns the
umu-launcher chain and waits until the **engine** (`resonite_pid`) and **renderer**
(`renderer_pid`) host processes appear, printing both; it refuses to start a second instance.
`terminate` signals those two PIDs (`SIGTERM` → `SIGKILL`); given no PIDs it auto-detects the
single running instance (and errors if it finds more than one). Because they work from the host
process table they run before the UDS exists and regardless of whether the client is reachable.
`launch` / `terminate` / `terminate-all` are **local process control** (no gRPC). `launch` spawns
the umu-launcher chain and waits until the **engine** (`resonite_pid`) and **renderer**
(`renderer_pid`) host processes appear, printing both. It **never refuses** because another
instance is already running — to run **several side by side**, give each launch its own
`--data-path` (so the instances don't share a Resonite database). `--name <label>` is a
convenience that auto-allocates an isolated tree under `~/.resonite-io/instances/<label>/` (its
own WINEPREFIX, `-DataPath` / `-CachePath` / `-LogsPath`, and a private Camera IPC queue so the
instances never cross-talk on the renderer queue); explicit `--prefix` / `--data-path` still win
over the label defaults, and `--name` imposes no guard. Each engine binds its own
`resonite-{pid}.sock`, so target a specific instance with
`--socket ~/.resonite-io/resonite-{pid}.sock` (or `RESONITE_IO_SOCKET`). `terminate` signals one
instance's two PIDs (`SIGTERM` → `SIGKILL`); given no PIDs it auto-detects the single running
instance (and errors if it finds more than one — use explicit PIDs or `terminate-all`).
`terminate-all` stops every instance at once. Because they work from the host process table they
run before the UDS exists and regardless of whether the client is reachable.
`shutdown`, by contrast, is a pure gRPC call (`Lifecycle.Shutdown`) that asks a **running**
engine to quit gracefully — use it when the client is up and reachable. It is best-effort,
though: on Linux (including the dev container) FrooxEngine frequently hangs during teardown and
Expand Down Expand Up @@ -182,9 +192,17 @@ resoio launch --format json # {"resonite_pid": ..., "renderer_pid": ...}
# Pick a specific Wine prefix and Proton build
resoio launch --prefix ~/prefixes/resonite --proton-path GE-Proton

# Stop it — by the PIDs from launch, or auto-detect the running instance
# Run two instances side by side. --name auto-allocates isolated data trees;
# or separate them yourself with --data-path. Then talk to one by its socket.
resoio launch --name a # {"resonite_pid": 12345, ...}
resoio launch --name b # {"resonite_pid": 23456, ...}
resoio launch --data-path ~/res-c/data --prefix ~/res-c/pfx # manual isolation
resoio --socket ~/.resonite-io/resonite-12345.sock ping

# Stop it — by the PIDs from launch, auto-detect the single instance, or stop every instance
resoio terminate 12345 12399
resoio terminate
resoio terminate-all --format json # [{"resonite_pid": ..., "renderer_pid": ...}, ...]

# ... or ask a running engine to quit gracefully over gRPC (prints the engine host PID)
resoio shutdown
Expand Down
2 changes: 1 addition & 1 deletion mod/prebuilt/renderer.sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
6ca5f47b4593c3c561b6f3ec1ee4a50ec645fe8a30d7a98c7a780acf6b04fc89
f040d1a69a647e02875f9781701a71d39ddd869d25e9347c89bdb75b80942405
Binary file modified mod/prebuilt/renderer/ResoniteIO.Renderer.dll
Binary file not shown.
Binary file modified mod/prebuilt/renderer/ResoniteIO.Renderer.pdb
Binary file not shown.
Binary file modified mod/prebuilt/renderer/ResoniteIO.RendererShared.dll
Binary file not shown.
26 changes: 25 additions & 1 deletion mod/src/ResoniteIO.RendererShared/IpcSocketPaths.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System;

namespace ResoniteIO.RendererShared;

/// <summary>
Expand All @@ -13,7 +15,29 @@ public static class IpcSocketPaths
{
public const string OwnerId = "net.mlshukai.resonite-io.camera";

public const string QueueName = "resonite-io-camera-frames";
/// <summary><see cref="QueueName"/> を上書きする環境変数名。</summary>
/// <remarks>
/// 多重起動時に各インスタンスの queue を分離するための逃げ道。token は exec 前に
/// env へ載せる必要がある: engine が runtime に <c>Environment.SetEnvironmentVariable</c>
/// で set しても、その値は別プロセスとして起動する renderer に届かず、engine/renderer
/// が別 queue を掴んで client が frame 待ちでハングする。
/// <list type="bullet">
/// <item><c>resoio launch</c> 経由: Python launcher が起動前に instance 固有 token を
/// 注入し、engine と renderer (Wine 子プロセス) が同じ env を継承して queue を分離する。</item>
/// <item>Gale / Steam 直接起動: 何も set されず、engine/renderer 双方が固定名
/// (<see cref="_defaultQueueName"/>) に fallback して一致する (単一インスタンス)。</item>
/// </list>
/// <c>GrpcHost.ResolveSocketPath</c> と同じ「env override → 既定値」パターン。
/// </remarks>
public const string QueueNameEnvVar = "RESONITE_IO_CAMERA_QUEUE";

private const string _defaultQueueName = "resonite-io-camera-frames";

/// <summary>共有メモリ queue の名前。<see cref="QueueNameEnvVar"/> で上書き可能。</summary>
public static string QueueName =>
Environment.GetEnvironmentVariable(QueueNameEnvVar) is { Length: > 0 } overridden
? overridden
: _defaultQueueName;

public const string FrameMessageId = "frame";

Expand Down
9 changes: 9 additions & 0 deletions mod/src/ResoniteIO/ResoniteIOPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ public override void Load()
{
Log = base.Log;

// Camera IPC queue token (RESONITE_IO_CAMERA_QUEUE) はここでは生成しない。
// engine が runtime に Environment.SetEnvironmentVariable で set しても、その値は
// 別プロセスとして起動する renderer に届かず (engine/renderer が別 queue を掴んで
// client が frame 待ちでハングする)。代わりに token は exec 前に env へ載せる:
// - `resoio launch` 経由: launcher が起動前に instance 固有 token を注入し、
// engine と renderer (Wine 子プロセス) が同じ env を継承して queue を分離する。
// - Gale / Steam 直接起動: 何も set されず、両側が IpcSocketPaths.QueueName の
// 固定デフォルト名に fallback して一致する (単一インスタンス)。

// resolver は Core 型に触れる前に attach する必要があるため、ManualLogSource を
// 直接渡し ILogSink を経由しない (上記 remarks 参照)。
var pluginDirectory =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System;
using ResoniteIO.RendererShared;
using Xunit;

namespace ResoniteIO.Core.Tests.IpcSocketPaths;

/// <summary>
/// <see cref="ResoniteIO.RendererShared.IpcSocketPaths.QueueName"/> の env-override 解決を
/// 検証する。多重起動時に engine が token を env へ流し、renderer が継承して同名を読む経路の
/// 単体保証。env は process-global なので単一テスト内で set/clear を直列化し、元値を復元する。
/// </summary>
public sealed class IpcSocketPathsTests
{
/// <summary>env 未設定なら従来固定名、設定すればその値を返す。</summary>
[Fact]
public void QueueName_honours_env_override_and_falls_back_to_default()
{
var original = Environment.GetEnvironmentVariable(
RendererShared.IpcSocketPaths.QueueNameEnvVar
);
try
{
Environment.SetEnvironmentVariable(RendererShared.IpcSocketPaths.QueueNameEnvVar, null);
Assert.Equal("resonite-io-camera-frames", RendererShared.IpcSocketPaths.QueueName);

Environment.SetEnvironmentVariable(
RendererShared.IpcSocketPaths.QueueNameEnvVar,
"resonite-io-camera-instance-a"
);
Assert.Equal("resonite-io-camera-instance-a", RendererShared.IpcSocketPaths.QueueName);

// 空文字は「未設定」と同じ扱いで fallback する。
Environment.SetEnvironmentVariable(RendererShared.IpcSocketPaths.QueueNameEnvVar, "");
Assert.Equal("resonite-io-camera-frames", RendererShared.IpcSocketPaths.QueueName);
}
finally
{
Environment.SetEnvironmentVariable(
RendererShared.IpcSocketPaths.QueueNameEnvVar,
original
);
}
}
}
2 changes: 2 additions & 0 deletions python/src/resoio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
LaunchResult,
launch,
terminate,
terminate_all,
)
from resoio.lifecycle import LifecycleClient, shutdown
from resoio.locomotion import (
Expand Down Expand Up @@ -165,5 +166,6 @@
"launch",
"shutdown",
"terminate",
"terminate_all",
"wait_for_ready",
]
2 changes: 2 additions & 0 deletions python/src/resoio/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
session,
shutdown,
terminate,
terminate_all,
wait,
world,
)
Expand Down Expand Up @@ -66,6 +67,7 @@
launch,
shutdown,
terminate,
terminate_all,
]


Expand Down
11 changes: 11 additions & 0 deletions python/src/resoio/cli/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ def register(
"path. Default: GE-Proton."
),
)
parser.add_argument(
"--name",
default=None,
help=(
"Instance label for multi-launch. Allocates an isolated data tree "
"(WINEPREFIX + data/cache/logs) and a private camera queue under "
"~/.resonite-io/instances/<name>/, so several instances run side by "
"side without sharing data. Omit for the single-instance default."
),
)
parser.add_argument(
"--data-path",
default=None,
Expand Down Expand Up @@ -117,6 +127,7 @@ async def _run(args: argparse.Namespace) -> int:
extra_args=args.args,
prefix=args.prefix,
proton_path=args.proton_path,
name=args.name,
wait_timeout=args.timeout,
)
except LauncherError as exc:
Expand Down
66 changes: 66 additions & 0 deletions python/src/resoio/cli/terminate_all.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""``resoio terminate-all`` subcommand: stop every running Resonite instance.

The multi-instance counterpart to ``resoio terminate`` (which targets a single
instance). Detects all engine + renderer processes for the configured install
and stages ``SIGTERM`` -> ``SIGKILL`` on each, printing the engine/renderer pairs
it signalled. Unlike the pid-only ``terminate``, this returns a *list* and so
carries ``--format human|json`` for machine-readable output.
"""

from __future__ import annotations

import argparse
import asyncio
import sys

from resoio.cli import output


def register(
subparsers: argparse._SubParsersAction[argparse.ArgumentParser], # pyright: ignore[reportPrivateUsage]
common: argparse.ArgumentParser,
) -> None:
"""Register the ``terminate-all`` subparser on the top-level parser."""
parser = subparsers.add_parser(
"terminate-all",
parents=[common],
help="Stop every running Resonite instance (SIGTERM -> SIGKILL); lists pairs.",
description=(
"Force-stop all running Resonite clients by signalling each engine and "
"renderer process. The multi-instance counterpart to 'resoio terminate'. "
"Idempotent (already-dead PIDs are skipped). Prints one engine/renderer "
"pair per instance signalled, or 'no running Resonite instances found'."
),
)
parser.add_argument(
"--timeout",
type=float,
default=3.0,
help="Seconds to wait after SIGTERM before SIGKILL, per PID (default: 3).",
)
output.add_format_argument(parser)
parser.set_defaults(func=_run)


async def _run(args: argparse.Namespace) -> int:
# Defer the heavy import (psutil) to keep `resoio --help` fast.
from resoio.launcher import LauncherError, terminate_all

try:
results = await asyncio.to_thread(terminate_all, timeout=args.timeout)
except LauncherError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1

if output.is_structured(args.format):
# list[LaunchResult] -> [{"resonite_pid": .., "renderer_pid": ..}, ...].
output.emit(results, args.format)
return 0

if not results:
print("no running Resonite instances found")
return 0
for result in results:
print(f"resonite_pid={result.resonite_pid} renderer_pid={result.renderer_pid}")
print(f"terminated {len(results)} instance(s)")
return 0
Loading
Loading