Codex/perf benchmark improvements - #184
Open
hodgesds wants to merge 18 commits into
Open
Conversation
A stress-ng MM/process matrix under KASAN (SMP=16) panicked with a store
to a poisoned address in close_kernel_span's kern_span_start_ns.swap(0),
reached from the CPL0 timer-preempt accounting hook:
try_preempt -> pause_user_kernel_span -> pause_current_kernel_span
-> pause_kernel_span_for -> close_kernel_span (store to freed uctx)
try_preempt had already validated the preempted KernelTask as live (its
own-stack tripwire passed), so current_user_task() was not resolving that
task -- it fell through to the legacy per-CPU CURRENT cell. In the
own-stack model that cell is published once per poll (install_current)
but its clear site is the longjmp poll tail (clear_current), which
own-stack execution never reaches: it diverges into user mode via
kernel_switch and parks/exits without returning through the poll. So
after any own-stack task exits and its Arc<Task>/uctx is RCU-freed, the
cell dangles at freed memory.
The race: a timer preempts a freshly polled task before its poll reaches
publish_current_task, so its owner context (user_context) is still null.
current_user_task() then fell back to the stale cell and the accounting
pause stored into the previous task's freed uctx. KASAN-slow fresh-task
startup (address-space activate, page-table walks) is what widened the
window enough to hit reliably.
Fix: in the own-stack model, resolve the current uctx only from the
scheduler-published owner context (lifetime-coupled to the live
KernelTask, and correct across direct resume + migration). When no owner
is published, return None instead of reading the legacy cell -- a task
that has not published a context has no open kernel span to account
against. The legacy cell remains authoritative only in the longjmp
model. The own-stack-vs-legacy precedence is extracted into a pure
current_user_task_source() helper so it is deterministically testable
without toggling the global own-stack latch (shared across the
concurrent kernel-test run).
Regression test smoke_own_stack_never_reads_stale_legacy_cell asserts
the split: own-stack + no owner + non-null legacy cell resolves to None
(the exact pre-fix UAF path). Validated: the KASAN stress-ng matrix that
reproduced the panic now completes clean (36/36 stressors); kernel-test
7291 pass / 0 fail; clippy x86_64 + aarch64 and fmt clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Blocking stream send(2)/sendmsg(2) now park on the connected ring's durable capacity instead of failing early. O_NONBLOCK and MSG_DONTWAIT return EAGAIN immediately. sendmmsg(2) returns an already-transmitted prefix without retrying it. Adds socket_send_would_block() as the shared non-blocking predicate and covers the paths with abi_socket_tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a wide-fanout BPF program image to the bench suite (bpf/src/bench.rs) with the verifier fixpoint support it needs, and extends the xtask bpf_bench runner to accept a previous green-main record via --baseline and a release record via --release-baseline, archiving series for comparison. Specs updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The chroot_run harness now mounts a fresh tmpfs at /mnt/dev/shm and a fresh /mnt/tmp before entering the root, so the POSIX shm stressor finds its shared-memory mount (a plain bind of /dev omits the nested /dev/shm). Rebuilds chroot_run_x86_64 and refreshes REGEN_stress_rootfs.sh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
madvise_dontneed cleared each resident page with a separate unmap_4kb_local call, and that helper acquires the page-table-root lock and walks PML4→PDPT→PD→PT every time. A large MADV_DONTNEED/MADV_FREE therefore paid one root-lock acquisition and a full upper-level walk per page. Clear the whole page-aligned per-region intersection with a single unmap_4kb_local_range (one root lock, one cached upper-level walk), mirroring what the aarch64 path already did with unmap_4kb_range and what punch_fixed / rewrite_perms_pages already use on x86_64. The per-page loop now only collects frames to release. Behavior is unchanged: the same leaves are cleared (missing leaves over unfaulted holes are benign), the same private frames are freed, LOCKED and SHARED regions are still skipped, and the single post-teardown cross-CPU broadcast still precedes any frame reuse. Adds smoke_memory_madvise_dontneed_range_frees_all_and_keeps_hole: a multi-page region with an interior unfaulted hole must free every resident frame and zero its slot while leaving the hole's slot untouched — coverage the single-page release test could not provide. Validated: kernel-test 7292 pass / 0 fail; clippy x86_64 + aarch64 and fmt clean; the KASAN stress-ng madvise stressor passes with no sanitizer catch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A stress-ng os-class sweep (bad-altstack) panicked NARF with an unrecoverable kernel #PF: error=0x3 (supervisor write to a present, write-protected page), cr2 in the user half, RIP in deliver_signal's CPL=0 signal-frame write under the SMAP window. deliver_signal pre-flights the frame's target pages with ensure_user_range_writable, which backed demand/guard pages and treated demand_alloc_page's AlignmentMismatch (page already present) as "writable now". But presence is not writability: bad-altstack points sigaltstack at a PROT_READ page, so the pre-flight passed, the CPL=0 frame write faulted on the read-only page, and the #PF handler's supervisor-COW recovery correctly refused it (not a COW page) — leaving only the panic path. Userspace could thus panic the whole kernel by taking a signal with its (alt)stack on a write-protected page. Add AddressSpace::user_page_writable_or_resolve: for a present page it gates on RegionPerms::WRITE and resolves a COW copy in place (cow_split + remap); a genuinely read-only mapping returns false. ensure_user_range_ writable now calls it after backing each page, so an unwritable target makes deliver_signal return false and the caller applies the signal's default action (terminate the task) — Linux's force_sigsegv model — while the kernel survives. Legitimate deliveries to writable or COW stacks are unaffected. Regression test smoke_memory_user_page_writable_gates_readonly asserts a PROT_READ page is refused, a writable page accepted, an unmapped page refused. Validated: kernel-test 7293 pass / 0 fail; clippy x86_64 + aarch64 and fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Listing a directory of N entries cost O(N²): sys_getdents64 called Dir::enumerate_async(cursor, 1) once per emitted record, and the memfs backend's enumerate does entries.iter().skip(cursor) — re-walking the BTreeMap from the start on every call. Mass directory workloads (stress-ng chdir/dirdeep create 8192 dirs, getdents them all, remove them) were three orders of magnitude slower than their filesystem-class peers (chdir 0.27 vs dir 1445 bogo-ops/s). Take a single tail snapshot enumerate_async(cursor, usize::MAX) once per syscall and serve records from it — one BTreeMap traversal per buffer fill instead of one per record. This reuses the snapshot pattern the handler already had for the procfs iter() fallback (preserved). Semantics are unchanged: identical entry order, cursor advances exactly once per written record, a buffer-full stop breaks without advancing the cursor so the next call resumes from the persisted position (block-at-a-time getdents), and "."/".." handling, d_type mapping, and EBADF/ENOTDIR are untouched. smoke_memfs_large_dir_enumerate_walks_all drives 2048 entries through the exact handler snapshot/advance protocol and asserts each name appears once in sorted order, then lookup+unlink of all N. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pipe transfers moved one byte at a time: PipeRead::read popped with a q.pop_front() loop and PipeWrite::write pushed with a q.push_back() loop, so a 64 KiB transfer did 64 K individual VecDeque ops. splice compounded it — copy_fd_to_fd looped in 4096-byte chunks, each a fresh heap Vec plus two fd-table lock acquisitions (16 iterations for a full pipe). splice ran at 360 bogo-ops/s vs the plain pipe stressor's 4438. vmsplice additionally made no forward progress (0 bogo-ops): with the pipe full, PipeWrite::write returned Ok(0) and the handler reported a 0-byte success instead of blocking or signalling EAGAIN, so the vmsplice→splice loop stalled once the pipe filled. - pipe.rs: bulk ring I/O — PipeRead::read uses q.drain(..n), PipeWrite::write uses q.extend(...). Benefits splice, vmsplice, pipe, fifo, ring-pipe. - core.inc.rs: copy_fd_to_fd CHUNK 4096 → 65536 (one default pipe buffer), so a full-pipe splice completes in a single read+write pair. - sys_vmsplice.rs: check for a full pipe before gathering any bytes (keeps re-execution idempotent) — SPLICE_F_NONBLOCK returns EAGAIN, blocking parks via park_reexecute_on_io. Return value stays bytes-gathered; EINVAL/EFAULT semantics unchanged. smoke_abi_fdio_vmsplice_full_pipe_eagain fills the 64 KiB pipe, asserts a SPLICE_F_NONBLOCK vmsplice returns EAGAIN and queues nothing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every brk grow registered a NEW VMA and then called whole-address-space materialize(), which re-walks every region and its backing vector. N small grows therefore created N VMAs and cost O(N²). Shrink took a deep regions_snapshot() before selecting regions to unmap. Add AddressSpace::brk_extend_region: under the region lock it checks the ordered index's successor for tail overlap (O(log VMA)) and either extends the single existing heap VMA in place (phys.extend + len bump) or inserts the one heap region on the first grow. sys_brk's grow now calls it and materializes ONLY the appended range (materialize_range), and shrink is a single punch_fixed of the freed tail selected from the ordered index — no whole-AS snapshot. Growth is O(log VMA + pages added). Preserved: the AS-scoped break shared across CLONE_VM siblings; exact partial-page break semantics (a within-page move records the break with no PTE work); OLD-break rollback on alloc / register / PTE-install failure; and fork inheritance (the single heap VMA clones like any region, brk_top already inherited). smoke_brk_single_growable_vma does 64 one-page grows asserting exactly one heap VMA throughout and zeroed backing, a fresh-page grow that adds exactly one page, a within-page grow/shrink that leaves the VMA unchanged, an over-ceiling grow that reports the old break, and a real shrink that tail-punches to a single truncated VMA. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each growable-stack #PF ran AddressSpace::try_grow_stack, which found the STACK_GUARD via regions.iter().find() — a linear scan over every VMA — and then promoted the guard into a fresh STANDALONE R+W region without merging it into the stack VMA directly above. Every fault therefore added one more region, so N growths cost O(N²) scanning an ever-growing table (plus VMA churn and a dense-phys clone per fault). stress-ng's stack stressor hammers this: NARF 517 vs Linux ~247k bogo-ops/s. Locate the guard in O(log n) via the ordered index (containing().or_else(successor())) and extend the adjacent stack VMA in place: consume the one-page guard, prepend its frame to the stack region's phys, lower the region base, grow its len, and reinstall one fresh guard below — keeping the stack a SINGLE VMA across arbitrarily many growths and preserving its own perms (an executable stack stays executable, which the old hardcoded READ|WRITE dropped). A fallback still installs a standalone span when no stack VMA sits above the guard. Guard/overflow semantics are unchanged: the MAX_GROW bound, the MMAP_WINDOW_TOP floor, and the guard-collision -> Overlap (real SIGSEGV) checks are preserved, so a wild write far below the stack still faults. Fresh pages are alloc_user_frame'd + zeroed (refcount 1), writable even if the merged region carries COW. Tests: smoke_memory_try_grow_stack_extends_one_vma (16 growths stay one EXEC VMA, base moves down 16 pages, all backed+zeroed) and an updated smoke_memory_try_grow_stack_sequential (three growths coalesce into one 3-page VMA based two pages below the guard, single trailing guard). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sysvipc.rs was already keyed by BTreeMap and locked per-syscall, so the gap versus Linux (sem-sysv 363x, msg 144x) was not contention — it was heap allocation on every hot-path op: - semop cloned the whole semaphore-value vector (set.sems.clone()) and reassigned it, plus copy_from_user_vec'd the sops array — three heap ops per semop for what is a couple of integer compares; - msgsnd copied the message into a combined Vec then .to_vec()'d the payload again (two allocs per send); - msgrcv allocated a fresh combined mtype+payload Vec per receive. Read the sops array into a fixed on-stack buffer (entered only after the existing nsops<=MAX_SOPS guard) and apply the semop in place with rollback: bounds-check every sem_num, apply each op against the running value (preserving repeated-sem_num accumulation, i.e. Linux's atomic block), and on the first blocking op undo the applied deltas. msgsnd validates the mtype header from a stack slot and allocates the queued payload exactly once; msgrcv writes the header and payload with two direct copy_to_user calls. No heap allocation on the semop/msgsnd/msgrcv hot paths. Semantics preserved: id/key allocation, semop all-or-nothing + EAGAIN, msg type selection (>0 first-of-type, <0 lowest-<=|type|, 0 first) and byte layout, EIDRM, and the *ctl fields. The in-place rollback is sound because verify+apply run under one SEMS lock with no await between. Tests: semop atomic rollback (blocked multi-sop leaves values intact), multi-sop commit with repeated sem_num accumulation, 64-set keyed lookup, and msgrcv type selection with the two-write output path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sigqueue's pending-signal store is already per-task bucket-sharded (no global lock) and the return-to-user path early-outs on an empty pending mask, but each rt_sigqueueinfo still paid two avoidable costs (sigq: NARF 2639 vs Linux 366450 bogo-ops/s): - capture_queued_siginfo read the 32-byte siginfo via copy_from_user_vec, which heap-allocates a throwaway Vec on every send; - after enqueue, each send handler called sigqueue_depth(), a SECOND SIGQUEUE_INFO lock acquisition and another range().sum() scan over the task's queued signals, purely to decide the back-pressure yield — O(n) per enqueue, O(n^2) under a lagging consumer. Read the siginfo into a fixed [u8; 32] stack buffer. Fold the depth into the enqueue: store_sigqueue_info_depth returns the post-insert queued depth (from the sum the RT cap check already computes), and the send handlers use it directly — one SIGQUEUE_INFO round-trip per send instead of two. Semantics preserved: RT signals (>=32) queue every instance FIFO with per-instance payload; standard signals coalesce to the latest; si_value/ si_code/si_pid bytes unchanged; the RLIMIT_SIGPENDING cap still returns EAGAIN; mask blocking, SIGRETURN_SAVED_MASK, and deliver-on-return untouched. Test: enqueue three RT instances (depth 1/2/3), a standard signal twice (depth rises by one, coalesced), then drain asserting RT FIFO order + payloads and the single coalesced standard instance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
resolve_vfs_symlink_path was O(depth²): for each of the D path components it rebuilt the /c0/…/ci prefix and re-resolved it from the mount root (1+2+…+D walks). It is on the hot path of open/mount/chdir, so a deep tree (dirdeep) or many chdirs cost quadratically — dirdeep 167x, chdir 39x slower than Linux. Add a single O(depth) forward walk (resolve_vfs_symlink_path_fast) that carries the parent-dir handle and looks up each component NOFOLLOW (lookup_async returns the raw node), so a symlink is SEEN, not silently followed. It returns the path unchanged only after proving every component is a real directory (last may be a real file) with no symlink and no mount ambiguity; ANY symlink component, or a path that crosses / ancestors a nested mount (fast_walk_stays_in_one_mount), bails to the unchanged slow per-prefix loop, which still does splice / absolute-target re-root / ELOOP / chroot exactly as before. No lock is held across block I/O (the fs Arc is cloned out first). This is the corrected form of an earlier attempt that used auto-following directory handles and so skipped symlink expansion; the difference is the NOFOLLOW single-component lookup. Tests: a deep symlink-free tree create/chdir/open/rmdir (exercises the fast path) and an intermediate directory-symlink case (forces the slow path); both the mount-crossing and directory-symlink gate cases are covered. "."/".."/trailing-slash (normalize_abs), ELOOP, mount traversal, chroot scoping and all path errors are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sys_shmat called AddressSpace::materialize() — the whole-address-space walk — on every attach, so a shmget/shmat/shmdt loop re-installed PTEs for every VMA in the address space per attach (shm-sysv ~28x slower than Linux). Use the range-scoped materialize_range(base, map_len) primitive (the same one sys_mmap uses) over exactly the attached segment. Derive the region len and the VA reservation from the page-aligned frame count so the Region's len matches its scatter list — which also fixes a latent sub-page-segment attach that failed map_region_inner's page-alignment assert. Shared-frame lifetime is unchanged: the mapping stays RegionPerms::SHARED under with_shared_mapping_transaction, attach retains via retain_shared_frames and detach/drop releases via release_shared_phys (never free_frame), so the cross-AS marginal-buddy double-free guard is intact; materialize_range only installs PTEs, mapping the SAME frames (shared visibility, no copy). Address selection and SHM_RDONLY unchanged. Test: two address spaces attach the same frame via materialize_range, both translate to the same phys, a sentinel written through one is read via the other (shared, not copied), and dropping one AS leaves the frame mapped+live in the other (no free-while-attached). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two problems from the sweep: vm-splice did 0 bogo-ops and splice was 132x slower than Linux. vm-splice made no progress because sys_vmsplice treated every fd as a gather target and called ops.write() on it. The stress-ng vm-splice loop issues vmsplice on the pipe READ end (SPLICE_TO_USER — drain pipe to memory); PipeRead::write returns BadFd, so the handler hit its EINVAL arm and the stressor aborted on iteration one. Split sys_vmsplice on direction (pipe_peek is Some only on the read end): the read end drains the pipe out to user memory (vmsplice_from_pipe, copy_to_user iovec-at-a-time honoring EAGAIN/park/EOF); the write end gathers as before. The iovec array is validated once up front so a bad iovec reports EFAULT regardless of the fd; a bad fd still reports EBADF. splice paid a per-call 64 KiB vec![0u8; N] memset plus two memcpys and two fd-table locks in copy_fd_to_fd. Add PipeRead::pipe_take (drain the ring into a Vec via drain(..n).collect(), no zero-init/second copy) and pipe_unread (prepend a sink's unwritten tail back, zero-loss); splice from a pipe source now uses splice_pipe_source (drain straight into the sink write), while a non-pipe source keeps the offset-aware copy_fd_to_fd. Draining a pipe now also bumps the readiness generation so a blocked writer/reader is actually woken (a latent lost-wakeup). Semantics preserved: return value = bytes moved (partial sink write pushes the tail back), EAGAIN on nonblocking empty/full, SPLICE_F_NONBLOCK, offset write-back for non-pipe fds (pipe sources are ESPIPE), PIPE_BUF atomicity, EOF/HUP. Tests: splice N bytes pipe->pipe (count + drained), vmsplice drain from a pipe read end, and the vmsplice-in -> splice-out -> vmsplice-in round-trip that used to abort now makes progress. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Syscall::from_raw ran a linear scan of the ~300-row LINUX_TABLE (then the NARF extension table) on EVERY syscall — via kernel_syscall_entry and the plain-path dispatch — and again on every parked-syscall backstop re-poll. High Linux numbers (futex=202, clock_gettime=228, rseq=293) landed deep in the scan, so the reverse lookup was a real per-syscall tax on top of the entry/exit register shuffle. A 1:1 benchmark (both in QEMU) showed NARF 34–541x slower than Linux with the gap tracking syscalls-per-op, so per-syscall fixed cost is the dominant lever. Build a compile-time direct-indexed map [Option<Syscall>; 1024] keyed by wire number (a const-eval assert fails the build if a new Linux row ever exceeds the range), and index it in O(1). NARF extensions (0x4000+) stay a short linear scan since they're sparse and far out of the Linux range. from_raw drops `const` (no const callers); raw() is unchanged. Test smoke_syscall_from_raw_matches_tables checks the O(1) result equals the original top-down linear scan for every wire number in both tables and across the whole dense range (a variant can be aliased to more than one number, so it compares against the scan oracle, not a raw() round-trip). Validated: kernel-test 7310 pass / 0 fail; clippy x86_64 + aarch64 and fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Scheduler policy trait had pick_next + enqueue/dequeue notifications + CPU lifecycle hooks, but nothing for the WAKEUP path — the piece Linux's try_to_wake_up centers on (select_task_rq -> enqueue -> wakeup_preempt -> task_woken). A wake in NARF is a bare flag=true + resched IPI on the task's home CPU, with no CPU selection, no wakeup preemption, and no wake event, so a task woken onto a busy CPU waits at that CPU's queue tail while other cores sit idle — the scheduling hop that dominates syscall-rate-bound latency, and (with no centralized wake) the surface for the executor-halt lost-wakeup. Add the missing advisory hooks to the trait, mirroring Linux's sched_class (kernel/sched/sched.h) and this trait's existing "core keeps authority, policy advises" contract: - select_wake_cpu(task, waker, home, idle_mask) -> CpuId (select_task_rq) - wakeup_preempt(cpu, woken, current) -> bool (wakeup_preempt) - task_woken(cpu, task) (task_woken) - task_tick(cpu, current) -> bool (task_tick) - update_curr(cpu, current, delta_ns) (update_curr) - yield_task(cpu, current) (yield_task) All have behavior-preserving defaults (select_wake_cpu keeps the task on its home CPU; wakeup_preempt/task_tick return false; the rest no-op), so this commit is purely additive with no runtime change — it establishes the interface. Wiring the wake path through select_wake_cpu/wakeup_preempt /task_woken (idle-first placement + a reliable enqueue+IPI handshake) and implementing them in FifoScheduler land in follow-up commits, each validated on its own. Kernel-test 7310 pass / 0 fail; clippy x86_64 + aarch64 and fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the "run the woken task promptly" slice of Linux's try_to_wake_up. A wake was a bare flag=true on the task's home CPU, so a woken task waited at that CPU's ready-queue tail behind every other slot (the scheduling hop that dominates multi-task wakeup latency). Now a wake also publishes the task id into a per-CPU RUN_NEXT hint, and pick_next_slot picks that task ahead of the FIFO backlog. Correctness guards (a naive hint starves the queue): - WakeCell gains a task id; the hint is bounded to best-tier AND best-priority slots, so it reorders a woken task ahead of its EQUALS only — never inverting priority or bypassing throttling; - LAST_PICKED rejects a hint that names the just-picked task, so a cooperatively-yielding task's SELF-wake (the yield heartbeat) goes to the tail like any yield instead of re-boosting itself forever; - a one-pick cooldown caps the boost to at most every other pick, so a ping-pong pair waking each other cannot monopolize the CPU. The hint only reorders already-runnable slots — it never sets the runnable bit or touches the halt handshake, so it cannot create or mask a lost wakeup; a stale/migrated id is simply ignored. Measured neutral on single-worker stress-ng (pipe/msg/sem-sysv/sigq within run-to-run noise): with one task per CPU there is no FIFO backlog to jump, so the hop it removes is absent there — those gaps are per-syscall-overhead-bound, not scheduling-hop-bound. Its win is multi-task-per-CPU wakeup latency (the redis/mt-echo class). Committed as the foundation the select_wake_cpu placement + wakeup_preempt steps build on. Validated: kernel-test 7310 pass / 0 fail (incl. the concurrent-reader and sleepable-timeout tests that a first, unguarded version starved); clippy x86_64 + aarch64 and fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.