{"id":482664,"date":"2026-06-06T23:03:46","date_gmt":"2026-06-06T23:03:46","guid":{"rendered":"https:\/\/savepearlharbor.com\/?p=482664"},"modified":"-0001-11-30T00:00:00","modified_gmt":"-0001-11-29T21:00:00","slug":"","status":"publish","type":"post","link":"https:\/\/savepearlharbor.com\/?p=482664","title":{"rendered":"A native macOS load tester app \u2014 and backpressure made it honest"},"content":{"rendered":"<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<p><em>Why I built <\/em><a href=\"https:\/\/github.com\/eugenezimin\/requester-public\/releases\" rel=\"noopener noreferrer nofollow\"><em>Requester<\/em><\/a><em>, a real-time HTTP load testing app for macOS, and what Swift structured concurrency taught me about telling the truth under load.<\/em><\/p>\n<p>I wanted to hammer an HTTP endpoint and\u00a0<em>see<\/em>\u00a0what happened. Not read a summary report three minutes later \u2014 watch it, live, the way you watch a profiler.<\/p>\n<p>The existing options are great but they all live in the terminal:\u00a0<code>wrk<\/code>,\u00a0<code>hey<\/code>,\u00a0<code>k6<\/code>. I love them, but I kept wishing for a native window with a chart that moved. So I built one for macOS, in Swift and SwiftUI, and called it\u00a0<strong>Requester<\/strong>.<\/p>\n<p>This post is less \u201chere are the features\u201d and more \u201chere are the three things I made building it.\u201d The most interesting one: making the tool\u00a0<em>honest<\/em>\u00a0about backpressure turned out to be a design decision, not an accident.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/1e7\/795\/ee5\/1e7795ee5f32a6733df18122fa505ad5.png\" alt=\"Main Window\" width=\"2114\" height=\"1484\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/getpro\/habr\/upload_files\/1e7\/795\/ee5\/1e7795ee5f32a6733df18122fa505ad5.png 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/1e7\/795\/ee5\/1e7795ee5f32a6733df18122fa505ad5.png 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption>Main Window<\/figcaption><\/div>\n<\/figure>\n<h3>The model: RPS per channel \u00d7 channels<\/h3>\n<p>Before any code, I needed a mental model the user could reason about. I landed on two knobs:<\/p>\n<ul>\n<li>\n<p><strong>Channels<\/strong>\u00a0\u2014 how many requests can be in flight at once to emulate multiuser load (even though from a single host).<\/p>\n<\/li>\n<li>\n<p><strong>RPS per channel<\/strong>\u00a0\u2014 the rate I\u00a0<em>want<\/em>\u00a0each channel to sustain.<\/p>\n<\/li>\n<\/ul>\n<p>So the total target rate is just\u00a0<code>channels \u00d7 rpsPerChannel<\/code>. No hidden division, no surprises. The dispatcher converts that into a fixed firing interval:<\/p>\n<pre><code class=\"swift\">let interval = Duration.seconds(    1.0 \/ Double(session.throughputPerSec * session.concurrency))<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:87px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The win here is conceptual, not technical: the number you type is the number you get (when the endpoint can keep up). Which brings me to the part where it\u00a0<em>can\u2019t<\/em>.<\/p>\n<h3>A load tester should lie down when the server does<\/h3>\n<p>Here\u2019s the temptation. You set a target of 500 RPS. The endpoint can only serve 200. What do you do with the other 300 requests every second?<\/p>\n<p>The naive answer is \u201cbuffer them.\u201d But then your queue grows without bound, memory balloons, and \u2014 worse \u2014 your tool reports that it\u2019s happily firing 500 RPS while reality is nowhere close. That\u2019s a lie, and it\u2019s the most dangerous kind because it looks like success.<\/p>\n<p>So Requester does the opposite. The dispatcher fires requests as independent child tasks and never awaits a response. A single actor gates how many can be in flight:<\/p>\n<pre><code class=\"swift\">private actor InFlightCounter {    private let limit: Int    private let maxQueueDepth: Int    private var current = 0    private var waiters: [CheckedContinuation&lt;Void, Never&gt;] = []    func acquireSlot() async -&gt; Bool {        \/\/ Queue is full \u2014 shed this request, don't buffer forever.        guard waiters.count &lt; maxQueueDepth else { return false }        if current &lt; limit {            current += 1            return true        }        \/\/ At the limit: suspend until a response frees a slot.        await withCheckedContinuation { waiters.append($0) }        current += 1        return true    }    func release() {        current = max(0, current - 1)        if !waiters.isEmpty { waiters.removeFirst().resume() }    }}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Two behaviors fall out of this:<\/p>\n<ol>\n<li>\n<p><strong>The dispatcher suspends when every channel is busy.<\/strong>\u00a0It can\u2019t fire faster than responses come back. So the\u00a0<em>achieved<\/em>\u00a0RPS visibly drops below your target \u2014 and that dip is exactly the signal you came for. The chart tells you the truth.<\/p>\n<\/li>\n<li>\n<p><strong>Past a configurable queue depth, requests are shed<\/strong>, not buffered. Bounded memory, bounded lag, no fantasy throughput.<\/p>\n<\/li>\n<\/ol>\n<p>When the green \u201cReceived\u201d line peels away from the blue \u201cSent\u201d line on the chart, that\u2019s the endpoint saying\u00a0<em>uncle<\/em>. I didn\u2019t have to build a special \u201cthe server is struggling\u201d indicator. The honest plumbing surfaces it for free.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/692\/143\/613\/6921436130c3111fa854d7da5c06aac0.png\" alt=\"Stats View\" width=\"974\" height=\"994\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/getpro\/habr\/upload_files\/692\/143\/613\/6921436130c3111fa854d7da5c06aac0.png 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/692\/143\/613\/6921436130c3111fa854d7da5c06aac0.png 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption>Stats View<\/figcaption><\/div>\n<\/figure>\n<h3>Structured concurrency made the whole thing boring (in the best way)<\/h3>\n<p>The entire engine is\u00a0<code>async<\/code>\/<code>await<\/code>,\u00a0<code>TaskGroup<\/code>, and actors \u2014 no GCD queues, no completion-handler pyramids, no manual locks.<\/p>\n<p>The dispatcher and a snapshot timer run as siblings in one\u00a0<code>TaskGroup<\/code>. The dispatcher records per-request outcomes into a\u00a0<code>RunCounter<\/code>\u00a0<strong>actor<\/strong>; the timer reads a delta from that same actor once per sampling interval and emits a\u00a0<code>.tick<\/code>\u00a0event. Because the counter is actor-isolated, the N writers and the one reader never race \u2014 and I never wrote a lock.<\/p>\n<pre><code class=\"swift\">actor RunCounter {    private(set) var totalCompleted = 0    private(set) var totalFailed = 0    func record(_ outcome: RequestOutcome) {        switch outcome {        case .success: totalCompleted += 1        case .failure: totalFailed += 1        }    }    \/\/\/ Delta since the last read, then advance the baseline.    func snapshot() -&gt; (completedDelta: Int, \/* \u2026 *\/ totalCompleted: Int) {        let cd = totalCompleted - lastCompleted        lastCompleted = totalCompleted        return (cd, \/* \u2026 *\/ totalCompleted)    }}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Events flow up through an\u00a0<code>AsyncThrowingStream<\/code>\u00a0from the transport adapter, into a facade, and finally onto\u00a0<code>@Published<\/code>properties in a\u00a0<code>@MainActor<\/code>\u00a0view model. SwiftUI redraws. The chart moves. Each layer has exactly one job and they communicate through\u00a0<code>Sendable<\/code>\u00a0values crossing isolation boundaries safely.<\/p>\n<p>The payoff: the scary part of a load tester \u2014 concurrent state \u2014 became the calmest part of the codebase.<\/p>\n<h3>URLSession will silently eat your headers<\/h3>\n<p>This one cost me an embarrassing amount of time, so you get it for free.<\/p>\n<p>I set a custom\u00a0<code>User-Agent<\/code>\u00a0on my\u00a0<code>URLRequest<\/code>\u00a0like any reasonable person:<\/p>\n<pre><code class=\"swift\">request.setValue(session.userAgent, forHTTPHeaderField: \"User-Agent\") \/\/ \ud83d\ude36 silently dropped<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>It never showed up on the wire. Turns out\u00a0<code>URLSession<\/code>\u00a0treats\u00a0<code>User-Agent<\/code>\u00a0(and other reserved headers) as off-limits at the\u00a0<em>request<\/em>\u00a0level and quietly strips them. The fix is to set them on the\u00a0<strong>configuration<\/strong>\u00a0instead:<\/p>\n<pre><code class=\"swift\">let config = URLSessionConfiguration.ephemeralconfig.httpAdditionalHeaders = [\"User-Agent\": session.userAgent] \/\/ \u2705 actually sent<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>No error, no warning \u2014 it just works when you move it. If you\u2019ve ever sworn your custom User-Agent \u201cisn\u2019t applying,\u201d this is probably why. Requester ships with realistic browser presets (Safari, Chrome, Firefox, mobile variants,\u00a0<code>curl<\/code>, Googlebot) precisely so you can test how an endpoint responds to different clients.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/780\/f90\/bec\/780f90bec0c40efe45f35a31eaa6acbd.png\" alt=\"Headers\" width=\"900\" height=\"652\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/getpro\/habr\/upload_files\/780\/f90\/bec\/780f90bec0c40efe45f35a31eaa6acbd.png 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/780\/f90\/bec\/780f90bec0c40efe45f35a31eaa6acbd.png 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption>Headers<\/figcaption><\/div>\n<\/figure>\n<h3>Seeing the actual bytes<\/h3>\n<p>A throughput chart tells you\u00a0<em>how much<\/em>; sometimes you need\u00a0<em>what<\/em>. Requester has a request\/response inspector that shows the exact request being fired and the live responses coming back \u2014 status line, headers, body \u2014 with a sliding-window history and an optional truncation toggle for noisy bodies.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/cc7\/290\/141\/cc7290141e7c45c086c2d425126ef74d.png\" alt=\"Loads View\" width=\"986\" height=\"1078\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/getpro\/habr\/upload_files\/cc7\/290\/141\/cc7290141e7c45c086c2d425126ef74d.png 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/cc7\/290\/141\/cc7290141e7c45c086c2d425126ef74d.png 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption>Loads View<\/figcaption><\/div>\n<\/figure>\n<h3>What\u2019s next<\/h3>\n<p>HTTP is what ships today. The transport layer is already abstracted behind a\u00a0<code>TransportAdapter<\/code>\u00a0protocol (which also made testing trivial \u2014 every transport has a mock), so the architecture is ready for more:<\/p>\n<ul>\n<li>\n<p><strong>gRPC<\/strong>\u00a0endpoint testing<\/p>\n<\/li>\n<li>\n<p><strong>WebSocket<\/strong>\u00a0load<\/p>\n<\/li>\n<li>\n<p><strong>Raw TCP<\/strong>\u00a0load<\/p>\n<\/li>\n<\/ul>\n<p>Requester is a native macOS app built entirely in Swift and SwiftUI, targeting macOS 14+.<\/p>\n<p>Available on \u2014\u00a0<a href=\"https:\/\/github.com\/eugenezimin\/requester-public\/releases\" rel=\"noopener noreferrer nofollow\">GitHub<\/a>.<\/p>\n<p><em>If you\u2019ve built load-testing tooling, I\u2019d love to hear how you handled the buffer-vs-shed question \u2014 drop a comment.<\/em><\/p>\n<\/div>\n<p>\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/1044472\/\">https:\/\/habr.com\/ru\/articles\/1044472\/<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Why I built Requester, a real-time HTTP load testing app for macOS, and what Swift structured concurrency taught me about telling the truth under load.I wanted to hammer an HTTP endpoint and\u00a0see\u00a0what happened. Not read a summary report three minutes later \u2014 watch it, live, the way you watch a profiler.The existing options are great but they all live in the terminal:\u00a0wrk,\u00a0hey,\u00a0k6. I love them, but I kept wishing for a native window with a chart that moved. So I built one for macOS, in Swift and SwiftUI, and called it\u00a0Requester.This post is less \u201chere are the features\u201d and more \u201chere are the three things I made building it.\u201d The most interesting one: making the tool\u00a0honest\u00a0about backpressure turned out to be a design decision, not an accident.Main WindowThe model: RPS per channel \u00d7 channelsBefore any code, I needed a mental model the user could reason about. I landed on two knobs:Channels\u00a0\u2014 how many requests can be in flight at once to emulate multiuser load (even though from a single host).RPS per channel\u00a0\u2014 the rate I\u00a0want\u00a0each channel to sustain.So the total target rate is just\u00a0channels \u00d7 rpsPerChannel. No hidden division, no surprises. The dispatcher converts that into a fixed firing interval:let interval = Duration.seconds(    1.0 \/ Double(session.throughputPerSec * session.concurrency))The win here is conceptual, not technical: the number you type is the number you get (when the endpoint can keep up). Which brings me to the part where it\u00a0can\u2019t.A load tester should lie down when the server doesHere\u2019s the temptation. You set a target of 500 RPS. The endpoint can only serve 200. What do you do with the other 300 requests every second?The naive answer is \u201cbuffer them.\u201d But then your queue grows without bound, memory balloons, and \u2014 worse \u2014 your tool reports that it\u2019s happily firing 500 RPS while reality is nowhere close. That\u2019s a lie, and it\u2019s the most dangerous kind because it looks like success.So Requester does the opposite. The dispatcher fires requests as independent child tasks and never awaits a response. A single actor gates how many can be in flight:private actor InFlightCounter {    private let limit: Int    private let maxQueueDepth: Int    private var current = 0    private var waiters: [CheckedContinuation&lt;Void, Never&gt;] = []    func acquireSlot() async -&gt; Bool {        \/\/ Queue is full \u2014 shed this request, don&#8217;t buffer forever.        guard waiters.count &lt; maxQueueDepth else { return false }        if current &lt; limit {            current += 1            return true        }        \/\/ At the limit: suspend until a response frees a slot.        await withCheckedContinuation { waiters.append($0) }        current += 1        return true    }    func release() {        current = max(0, current &#8212; 1)        if !waiters.isEmpty { waiters.removeFirst().resume() }    }}Two behaviors fall out of this:The dispatcher suspends when every channel is busy.\u00a0It can\u2019t fire faster than responses come back. So the\u00a0achieved\u00a0RPS visibly drops below your target \u2014 and that dip is exactly the signal you came for. The chart tells you the truth.Past a configurable queue depth, requests are shed, not buffered. Bounded memory, bounded lag, no fantasy throughput.When the green \u201cReceived\u201d line peels away from the blue \u201cSent\u201d line on the chart, that\u2019s the endpoint saying\u00a0uncle. I didn\u2019t have to build a special \u201cthe server is struggling\u201d indicator. The honest plumbing surfaces it for free.Stats ViewStructured concurrency made the whole thing boring (in the best way)The entire engine is\u00a0async\/await,\u00a0TaskGroup, and actors \u2014 no GCD queues, no completion-handler pyramids, no manual locks.The dispatcher and a snapshot timer run as siblings in one\u00a0TaskGroup. The dispatcher records per-request outcomes into a\u00a0RunCounter\u00a0actor; the timer reads a delta from that same actor once per sampling interval and emits a\u00a0.tick\u00a0event. Because the counter is actor-isolated, the N writers and the one reader never race \u2014 and I never wrote a lock.actor RunCounter {    private(set) var totalCompleted = 0    private(set) var totalFailed = 0    func record(_ outcome: RequestOutcome) {        switch outcome {        case .success: totalCompleted += 1        case .failure: totalFailed += 1        }    }    \/\/\/ Delta since the last read, then advance the baseline.    func snapshot() -&gt; (completedDelta: Int, \/* \u2026 *\/ totalCompleted: Int) {        let cd = totalCompleted &#8212; lastCompleted        lastCompleted = totalCompleted        return (cd, \/* \u2026 *\/ totalCompleted)    }}Events flow up through an\u00a0AsyncThrowingStream\u00a0from the transport adapter, into a facade, and finally onto\u00a0@Publishedproperties in a\u00a0@MainActor\u00a0view model. SwiftUI redraws. The chart moves. Each layer has exactly one job and they communicate through\u00a0Sendable\u00a0values crossing isolation boundaries safely.The payoff: the scary part of a load tester \u2014 concurrent state \u2014 became the calmest part of the codebase.URLSession will silently eat your headersThis one cost me an embarrassing amount of time, so you get it for free.I set a custom\u00a0User-Agent\u00a0on my\u00a0URLRequest\u00a0like any reasonable person:request.setValue(session.userAgent, forHTTPHeaderField: &#171;User-Agent&#187;) \/\/ \ud83d\ude36 silently droppedIt never showed up on the wire. Turns out\u00a0URLSession\u00a0treats\u00a0User-Agent\u00a0(and other reserved headers) as off-limits at the\u00a0request\u00a0level and quietly strips them. The fix is to set them on the\u00a0configuration\u00a0instead:let config = URLSessionConfiguration.ephemeralconfig.httpAdditionalHeaders = [&#171;User-Agent&#187;: session.userAgent] \/\/ \u2705 actually sentNo error, no warning \u2014 it just works when you move it. If you\u2019ve ever sworn your custom User-Agent \u201cisn\u2019t applying,\u201d this is probably why. Requester ships with realistic browser presets (Safari, Chrome, Firefox, mobile variants,\u00a0curl, Googlebot) precisely so you can test how an endpoint responds to different clients.HeadersSeeing the actual bytesA throughput chart tells you\u00a0how much; sometimes you need\u00a0what. Requester has a request\/response inspector that shows the exact request being fired and the live responses coming back \u2014 status line, headers, body \u2014 with a sliding-window history and an optional truncation toggle for noisy bodies.Loads ViewWhat\u2019s nextHTTP is what ships today. The transport layer is already abstracted behind a\u00a0TransportAdapter\u00a0protocol (which also made testing trivial \u2014 every transport has a mock), so the architecture is ready for more:gRPC\u00a0endpoint testingWebSocket\u00a0loadRaw TCP\u00a0loadRequester is a native macOS app built entirely in Swift and SwiftUI, targeting macOS 14+.Available on \u2014\u00a0GitHub.If you\u2019ve built load-testing tooling, I\u2019d love to hear how you handled the buffer-vs-shed question \u2014 drop a comment.\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 https:\/\/habr.com\/ru\/articles\/1044472\/<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[],"tags":[],"class_list":["post-482664","post","type-post","status-publish","format-standard","hentry"],"_links":{"self":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/482664","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=482664"}],"version-history":[{"count":0,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/482664\/revisions"}],"wp:attachment":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=482664"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=482664"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=482664"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}