feat(core): share continuous tasks (#29901)
<!-- Please make sure you have read the submission guidelines before posting an PR --> <!-- https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr --> <!-- Please make sure that your commit message follows our format --> <!-- Example: `fix(nx): must begin with lowercase` --> <!-- If this is a particularly complex change or feature addition, you can request a dedicated Nx release for this pull request branch. Mention someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they will confirm if the PR warrants its own release for testing purposes, and generate it for you if appropriate. --> <!-- This is the behavior we have today --> <!-- This is the behavior we should expect with the changes in this PR --> <!-- Please link the issue being fixed so it gets closed when this is merged. --> Fixes # --------- Co-authored-by: Leosvel Pérez Espinosa <leosvel.perez.espinosa@gmail.com>
This commit is contained in:
parent
dee4906f5e
commit
c5fb467118
@ -81,7 +81,6 @@ describe('env vars', () => {
|
|||||||
`e2e ${myapp}-e2e --config \\'{\\"env\\":{\\"cliArg\\":\\"i am from the cli args\\"}}\\'`
|
`e2e ${myapp}-e2e --config \\'{\\"env\\":{\\"cliArg\\":\\"i am from the cli args\\"}}\\'`
|
||||||
);
|
);
|
||||||
expect(run1).toContain('All specs passed!');
|
expect(run1).toContain('All specs passed!');
|
||||||
await killPort(4200);
|
|
||||||
// tests should not fail because of a config change
|
// tests should not fail because of a config change
|
||||||
updateFile(
|
updateFile(
|
||||||
`apps/${myapp}-e2e/cypress.config.ts`,
|
`apps/${myapp}-e2e/cypress.config.ts`,
|
||||||
@ -114,7 +113,6 @@ export default defineConfig({
|
|||||||
`e2e ${myapp}-e2e --config \\'{\\"env\\":{\\"cliArg\\":\\"i am from the cli args\\"}}\\'`
|
`e2e ${myapp}-e2e --config \\'{\\"env\\":{\\"cliArg\\":\\"i am from the cli args\\"}}\\'`
|
||||||
);
|
);
|
||||||
expect(run2).toContain('All specs passed!');
|
expect(run2).toContain('All specs passed!');
|
||||||
await killPort(4200);
|
|
||||||
|
|
||||||
// make sure project.json env vars also work
|
// make sure project.json env vars also work
|
||||||
checkFilesExist(`apps/${myapp}-e2e/src/e2e/env.cy.ts`);
|
checkFilesExist(`apps/${myapp}-e2e/src/e2e/env.cy.ts`);
|
||||||
@ -143,8 +141,6 @@ export default defineConfig({
|
|||||||
);
|
);
|
||||||
const run3 = runCLI(`e2e ${myapp}-e2e`);
|
const run3 = runCLI(`e2e ${myapp}-e2e`);
|
||||||
expect(run3).toContain('All specs passed!');
|
expect(run3).toContain('All specs passed!');
|
||||||
|
|
||||||
expect(await killPort(4200)).toBeTruthy();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
TEN_MINS_MS
|
TEN_MINS_MS
|
||||||
|
|||||||
@ -42,6 +42,7 @@
|
|||||||
"@phenomnomnominal/tsquery": "~5.0.1",
|
"@phenomnomnominal/tsquery": "~5.0.1",
|
||||||
"detect-port": "^1.5.1",
|
"detect-port": "^1.5.1",
|
||||||
"semver": "^7.6.3",
|
"semver": "^7.6.3",
|
||||||
|
"tree-kill": "1.2.2",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { dirname, join, relative } from 'path';
|
|||||||
import type { InlineConfig } from 'vite';
|
import type { InlineConfig } from 'vite';
|
||||||
import vitePreprocessor from '../src/plugins/preprocessor-vite';
|
import vitePreprocessor from '../src/plugins/preprocessor-vite';
|
||||||
import { NX_PLUGIN_OPTIONS } from '../src/utils/constants';
|
import { NX_PLUGIN_OPTIONS } from '../src/utils/constants';
|
||||||
|
import * as treeKill from 'tree-kill';
|
||||||
|
|
||||||
// Importing the cypress type here causes the angular and next unit
|
// Importing the cypress type here causes the angular and next unit
|
||||||
// tests to fail when transpiling, it seems like the cypress types are
|
// tests to fail when transpiling, it seems like the cypress types are
|
||||||
@ -79,7 +80,7 @@ function startWebServer(webServerCommand: string) {
|
|||||||
windowsHide: false,
|
windowsHide: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return async () => {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
try {
|
try {
|
||||||
execSync('taskkill /pid ' + serverProcess.pid + ' /T /F', {
|
execSync('taskkill /pid ' + serverProcess.pid + ' /T /F', {
|
||||||
@ -91,9 +92,14 @@ function startWebServer(webServerCommand: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// child.kill() does not work on linux
|
return new Promise<void>((res, rej) => {
|
||||||
// process.kill will kill the whole process group on unix
|
treeKill(serverProcess.pid, (err) => {
|
||||||
process.kill(-serverProcess.pid, 'SIGKILL');
|
if (err) {
|
||||||
|
rej(err);
|
||||||
|
}
|
||||||
|
res();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -172,7 +178,7 @@ export function nxE2EPreset(
|
|||||||
const killWebServer = startWebServer(webServerCommand);
|
const killWebServer = startWebServer(webServerCommand);
|
||||||
|
|
||||||
on('after:run', () => {
|
on('after:run', () => {
|
||||||
killWebServer();
|
return killWebServer();
|
||||||
});
|
});
|
||||||
await waitForServer(config.baseUrl, options.webServerConfig);
|
await waitForServer(config.baseUrl, options.webServerConfig);
|
||||||
}
|
}
|
||||||
|
|||||||
7
packages/nx/src/native/index.d.ts
vendored
7
packages/nx/src/native/index.d.ts
vendored
@ -62,6 +62,13 @@ export declare class NxTaskHistory {
|
|||||||
getEstimatedTaskTimings(targets: Array<TaskTarget>): Record<string, number>
|
getEstimatedTaskTimings(targets: Array<TaskTarget>): Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export declare class RunningTasksService {
|
||||||
|
constructor(db: ExternalObject<NxDbConnection>)
|
||||||
|
getRunningTasks(ids: Array<string>): Array<string>
|
||||||
|
addRunningTask(taskId: string): void
|
||||||
|
removeRunningTask(taskId: string): void
|
||||||
|
}
|
||||||
|
|
||||||
export declare class RustPseudoTerminal {
|
export declare class RustPseudoTerminal {
|
||||||
constructor()
|
constructor()
|
||||||
runCommand(command: string, commandDir?: string | undefined | null, jsEnv?: Record<string, string> | undefined | null, execArgv?: Array<string> | undefined | null, quiet?: boolean | undefined | null, tty?: boolean | undefined | null): ChildProcess
|
runCommand(command: string, commandDir?: string | undefined | null, jsEnv?: Record<string, string> | undefined | null, execArgv?: Array<string> | undefined | null, quiet?: boolean | undefined | null, tty?: boolean | undefined | null): ChildProcess
|
||||||
|
|||||||
@ -368,6 +368,7 @@ module.exports.HttpRemoteCache = nativeBinding.HttpRemoteCache
|
|||||||
module.exports.ImportResult = nativeBinding.ImportResult
|
module.exports.ImportResult = nativeBinding.ImportResult
|
||||||
module.exports.NxCache = nativeBinding.NxCache
|
module.exports.NxCache = nativeBinding.NxCache
|
||||||
module.exports.NxTaskHistory = nativeBinding.NxTaskHistory
|
module.exports.NxTaskHistory = nativeBinding.NxTaskHistory
|
||||||
|
module.exports.RunningTasksService = nativeBinding.RunningTasksService
|
||||||
module.exports.RustPseudoTerminal = nativeBinding.RustPseudoTerminal
|
module.exports.RustPseudoTerminal = nativeBinding.RustPseudoTerminal
|
||||||
module.exports.TaskDetails = nativeBinding.TaskDetails
|
module.exports.TaskDetails = nativeBinding.TaskDetails
|
||||||
module.exports.TaskHasher = nativeBinding.TaskHasher
|
module.exports.TaskHasher = nativeBinding.TaskHasher
|
||||||
|
|||||||
@ -10,3 +10,5 @@ mod utils;
|
|||||||
pub mod details;
|
pub mod details;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod task_history;
|
pub mod task_history;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod running_tasks_service;
|
||||||
|
|||||||
167
packages/nx/src/native/tasks/running_tasks_service.rs
Normal file
167
packages/nx/src/native/tasks/running_tasks_service.rs
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
use crate::native::db::connection::NxDbConnection;
|
||||||
|
use crate::native::utils::Normalize;
|
||||||
|
use hashbrown::HashSet;
|
||||||
|
use napi::bindgen_prelude::External;
|
||||||
|
use std::env::args_os;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
struct RunningTasksService {
|
||||||
|
db: External<NxDbConnection>,
|
||||||
|
added_tasks: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
impl RunningTasksService {
|
||||||
|
#[napi(constructor)]
|
||||||
|
pub fn new(db: External<NxDbConnection>) -> anyhow::Result<Self> {
|
||||||
|
let s = Self {
|
||||||
|
db,
|
||||||
|
added_tasks: Default::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
s.setup()?;
|
||||||
|
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub fn get_running_tasks(&mut self, ids: Vec<String>) -> anyhow::Result<Vec<String>> {
|
||||||
|
let mut results = Vec::<String>::with_capacity(ids.len());
|
||||||
|
for id in ids.into_iter() {
|
||||||
|
if self.is_task_running(&id)? {
|
||||||
|
results.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_task_running(&self, task_id: &String) -> anyhow::Result<bool> {
|
||||||
|
let mut stmt = self
|
||||||
|
.db
|
||||||
|
.prepare("SELECT pid, command, cwd FROM running_tasks WHERE task_id = ?")?;
|
||||||
|
if let Ok((pid, db_process_command, db_process_cwd)) = stmt.query_row([task_id], |row| {
|
||||||
|
let pid: u32 = row.get(0)?;
|
||||||
|
let command: String = row.get(1)?;
|
||||||
|
let cwd: String = row.get(2)?;
|
||||||
|
|
||||||
|
Ok((pid, command, cwd))
|
||||||
|
}) {
|
||||||
|
debug!("Checking if {} exists", pid);
|
||||||
|
|
||||||
|
let mut sys = System::new();
|
||||||
|
sys.refresh_processes_specifics(
|
||||||
|
ProcessesToUpdate::Some(&[Pid::from(pid as usize)]),
|
||||||
|
true,
|
||||||
|
ProcessRefreshKind::everything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
match sys.process(sysinfo::Pid::from(pid as usize)) {
|
||||||
|
Some(process_info) => {
|
||||||
|
let cmd = process_info.cmd().to_vec();
|
||||||
|
let cmd_str = cmd
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
if let Some(cwd_path) = process_info.cwd() {
|
||||||
|
let cwd_str = cwd_path.to_normalized_string();
|
||||||
|
Ok(cmd_str == db_process_command && cwd_str == db_process_cwd)
|
||||||
|
} else {
|
||||||
|
Ok(cmd_str == db_process_command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => Ok(false),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub fn add_running_task(&mut self, task_id: String) -> anyhow::Result<()> {
|
||||||
|
let pid = std::process::id();
|
||||||
|
let command = args_os().collect::<Vec<OsString>>();
|
||||||
|
// Convert command vector to a string representation
|
||||||
|
let command_str = command
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
let cwd = std::env::current_dir()
|
||||||
|
.expect("The current working directory does not exist")
|
||||||
|
.to_normalized_string();
|
||||||
|
let mut stmt = self.db.prepare(
|
||||||
|
"INSERT OR REPLACE INTO running_tasks (task_id, pid, command, cwd) VALUES (?, ?, ?, ?)",
|
||||||
|
)?;
|
||||||
|
stmt.execute([&task_id, &pid.to_string(), &command_str, &cwd])?;
|
||||||
|
self.added_tasks.insert(task_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub fn remove_running_task(&self, task_id: String) -> anyhow::Result<()> {
|
||||||
|
let mut stmt = self
|
||||||
|
.db
|
||||||
|
.prepare("DELETE FROM running_tasks WHERE task_id = ?")?;
|
||||||
|
stmt.execute([task_id])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup(&self) -> anyhow::Result<()> {
|
||||||
|
self.db.execute_batch(
|
||||||
|
"
|
||||||
|
CREATE TABLE IF NOT EXISTS running_tasks (
|
||||||
|
task_id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
pid INTEGER NOT NULL,
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
cwd TEXT NOT NULL
|
||||||
|
);
|
||||||
|
",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RunningTasksService {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Remove tasks added by this service. This might happen if process exits because of SIGKILL
|
||||||
|
for task_id in self.added_tasks.iter() {
|
||||||
|
self.remove_running_task(task_id.clone()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::env::args_os;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_task() {
|
||||||
|
let pid = std::process::id();
|
||||||
|
|
||||||
|
let mut sys = System::new();
|
||||||
|
sys.refresh_processes_specifics(
|
||||||
|
ProcessesToUpdate::Some(&[Pid::from(pid as usize)]),
|
||||||
|
true,
|
||||||
|
ProcessRefreshKind::everything(),
|
||||||
|
);
|
||||||
|
if let Some(process_info) = sys.process(sysinfo::Pid::from(pid as usize)) {
|
||||||
|
// Check if the process name contains "nx" or is related to nx
|
||||||
|
// TODO: check is the process is actually the same process
|
||||||
|
dbg!(process_info);
|
||||||
|
dbg!("Process {} is running", pid);
|
||||||
|
let cmd = process_info.cmd().to_vec();
|
||||||
|
let command = args_os().collect::<Vec<OsString>>();
|
||||||
|
assert_eq!(cmd, command);
|
||||||
|
} else {
|
||||||
|
dbg!("Process {} is not running", pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
packages/nx/src/native/tests/running_tasks_service.spec.ts
Normal file
42
packages/nx/src/native/tests/running_tasks_service.spec.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { RunningTasksService, TaskDetails } from '../index';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { TempFs } from '../../internal-testing-utils/temp-fs';
|
||||||
|
import { rmSync } from 'fs';
|
||||||
|
import { getDbConnection } from '../../utils/db-connection';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
const dbOutputFolder = 'temp-db-task';
|
||||||
|
describe('RunningTasksService', () => {
|
||||||
|
let runningTasksService: RunningTasksService;
|
||||||
|
let tempFs: TempFs;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempFs = new TempFs('running-tasks-service');
|
||||||
|
|
||||||
|
const dbConnection = getDbConnection({
|
||||||
|
directory: join(__dirname, dbOutputFolder),
|
||||||
|
dbName: `temp-db-${randomBytes(4).toString('hex')}`,
|
||||||
|
});
|
||||||
|
runningTasksService = new RunningTasksService(dbConnection);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(join(__dirname, dbOutputFolder), {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should record a task as running', () => {
|
||||||
|
runningTasksService.addRunningTask('app:build');
|
||||||
|
expect(runningTasksService.getRunningTasks(['app:build'])).toEqual([
|
||||||
|
'app:build',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove a task from running tasks', () => {
|
||||||
|
runningTasksService.addRunningTask('app:build');
|
||||||
|
runningTasksService.removeRunningTask('app:build');
|
||||||
|
expect(runningTasksService.getRunningTasks(['app:build'])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -32,16 +32,18 @@ import { workspaceRoot } from '../utils/workspace-root';
|
|||||||
import { output } from '../utils/output';
|
import { output } from '../utils/output';
|
||||||
import { combineOptionsForExecutor } from '../utils/params';
|
import { combineOptionsForExecutor } from '../utils/params';
|
||||||
import { NxJsonConfiguration } from '../config/nx-json';
|
import { NxJsonConfiguration } from '../config/nx-json';
|
||||||
import type { TaskDetails } from '../native';
|
import { RunningTasksService, type TaskDetails } from '../native';
|
||||||
import { NoopChildProcess } from './running-tasks/noop-child-process';
|
import { NoopChildProcess } from './running-tasks/noop-child-process';
|
||||||
import { RunningTask } from './running-tasks/running-task';
|
import { RunningTask } from './running-tasks/running-task';
|
||||||
import { NxArgs } from '../utils/command-line-utils';
|
import { NxArgs } from '../utils/command-line-utils';
|
||||||
|
import { getDbConnection } from '../utils/db-connection';
|
||||||
|
|
||||||
export class TaskOrchestrator {
|
export class TaskOrchestrator {
|
||||||
private taskDetails: TaskDetails | null = getTaskDetails();
|
private taskDetails: TaskDetails | null = getTaskDetails();
|
||||||
private cache: DbCache | Cache = getCache(this.options);
|
private cache: DbCache | Cache = getCache(this.options);
|
||||||
private forkedProcessTaskRunner = new ForkedProcessTaskRunner(this.options);
|
private forkedProcessTaskRunner = new ForkedProcessTaskRunner(this.options);
|
||||||
|
|
||||||
|
private runningTasksService = new RunningTasksService(getDbConnection());
|
||||||
private tasksSchedule = new TasksSchedule(
|
private tasksSchedule = new TasksSchedule(
|
||||||
this.projectGraph,
|
this.projectGraph,
|
||||||
this.taskGraph,
|
this.taskGraph,
|
||||||
@ -428,6 +430,7 @@ export class TaskOrchestrator {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { code, terminalOutput } = await childProcess.getResults();
|
const { code, terminalOutput } = await childProcess.getResults();
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
task,
|
task,
|
||||||
status: code === 0 ? 'success' : 'failure',
|
status: code === 0 ? 'success' : 'failure',
|
||||||
@ -574,6 +577,15 @@ export class TaskOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async startContinuousTask(task: Task, groupId: number) {
|
private async startContinuousTask(task: Task, groupId: number) {
|
||||||
|
if (this.runningTasksService.getRunningTasks([task.id]).length) {
|
||||||
|
// task is already running, we need to poll and wait for the running task to finish
|
||||||
|
do {
|
||||||
|
console.log(`Waiting for ${task.id} in another nx process`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
} while (this.runningTasksService.getRunningTasks([task.id]).length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const taskSpecificEnv = await this.processedTasks.get(task.id);
|
const taskSpecificEnv = await this.processedTasks.get(task.id);
|
||||||
await this.preRunSteps([task], { groupId });
|
await this.preRunSteps([task], { groupId });
|
||||||
|
|
||||||
@ -613,9 +625,11 @@ export class TaskOrchestrator {
|
|||||||
temporaryOutputPath,
|
temporaryOutputPath,
|
||||||
pipeOutput
|
pipeOutput
|
||||||
);
|
);
|
||||||
|
this.runningTasksService.addRunningTask(task.id);
|
||||||
this.runningContinuousTasks.set(task.id, childProcess);
|
this.runningContinuousTasks.set(task.id, childProcess);
|
||||||
|
|
||||||
childProcess.onExit((code) => {
|
childProcess.onExit((code) => {
|
||||||
|
this.runningTasksService.removeRunningTask(task.id);
|
||||||
if (!this.cleaningUp) {
|
if (!this.cleaningUp) {
|
||||||
console.error(
|
console.error(
|
||||||
`Task "${task.id}" is continuous but exited with code ${code}`
|
`Task "${task.id}" is continuous but exited with code ${code}`
|
||||||
@ -836,6 +850,8 @@ export class TaskOrchestrator {
|
|||||||
return t.kill();
|
return t.kill();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Unable to terminate ${taskId}\nError:`, e);
|
console.error(`Unable to terminate ${taskId}\nError:`, e);
|
||||||
|
} finally {
|
||||||
|
this.runningTasksService.removeRunningTask(taskId);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user