termio: posix_spawn based process launching
parent
633bf5f021
commit
5156723070
|
|
@ -409,7 +409,10 @@ pub fn getData(self: Command, comptime DT: type) ?*DT {
|
||||||
|
|
||||||
// Copied from Zig. This is a publicly exported function but there is no
|
// Copied from Zig. This is a publicly exported function but there is no
|
||||||
// way to get it from the std package.
|
// way to get it from the std package.
|
||||||
fn createNullDelimitedEnvMap(arena: mem.Allocator, env_map: *const EnvMap) ![:null]?[*:0]u8 {
|
fn createNullDelimitedEnvMap(
|
||||||
|
arena: mem.Allocator,
|
||||||
|
env_map: *const EnvMap,
|
||||||
|
) ![:null]?[*:0]u8 {
|
||||||
const envp_count = env_map.count();
|
const envp_count = env_map.count();
|
||||||
const envp_buf = try arena.allocSentinel(?[*:0]u8, envp_count, null);
|
const envp_buf = try arena.allocSentinel(?[*:0]u8, envp_count, null);
|
||||||
|
|
||||||
|
|
|
||||||
16
src/pty.zig
16
src/pty.zig
|
|
@ -83,7 +83,7 @@ const NullPty = struct {
|
||||||
/// Linux PTY creation and management. This is just a thin layer on top
|
/// Linux PTY creation and management. This is just a thin layer on top
|
||||||
/// of Linux syscalls. The caller is responsible for detail-oriented handling
|
/// of Linux syscalls. The caller is responsible for detail-oriented handling
|
||||||
/// of the returned file handles.
|
/// of the returned file handles.
|
||||||
pub const PosixPty = struct {
|
const PosixPty = struct {
|
||||||
pub const Error = OpenError || GetModeError || GetSizeError || SetSizeError || ChildPreExecError;
|
pub const Error = OpenError || GetModeError || GetSizeError || SetSizeError || ChildPreExecError;
|
||||||
|
|
||||||
pub const Fd = posix.fd_t;
|
pub const Fd = posix.fd_t;
|
||||||
|
|
@ -249,20 +249,6 @@ pub const PosixPty = struct {
|
||||||
posix.close(self.slave);
|
posix.close(self.slave);
|
||||||
posix.close(self.master);
|
posix.close(self.master);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This is the pre-exec that needs to happen for posix_spawn on macOS
|
|
||||||
/// because the API doesn't support all the actions/attrs we need to
|
|
||||||
/// create a proper terminal environment.
|
|
||||||
pub fn posixSpawnPreExec(self: PosixPty) !void {
|
|
||||||
// Set controlling terminal
|
|
||||||
switch (posix.errno(c.ioctl(self.slave, TIOCSCTTY, @as(c_ulong, 0)))) {
|
|
||||||
.SUCCESS => {},
|
|
||||||
else => |err| {
|
|
||||||
log.err("error setting controlling terminal errno={}", .{err});
|
|
||||||
return error.SetControllingTerminalFailed;
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Windows PTY creation and management.
|
/// Windows PTY creation and management.
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ pub fn threadEnter(
|
||||||
flatpakExit,
|
flatpakExit,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Irrelevant, should've been caught by the xev.Process type
|
||||||
.fork_exec => {},
|
.fork_exec => {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -587,10 +588,7 @@ const Subprocess = struct {
|
||||||
|
|
||||||
/// Union that represents the running process type.
|
/// Union that represents the running process type.
|
||||||
const Process = union(enum) {
|
const Process = union(enum) {
|
||||||
/// Standard POSIX fork/exec
|
|
||||||
fork_exec: Command,
|
fork_exec: Command,
|
||||||
|
|
||||||
/// Flatpak DBus command
|
|
||||||
flatpak: FlatpakHostCommand,
|
flatpak: FlatpakHostCommand,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -911,6 +909,23 @@ const Subprocess = struct {
|
||||||
self.pty = null;
|
self.pty = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cleanup we only run in our parent when we successfully start
|
||||||
|
// the process.
|
||||||
|
defer if (!in_child and self.process != null) {
|
||||||
|
if (comptime builtin.os.tag != .windows) {
|
||||||
|
// Once our subcommand is started we can close the slave
|
||||||
|
// side. This prevents the slave fd from being leaked to
|
||||||
|
// future children.
|
||||||
|
_ = posix.close(pty.slave);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful start we can clear out some memory.
|
||||||
|
if (self.env) |*env| {
|
||||||
|
env.deinit();
|
||||||
|
self.env = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
log.debug("starting command command={f}", .{ArgsFormatter{ .args = self.args }});
|
log.debug("starting command command={f}", .{ArgsFormatter{ .args = self.args }});
|
||||||
|
|
||||||
// If we can't access the cwd, then don't set any cwd and inherit.
|
// If we can't access the cwd, then don't set any cwd and inherit.
|
||||||
|
|
@ -979,11 +994,6 @@ const Subprocess = struct {
|
||||||
pid,
|
pid,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Once started, we can close the pty child side. We do this after
|
|
||||||
// wait right now but that is fine too. This lets us read the
|
|
||||||
// parent and detect EOF.
|
|
||||||
_ = posix.close(pty.slave);
|
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
.read = pty.master,
|
.read = pty.master,
|
||||||
.write = pty.master,
|
.write = pty.master,
|
||||||
|
|
@ -1036,19 +1046,6 @@ const Subprocess = struct {
|
||||||
log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"});
|
log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (comptime builtin.os.tag != .windows) {
|
|
||||||
// Once our subcommand is started we can close the slave
|
|
||||||
// side. This prevents the slave fd from being leaked to
|
|
||||||
// future children.
|
|
||||||
_ = posix.close(pty.slave);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Successful start we can clear out some memory.
|
|
||||||
if (self.env) |*env| {
|
|
||||||
env.deinit();
|
|
||||||
self.env = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.process = .{ .fork_exec = cmd };
|
self.process = .{ .fork_exec = cmd };
|
||||||
return switch (builtin.os.tag) {
|
return switch (builtin.os.tag) {
|
||||||
.windows => .{
|
.windows => .{
|
||||||
|
|
@ -1138,41 +1135,45 @@ const Subprocess = struct {
|
||||||
_ = try command.wait(false);
|
_ = try command.wait(false);
|
||||||
},
|
},
|
||||||
|
|
||||||
else => if (getpgid(pid)) |pgid| {
|
else => try killPid(pid),
|
||||||
// It is possible to send a killpg between the time that
|
}
|
||||||
// our child process calls setsid but before or simultaneous
|
}
|
||||||
// to calling execve. In this case, the direct child dies
|
}
|
||||||
// but grandchildren survive. To work around this, we loop
|
|
||||||
// and repeatedly kill the process group until all
|
|
||||||
// descendents are well and truly dead. We will not rest
|
|
||||||
// until the entire family tree is obliterated.
|
|
||||||
while (true) {
|
|
||||||
switch (posix.errno(c.killpg(pgid, c.SIGHUP))) {
|
|
||||||
.SUCCESS => log.debug("process group killed pgid={}", .{pgid}),
|
|
||||||
else => |err| killpg: {
|
|
||||||
if ((comptime builtin.target.os.tag.isDarwin()) and
|
|
||||||
err == .PERM)
|
|
||||||
{
|
|
||||||
log.debug("killpg failed with EPERM, expected on Darwin and ignoring", .{});
|
|
||||||
break :killpg;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.warn("error killing process group pgid={} err={}", .{ pgid, err });
|
fn killPid(pid: c.pid_t) !void {
|
||||||
return error.KillFailed;
|
const pgid = getpgid(pid) orelse return;
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// See Command.zig wait for why we specify WNOHANG.
|
// It is possible to send a killpg between the time that
|
||||||
// The gist is that it lets us detect when children
|
// our child process calls setsid but before or simultaneous
|
||||||
// are still alive without blocking so that we can
|
// to calling execve. In this case, the direct child dies
|
||||||
// kill them again.
|
// but grandchildren survive. To work around this, we loop
|
||||||
const res = posix.waitpid(pid, std.c.W.NOHANG);
|
// and repeatedly kill the process group until all
|
||||||
log.debug("waitpid result={}", .{res.pid});
|
// descendents are well and truly dead. We will not rest
|
||||||
if (res.pid != 0) break;
|
// until the entire family tree is obliterated.
|
||||||
std.Thread.sleep(10 * std.time.ns_per_ms);
|
while (true) {
|
||||||
|
switch (posix.errno(c.killpg(pgid, c.SIGHUP))) {
|
||||||
|
.SUCCESS => log.debug("process group killed pgid={}", .{pgid}),
|
||||||
|
else => |err| killpg: {
|
||||||
|
if ((comptime builtin.target.os.tag.isDarwin()) and
|
||||||
|
err == .PERM)
|
||||||
|
{
|
||||||
|
log.debug("killpg failed with EPERM, expected on Darwin and ignoring", .{});
|
||||||
|
break :killpg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.warn("error killing process group pgid={} err={}", .{ pgid, err });
|
||||||
|
return error.KillFailed;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// See Command.zig wait for why we specify WNOHANG.
|
||||||
|
// The gist is that it lets us detect when children
|
||||||
|
// are still alive without blocking so that we can
|
||||||
|
// kill them again.
|
||||||
|
const res = posix.waitpid(pid, std.c.W.NOHANG);
|
||||||
|
log.debug("waitpid result={}", .{res.pid});
|
||||||
|
if (res.pid != 0) break;
|
||||||
|
std.Thread.sleep(10 * std.time.ns_per_ms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1404,18 +1405,34 @@ fn execCommand(
|
||||||
command: configpkg.Command,
|
command: configpkg.Command,
|
||||||
comptime passwdpkg: type,
|
comptime passwdpkg: type,
|
||||||
) (Allocator.Error || error{SystemError})![]const [:0]const u8 {
|
) (Allocator.Error || error{SystemError})![]const [:0]const u8 {
|
||||||
|
var args: std.ArrayList([:0]const u8) = .empty;
|
||||||
|
defer args.deinit(alloc);
|
||||||
|
|
||||||
|
// If we're on macOS, we use the posix_spawn trampoline no matter
|
||||||
|
// what, so prepend that.
|
||||||
|
if (comptime builtin.target.os.tag == .macos) {
|
||||||
|
var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||||
|
const exe_bin_path = std.fs.selfExePath(&exe_buf) catch |err| {
|
||||||
|
log.warn("failed to get ghostty exe path err={}", .{err});
|
||||||
|
return error.SystemError;
|
||||||
|
};
|
||||||
|
|
||||||
|
try args.append(alloc, try alloc.dupeZ(u8, exe_bin_path));
|
||||||
|
try args.append(alloc, "+_macos-disclaim");
|
||||||
|
}
|
||||||
|
|
||||||
// If we're on macOS, we have to use `login(1)` to get all of
|
// If we're on macOS, we have to use `login(1)` to get all of
|
||||||
// the proper environment variables set, a login shell, and proper
|
// the proper environment variables set, a login shell, and proper
|
||||||
// hushlogin behavior.
|
// hushlogin behavior.
|
||||||
if (comptime builtin.target.os.tag.isDarwin()) darwin: {
|
if (comptime builtin.target.os.tag == .macos) macos: {
|
||||||
const passwd = passwdpkg.get(alloc) catch |err| {
|
const passwd = passwdpkg.get(alloc) catch |err| {
|
||||||
log.warn("failed to read passwd, not using a login shell err={}", .{err});
|
log.warn("failed to read passwd, not using a login shell err={}", .{err});
|
||||||
break :darwin;
|
break :macos;
|
||||||
};
|
};
|
||||||
|
|
||||||
const username = passwd.name orelse {
|
const username = passwd.name orelse {
|
||||||
log.warn("failed to get username, not using a login shell", .{});
|
log.warn("failed to get username, not using a login shell", .{});
|
||||||
break :darwin;
|
break :macos;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hush = if (passwd.home) |home| hush: {
|
const hush = if (passwd.home) |home| hush: {
|
||||||
|
|
@ -1431,17 +1448,10 @@ fn execCommand(
|
||||||
break :hush if (dir.access(".hushlogin", .{})) true else |_| false;
|
break :hush if (dir.access(".hushlogin", .{})) true else |_| false;
|
||||||
} else false;
|
} else false;
|
||||||
|
|
||||||
// If we made it this far we're going to start building
|
// This capacity is chosen based on what we'd need to
|
||||||
// the actual command.
|
// execute a shell command (very common). We can/will
|
||||||
var args: std.ArrayList([:0]const u8) = try .initCapacity(
|
// grow if necessary for a longer command (uncommon).
|
||||||
alloc,
|
try args.ensureUnusedCapacity(alloc, 9);
|
||||||
|
|
||||||
// This capacity is chosen based on what we'd need to
|
|
||||||
// execute a shell command (very common). We can/will
|
|
||||||
// grow if necessary for a longer command (uncommon).
|
|
||||||
9,
|
|
||||||
);
|
|
||||||
defer args.deinit(alloc);
|
|
||||||
|
|
||||||
// The reason for executing login this way is unclear. This
|
// The reason for executing login this way is unclear. This
|
||||||
// comment will attempt to explain but prepare for a truly
|
// comment will attempt to explain but prepare for a truly
|
||||||
|
|
@ -1526,12 +1536,20 @@ fn execCommand(
|
||||||
}
|
}
|
||||||
|
|
||||||
return switch (command) {
|
return switch (command) {
|
||||||
// We need to clone the command since there's no guarantee the config remains valid.
|
// We need to clone the command since there's no guarantee the config
|
||||||
.direct => |_| (try command.clone(alloc)).direct,
|
// remains valid.
|
||||||
|
.direct => |_| direct: {
|
||||||
|
const v = (try command.clone(alloc)).direct;
|
||||||
|
if (comptime builtin.target.os.tag != .macos) break :direct v;
|
||||||
|
|
||||||
|
// On macOS we need to use our args list because it has the
|
||||||
|
// disclaim helper.
|
||||||
|
try args.appendSlice(alloc, v);
|
||||||
|
break :direct try args.toOwnedSlice(alloc);
|
||||||
|
},
|
||||||
|
|
||||||
.shell => |v| shell: {
|
.shell => |v| shell: {
|
||||||
var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 4);
|
try args.ensureUnusedCapacity(alloc, 4);
|
||||||
defer args.deinit(alloc);
|
|
||||||
|
|
||||||
if (comptime builtin.os.tag == .windows) {
|
if (comptime builtin.os.tag == .windows) {
|
||||||
// We run our shell wrapped in `cmd.exe` so that we don't have
|
// We run our shell wrapped in `cmd.exe` so that we don't have
|
||||||
|
|
@ -1571,8 +1589,8 @@ fn execCommand(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test "execCommand darwin: shell command" {
|
test "execCommand macos: shell command" {
|
||||||
if (comptime !builtin.os.tag.isDarwin()) return error.SkipZigTest;
|
if (comptime builtin.os.tag != .macos) return error.SkipZigTest;
|
||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
var arena = ArenaAllocator.init(testing.allocator);
|
var arena = ArenaAllocator.init(testing.allocator);
|
||||||
|
|
@ -1587,19 +1605,21 @@ test "execCommand darwin: shell command" {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
try testing.expectEqual(8, result.len);
|
try testing.expectEqual(10, result.len);
|
||||||
try testing.expectEqualStrings(result[0], "/usr/bin/login");
|
try testing.expect(result[0].len > 0); // ghostty executable path
|
||||||
try testing.expectEqualStrings(result[1], "-flp");
|
try testing.expectEqualStrings(result[1], "+_macos-disclaim");
|
||||||
try testing.expectEqualStrings(result[2], "testuser");
|
try testing.expectEqualStrings(result[2], "/usr/bin/login");
|
||||||
try testing.expectEqualStrings(result[3], "/bin/bash");
|
try testing.expectEqualStrings(result[3], "-flp");
|
||||||
try testing.expectEqualStrings(result[4], "--noprofile");
|
try testing.expectEqualStrings(result[4], "testuser");
|
||||||
try testing.expectEqualStrings(result[5], "--norc");
|
try testing.expectEqualStrings(result[5], "/bin/bash");
|
||||||
try testing.expectEqualStrings(result[6], "-c");
|
try testing.expectEqualStrings(result[6], "--noprofile");
|
||||||
try testing.expectEqualStrings(result[7], "exec -l foo bar baz");
|
try testing.expectEqualStrings(result[7], "--norc");
|
||||||
|
try testing.expectEqualStrings(result[8], "-c");
|
||||||
|
try testing.expectEqualStrings(result[9], "exec -l foo bar baz");
|
||||||
}
|
}
|
||||||
|
|
||||||
test "execCommand darwin: direct command" {
|
test "execCommand macos: direct command" {
|
||||||
if (comptime !builtin.os.tag.isDarwin()) return error.SkipZigTest;
|
if (comptime builtin.os.tag != .macos) return error.SkipZigTest;
|
||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
var arena = ArenaAllocator.init(testing.allocator);
|
var arena = ArenaAllocator.init(testing.allocator);
|
||||||
|
|
@ -1617,12 +1637,14 @@ test "execCommand darwin: direct command" {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
try testing.expectEqual(5, result.len);
|
try testing.expectEqual(7, result.len);
|
||||||
try testing.expectEqualStrings(result[0], "/usr/bin/login");
|
try testing.expect(result[0].len > 0); // ghostty executable path
|
||||||
try testing.expectEqualStrings(result[1], "-flp");
|
try testing.expectEqualStrings(result[1], "+_macos-disclaim");
|
||||||
try testing.expectEqualStrings(result[2], "testuser");
|
try testing.expectEqualStrings(result[2], "/usr/bin/login");
|
||||||
try testing.expectEqualStrings(result[3], "foo");
|
try testing.expectEqualStrings(result[3], "-flp");
|
||||||
try testing.expectEqualStrings(result[4], "bar baz");
|
try testing.expectEqualStrings(result[4], "testuser");
|
||||||
|
try testing.expectEqualStrings(result[5], "foo");
|
||||||
|
try testing.expectEqualStrings(result[6], "bar baz");
|
||||||
}
|
}
|
||||||
|
|
||||||
test "execCommand: shell command, empty passwd" {
|
test "execCommand: shell command, empty passwd" {
|
||||||
|
|
@ -1633,7 +1655,7 @@ test "execCommand: shell command, empty passwd" {
|
||||||
defer arena.deinit();
|
defer arena.deinit();
|
||||||
const alloc = arena.allocator();
|
const alloc = arena.allocator();
|
||||||
|
|
||||||
const result = try execCommand(
|
const command = try execCommand(
|
||||||
alloc,
|
alloc,
|
||||||
.{ .shell = "foo bar baz" },
|
.{ .shell = "foo bar baz" },
|
||||||
struct {
|
struct {
|
||||||
|
|
@ -1644,6 +1666,10 @@ test "execCommand: shell command, empty passwd" {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const result = if (comptime builtin.os.tag == .macos)
|
||||||
|
command[2..]
|
||||||
|
else
|
||||||
|
command;
|
||||||
|
|
||||||
try testing.expectEqual(3, result.len);
|
try testing.expectEqual(3, result.len);
|
||||||
try testing.expectEqualStrings(result[0], "/bin/sh");
|
try testing.expectEqualStrings(result[0], "/bin/sh");
|
||||||
|
|
@ -1659,7 +1685,7 @@ test "execCommand: shell command, error passwd" {
|
||||||
defer arena.deinit();
|
defer arena.deinit();
|
||||||
const alloc = arena.allocator();
|
const alloc = arena.allocator();
|
||||||
|
|
||||||
const result = try execCommand(
|
const command = try execCommand(
|
||||||
alloc,
|
alloc,
|
||||||
.{ .shell = "foo bar baz" },
|
.{ .shell = "foo bar baz" },
|
||||||
struct {
|
struct {
|
||||||
|
|
@ -1670,6 +1696,10 @@ test "execCommand: shell command, error passwd" {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const result = if (comptime builtin.os.tag == .macos)
|
||||||
|
command[2..]
|
||||||
|
else
|
||||||
|
command;
|
||||||
|
|
||||||
try testing.expectEqual(3, result.len);
|
try testing.expectEqual(3, result.len);
|
||||||
try testing.expectEqualStrings(result[0], "/bin/sh");
|
try testing.expectEqualStrings(result[0], "/bin/sh");
|
||||||
|
|
@ -1685,7 +1715,7 @@ test "execCommand: direct command, error passwd" {
|
||||||
defer arena.deinit();
|
defer arena.deinit();
|
||||||
const alloc = arena.allocator();
|
const alloc = arena.allocator();
|
||||||
|
|
||||||
const result = try execCommand(alloc, .{
|
const command = try execCommand(alloc, .{
|
||||||
.direct = &.{
|
.direct = &.{
|
||||||
"foo",
|
"foo",
|
||||||
"bar baz",
|
"bar baz",
|
||||||
|
|
@ -1697,6 +1727,10 @@ test "execCommand: direct command, error passwd" {
|
||||||
return error.Fail;
|
return error.Fail;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const result = if (comptime builtin.os.tag == .macos)
|
||||||
|
command[2..]
|
||||||
|
else
|
||||||
|
command;
|
||||||
|
|
||||||
try testing.expectEqual(2, result.len);
|
try testing.expectEqual(2, result.len);
|
||||||
try testing.expectEqualStrings(result[0], "foo");
|
try testing.expectEqualStrings(result[0], "foo");
|
||||||
|
|
@ -1720,13 +1754,17 @@ test "execCommand: direct command, config freed" {
|
||||||
},
|
},
|
||||||
}).clone(command_alloc);
|
}).clone(command_alloc);
|
||||||
|
|
||||||
const result = try execCommand(alloc, command, struct {
|
const raw = try execCommand(alloc, command, struct {
|
||||||
fn get(_: Allocator) !PasswdEntry {
|
fn get(_: Allocator) !PasswdEntry {
|
||||||
// Failed passwd entry means we can't construct a macOS
|
// Failed passwd entry means we can't construct a macOS
|
||||||
// login command and falls back to POSIX behavior.
|
// login command and falls back to POSIX behavior.
|
||||||
return error.Fail;
|
return error.Fail;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const result = if (comptime builtin.os.tag == .macos)
|
||||||
|
raw[2..]
|
||||||
|
else
|
||||||
|
raw;
|
||||||
|
|
||||||
command_arena.deinit();
|
command_arena.deinit();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue