fix: improve Windows managed OpenCode shutdown and launch behavior (#844)
* fix: windows shutdown and restart orphaned cleanup * fix: launch managed OpenCode directly on Windows Unwrap OpenCode wrappers to launch directly on Windows, improving shutdown reliability and avoid orphans. * fix: restore desktopNotifyEnabled in health snapshot --------- Signed-off-by: Dr. Zed <142888684+DocterZed@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
a3d73a8b67
commit
636dcd5314
@@ -112,6 +112,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `refreshOpenCodeAfterConfigChange(reason, options?)`
|
||||
- `bootstrapOpenCodeAtStartup()`
|
||||
- `startHealthMonitoring(healthCheckIntervalMs)`
|
||||
- `waitForPortRelease(port, timeoutMs, hostname?)`
|
||||
- `killProcessOnPort(port)`
|
||||
|
||||
## Public exports (env-runtime.js)
|
||||
@@ -122,10 +123,10 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `ensureOpencodeCliEnv()`
|
||||
- `applyOpencodeBinaryFromSettings()`
|
||||
- `resolveOpencodeCliPath()`
|
||||
- `resolveManagedOpenCodeLaunchSpec(opencodePath)`: resolves the effective managed OpenCode launch target, unwrapping Windows package-manager shims to a direct native binary or explicit runtime+script when possible.
|
||||
- `resolveGitBinaryForSpawn()`
|
||||
- `resolveWslExecutablePath()`
|
||||
- `buildWslExecArgs(execArgs, distroOverride?)`
|
||||
- `opencodeShimInterpreter(opencodePath)`
|
||||
- `isExecutable(filePath)`
|
||||
- `searchPathFor(binaryName)`
|
||||
- `clearResolvedOpenCodeBinary()`
|
||||
@@ -288,7 +289,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
## Public exports (opencode-resolution-runtime.js)
|
||||
- `createOpenCodeResolutionRuntime(dependencies)`: creates runtime for OpenCode binary/source snapshot resolution.
|
||||
- Returned API:
|
||||
- `getOpenCodeResolutionSnapshot(settings)`
|
||||
- `getOpenCodeResolutionSnapshot(settings)`: returns configured/resolved OpenCode binary details plus effective managed-launch fields (`launchBinary`, `launchArgs`, `launchWrapperType`) when applicable.
|
||||
|
||||
## Public exports (tunnel-wiring-runtime.js)
|
||||
- `createTunnelWiringRuntime(dependencies)`: creates runtime for tunnel service construction and tunnel route registration.
|
||||
|
||||
@@ -631,6 +631,194 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const WINDOWS_BATCH_EXTENSIONS = new Set(['.cmd', '.bat', '.com']);
|
||||
|
||||
const normalizeExecutableCandidate = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return isExecutable(trimmed) ? trimmed : null;
|
||||
};
|
||||
|
||||
const getWindowsNativeOpencodePackageNames = () => {
|
||||
if (process.arch === 'arm64') {
|
||||
return ['opencode-windows-arm64'];
|
||||
}
|
||||
if (process.arch === 'x64') {
|
||||
// Prefer the baseline build when bypassing package-manager wrappers so the
|
||||
// direct binary still runs on hosts without AVX2 support.
|
||||
return ['opencode-windows-x64-baseline', 'opencode-windows-x64'];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const resolveNativeOpencodeBinaryFromNodeModules = (nodeModulesDir) => {
|
||||
if (typeof nodeModulesDir !== 'string' || nodeModulesDir.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const packageName of getWindowsNativeOpencodePackageNames()) {
|
||||
const candidate = path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe');
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveOpencodeNodeLaunchSpecFromNodeModules = (nodeModulesDir) => {
|
||||
if (typeof nodeModulesDir !== 'string' || nodeModulesDir.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const launcher = path.join(nodeModulesDir, 'opencode-ai', 'bin', 'opencode');
|
||||
if (!isExecutable(launcher) && !fs.existsSync(launcher)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodeBinary = ensureNodeCliEnv() || resolveNodeCliPath() || 'node';
|
||||
return {
|
||||
binary: nodeBinary,
|
||||
args: [launcher],
|
||||
wrapperType: 'node-launcher',
|
||||
};
|
||||
};
|
||||
|
||||
const resolveNodeModulesDirFromCmdWrapper = (wrapperPath) => {
|
||||
if (!wrapperPath || typeof wrapperPath !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(wrapperPath, 'utf8');
|
||||
const launcherMatch = content.match(/node_modules[\\/]+opencode-ai[\\/]+bin[\\/]+opencode/i);
|
||||
if (!launcherMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const launcherPath = path.resolve(path.dirname(wrapperPath), launcherMatch[0]);
|
||||
return path.dirname(path.dirname(path.dirname(launcherPath)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveOpencodeNodeModulesDir = (opencodePath) => {
|
||||
if (typeof opencodePath !== 'string' || opencodePath.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = path.resolve(opencodePath);
|
||||
const lower = normalized.toLowerCase();
|
||||
const fileDir = path.dirname(normalized);
|
||||
const nodeModulesCandidates = [];
|
||||
const pushCandidate = (candidate) => {
|
||||
if (typeof candidate !== 'string' || candidate.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!nodeModulesCandidates.includes(candidate)) {
|
||||
nodeModulesCandidates.push(candidate);
|
||||
}
|
||||
};
|
||||
|
||||
if (lower.includes(`${path.sep}.bun${path.sep}bin${path.sep}opencode`)) {
|
||||
const bunRoot = path.dirname(path.dirname(normalized));
|
||||
pushCandidate(path.join(bunRoot, 'install', 'global', 'node_modules'));
|
||||
}
|
||||
|
||||
if (lower.endsWith(`${path.sep}node_modules${path.sep}.bin${path.sep}opencode`)
|
||||
|| lower.endsWith(`${path.sep}node_modules${path.sep}.bin${path.sep}opencode.cmd`)
|
||||
|| lower.endsWith(`${path.sep}node_modules${path.sep}.bin${path.sep}opencode.bat`)
|
||||
|| lower.endsWith(`${path.sep}node_modules${path.sep}.bin${path.sep}opencode.exe`)) {
|
||||
pushCandidate(path.dirname(fileDir));
|
||||
}
|
||||
|
||||
if (lower.endsWith(`${path.sep}node_modules${path.sep}opencode-ai${path.sep}bin${path.sep}opencode`)) {
|
||||
pushCandidate(path.dirname(path.dirname(fileDir)));
|
||||
}
|
||||
|
||||
if (path.basename(fileDir).toLowerCase() === 'npm') {
|
||||
pushCandidate(path.join(fileDir, 'node_modules'));
|
||||
}
|
||||
|
||||
if (WINDOWS_BATCH_EXTENSIONS.has(path.extname(normalized).toLowerCase())) {
|
||||
pushCandidate(resolveNodeModulesDirFromCmdWrapper(normalized));
|
||||
}
|
||||
|
||||
for (const candidate of nodeModulesCandidates) {
|
||||
if (resolveNativeOpencodeBinaryFromNodeModules(candidate) || resolveOpencodeNodeLaunchSpecFromNodeModules(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveManagedOpenCodeLaunchSpec = (opencodePath) => {
|
||||
const fallbackBinary = typeof opencodePath === 'string' && opencodePath.trim().length > 0
|
||||
? opencodePath.trim()
|
||||
: 'opencode';
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
return { binary: fallbackBinary, args: [], wrapperType: null };
|
||||
}
|
||||
|
||||
const ext = path.extname(fallbackBinary).toLowerCase();
|
||||
const candidatePaths = [fallbackBinary];
|
||||
if (WINDOWS_BATCH_EXTENSIONS.has(ext)) {
|
||||
candidatePaths.push(fallbackBinary.slice(0, -ext.length) + '.exe');
|
||||
}
|
||||
|
||||
for (const candidate of candidatePaths) {
|
||||
const nodeModulesDir = resolveOpencodeNodeModulesDir(candidate);
|
||||
const nativeBinary = resolveNativeOpencodeBinaryFromNodeModules(nodeModulesDir);
|
||||
if (nativeBinary) {
|
||||
return {
|
||||
binary: nativeBinary,
|
||||
args: [],
|
||||
wrapperType: nativeBinary === fallbackBinary ? null : 'native-wrapper',
|
||||
};
|
||||
}
|
||||
|
||||
const nodeLaunchSpec = resolveOpencodeNodeLaunchSpecFromNodeModules(nodeModulesDir);
|
||||
if (nodeLaunchSpec) {
|
||||
return nodeLaunchSpec;
|
||||
}
|
||||
|
||||
const interpreter = opencodeShimInterpreter(candidate);
|
||||
if (interpreter === 'node') {
|
||||
return {
|
||||
binary: ensureNodeCliEnv() || resolveNodeCliPath() || 'node',
|
||||
args: [candidate],
|
||||
wrapperType: 'node-shebang',
|
||||
};
|
||||
}
|
||||
if (interpreter === 'bun') {
|
||||
return {
|
||||
binary: ensureBunCliEnv() || resolveBunCliPath() || 'bun',
|
||||
args: [candidate],
|
||||
wrapperType: 'bun-shebang',
|
||||
};
|
||||
}
|
||||
|
||||
const directBinary = normalizeExecutableCandidate(candidate);
|
||||
if (directBinary) {
|
||||
return {
|
||||
binary: directBinary,
|
||||
args: [],
|
||||
wrapperType: directBinary === fallbackBinary ? null : 'executable-wrapper',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { binary: fallbackBinary, args: [], wrapperType: null };
|
||||
};
|
||||
|
||||
const readShebang = (opencodePath) => {
|
||||
if (!opencodePath || typeof opencodePath !== 'string') {
|
||||
return null;
|
||||
@@ -897,12 +1085,12 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
||||
applyOpencodeBinaryFromSettings,
|
||||
getLoginShellEnvSnapshot,
|
||||
resolveOpencodeCliPath,
|
||||
resolveManagedOpenCodeLaunchSpec,
|
||||
isExecutable,
|
||||
searchPathFor,
|
||||
resolveGitBinaryForSpawn,
|
||||
resolveWslExecutablePath,
|
||||
buildWslExecArgs,
|
||||
opencodeShimInterpreter,
|
||||
clearResolvedOpenCodeBinary,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
|
||||
export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
const {
|
||||
@@ -18,7 +16,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
ensureLocalOpenCodeServerPassword,
|
||||
buildWslExecArgs,
|
||||
resolveWslExecutablePath,
|
||||
opencodeShimInterpreter,
|
||||
resolveManagedOpenCodeLaunchSpec,
|
||||
setOpenCodePort,
|
||||
setDetectedOpenCodeApiPrefix,
|
||||
setupProxy,
|
||||
@@ -27,7 +25,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
} = deps;
|
||||
|
||||
const killProcessOnPort = (port) => {
|
||||
if (!port) return;
|
||||
if (!port || process.platform === 'win32') return;
|
||||
try {
|
||||
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000, windowsHide: true });
|
||||
const output = result.stdout || '';
|
||||
@@ -45,6 +43,130 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasChildProcessExited = (child) => !child || child.exitCode !== null || child.signalCode !== null;
|
||||
|
||||
const waitForChildProcessClose = (child, timeoutMs) => new Promise((resolve) => {
|
||||
if (!child || hasChildProcessExited(child)) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let done = false;
|
||||
const finish = (closed) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
child.off('close', onClose);
|
||||
child.off('error', onError);
|
||||
resolve(closed);
|
||||
};
|
||||
|
||||
const onClose = () => finish(true);
|
||||
const onError = () => finish(hasChildProcessExited(child));
|
||||
const timer = setTimeout(() => finish(hasChildProcessExited(child)), timeoutMs);
|
||||
|
||||
child.once('close', onClose);
|
||||
child.once('error', onError);
|
||||
});
|
||||
|
||||
const waitForPortRelease = (port, timeoutMs, hostname = env.ENV_CONFIGURED_OPENCODE_HOSTNAME) => {
|
||||
if (!port) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const probeHost = !hostname || hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]'
|
||||
? '127.0.0.1'
|
||||
: hostname;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const attempt = () => {
|
||||
const socket = net.connect({ port, host: probeHost });
|
||||
let settled = false;
|
||||
|
||||
const finish = (released) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
if (released || Date.now() >= deadline) {
|
||||
resolve(released);
|
||||
return;
|
||||
}
|
||||
setTimeout(attempt, 150);
|
||||
};
|
||||
|
||||
socket.once('connect', () => finish(false));
|
||||
socket.once('timeout', () => finish(true));
|
||||
socket.once('error', (error) => {
|
||||
if (error && typeof error === 'object' && (error.code === 'ECONNREFUSED' || error.code === 'EHOSTUNREACH')) {
|
||||
finish(true);
|
||||
return;
|
||||
}
|
||||
finish(false);
|
||||
});
|
||||
socket.setTimeout(500);
|
||||
};
|
||||
|
||||
attempt();
|
||||
});
|
||||
};
|
||||
|
||||
const closeManagedOpenCodeChild = async (child) => {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = child.pid;
|
||||
if (!pid || hasChildProcessExited(child)) {
|
||||
await waitForChildProcessClose(child, 250);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
spawnSync('taskkill', ['/pid', String(pid), '/t'], {
|
||||
stdio: 'ignore',
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
|
||||
if (await waitForChildProcessClose(child, 1500)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
spawnSync('taskkill', ['/pid', String(pid), '/f', '/t'], {
|
||||
stdio: 'ignore',
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
|
||||
await waitForChildProcessClose(child, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
}
|
||||
|
||||
if (await waitForChildProcessClose(child, 2500)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
}
|
||||
|
||||
await waitForChildProcessClose(child, 1000);
|
||||
};
|
||||
|
||||
const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv }) => {
|
||||
let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
|
||||
let args = ['serve', '--hostname', hostname, '--port', String(port)];
|
||||
@@ -72,26 +194,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && !state.useWslForOpencode) {
|
||||
const interpreter = opencodeShimInterpreter(binary);
|
||||
if (interpreter) {
|
||||
args.unshift(binary);
|
||||
binary = interpreter;
|
||||
} else {
|
||||
try {
|
||||
const shimContent = fs.readFileSync(binary, 'utf8');
|
||||
const jsMatch = shimContent.match(/node_modules[\\/]opencode[^\s"']*/);
|
||||
if (jsMatch) {
|
||||
const candidate = path.resolve(path.dirname(binary), jsMatch[0]);
|
||||
if (fs.existsSync(candidate)) {
|
||||
const realInterp = opencodeShimInterpreter(candidate);
|
||||
if (realInterp) {
|
||||
args.unshift(candidate);
|
||||
binary = realInterp;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const launchSpec = resolveManagedOpenCodeLaunchSpec(binary);
|
||||
if (launchSpec?.binary) {
|
||||
if (launchSpec.wrapperType) {
|
||||
console.log(`Launching OpenCode via ${launchSpec.wrapperType}: ${launchSpec.binary}`);
|
||||
}
|
||||
binary = launchSpec.binary;
|
||||
args = [...(Array.isArray(launchSpec.args) ? launchSpec.args : []), ...args];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,11 +264,8 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
|
||||
return {
|
||||
url,
|
||||
close() {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
}
|
||||
async close() {
|
||||
await closeManagedOpenCodeChild(child);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -302,7 +408,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
}
|
||||
|
||||
try {
|
||||
serverInstance.close();
|
||||
await serverInstance.close();
|
||||
} catch {
|
||||
}
|
||||
throw new Error('Server started but health check failed (timeout)');
|
||||
@@ -359,7 +465,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
if (state.openCodeProcess) {
|
||||
console.log('Stopping existing OpenCode process...');
|
||||
try {
|
||||
state.openCodeProcess.close();
|
||||
await state.openCodeProcess.close();
|
||||
} catch (error) {
|
||||
console.warn('Error closing OpenCode process:', error);
|
||||
}
|
||||
@@ -368,7 +474,9 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
}
|
||||
|
||||
killProcessOnPort(portToKill);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
if (!(await waitForPortRelease(portToKill, 5000))) {
|
||||
console.warn(`Timed out waiting for OpenCode port ${portToKill} to be released`);
|
||||
}
|
||||
|
||||
if (env.ENV_CONFIGURED_OPENCODE_PORT) {
|
||||
console.log(`Using OpenCode port from environment: ${env.ENV_CONFIGURED_OPENCODE_PORT}`);
|
||||
@@ -626,5 +734,6 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
bootstrapOpenCodeAtStartup,
|
||||
startHealthMonitoring,
|
||||
waitForPortRelease,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ export const createOpenCodeResolutionRuntime = (dependencies) => {
|
||||
resolveOpencodeCliPath,
|
||||
applyOpencodeBinaryFromSettings,
|
||||
ensureOpencodeCliEnv,
|
||||
opencodeShimInterpreter,
|
||||
resolveManagedOpenCodeLaunchSpec,
|
||||
getResolvedState,
|
||||
setResolvedOpencodeBinarySource,
|
||||
} = dependencies;
|
||||
@@ -42,7 +42,9 @@ export const createOpenCodeResolutionRuntime = (dependencies) => {
|
||||
source !== 'env'
|
||||
? source
|
||||
: rawDetectedSourceNow;
|
||||
const shim = resolved ? opencodeShimInterpreter(resolved) : null;
|
||||
const launchSpec = resolved && !useWslForOpencode
|
||||
? resolveManagedOpenCodeLaunchSpec(resolved)
|
||||
: null;
|
||||
|
||||
return {
|
||||
configured,
|
||||
@@ -51,7 +53,9 @@ export const createOpenCodeResolutionRuntime = (dependencies) => {
|
||||
source,
|
||||
detectedNow,
|
||||
detectedSourceNow,
|
||||
shim,
|
||||
launchBinary: launchSpec?.binary || null,
|
||||
launchArgs: launchSpec?.args || [],
|
||||
launchWrapperType: launchSpec?.wrapperType || null,
|
||||
viaWsl: useWslForOpencode,
|
||||
wslBinary: resolvedWslBinary || null,
|
||||
wslPath: resolvedWslOpencodePath || null,
|
||||
|
||||
@@ -17,6 +17,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
||||
getOpenCodeProcess,
|
||||
setOpenCodeProcess,
|
||||
killProcessOnPort,
|
||||
waitForPortRelease,
|
||||
getServer,
|
||||
getUiAuthController,
|
||||
setUiAuthController,
|
||||
@@ -58,7 +59,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
||||
if (openCodeProcess) {
|
||||
console.log('Stopping OpenCode process...');
|
||||
try {
|
||||
openCodeProcess.close();
|
||||
await openCodeProcess.close();
|
||||
} catch (error) {
|
||||
console.warn('Error closing OpenCode process:', error);
|
||||
}
|
||||
@@ -66,6 +67,9 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
||||
}
|
||||
|
||||
killProcessOnPort(portToKill);
|
||||
if (!(await waitForPortRelease(portToKill, 5000))) {
|
||||
console.warn(`Timed out waiting for OpenCode port ${portToKill} to be released during shutdown`);
|
||||
}
|
||||
} else {
|
||||
console.log('Skipping OpenCode shutdown (external server)');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user