Repository navigation
fix(terminal): queue concurrent send writers instead of fail-fast spin - #1527
Conversation
d6ae862 to
6782f29
Compare
|
@claude please review concurrent terminal writers, focusing on queue fairness, cancellation, backpressure, shutdown, and preservation of write ordering. |
|
This branch now conflicts with dev in
CI was green on the current head before the conflict appeared, so a clean rebase should go straight back to review. |
|
@claude please review this exact head (6782f29) for merge into dev. Check that the change is correct and complete for its stated goal, that tests cover the risk being fixed, and flag any security issues (secrets, injection, auth or approval bypass, path handling). Keep the review focused on this diff. |
|
Claude finished @fluck-boss's task in 3m 35s —— View job Review of
|
fluck-boss
left a comment
There was a problem hiding this comment.
Thanks for the fix. Two things before this can land:
- This branch now conflicts with dev (the terminal input path moved underneath it). Please rebase onto current dev and resolve.
- The automated review raised a real correctness concern with the queue: parked send writers can pin Dispatchers.IO threads under load, and a client cancellation may not interrupt a parked writer, so a later write can still go through after the caller has given up. Please address both - e.g. bound the parked-writer count (or offload parking off Dispatchers.IO) and make cancellation abandon the queued write.
Converting to draft in the meantime - flip it back to ready when CI is green on the rebased branch.
1120345 to
60415ef
Compare
|
Both review points are addressed on the rebased branch (head Rebase. Resolved the Parked writers. The fair
Tests.
|
TerminalSession.send's tryLock-fail path now queues writers on a kotlinx.coroutines Mutex for a bounded 5s wait (injectable via inputQueueTimeoutMillis); callers that outwait the bound are shed with RESOURCE_EXHAUSTED carrying a google.rpc RetryInfo retry-delay via StatusProto instead of an instant spin-inducing failure. The queue wait suspends rather than parking a Dispatchers.IO thread, so a stalled pipe cannot pin the pool under load, and coroutine cancellation abandons a queued write instead of letting it through after the caller gave up; cancellation surfaces as CANCELLED.
60415ef to
4ca5991
Compare
Added additional null character check for environment variable values.
|
Pushed 723df2b on top of 4ca5991. The rebase had dropped @claude please review this exact head (723df2b) for merge into dev. Confirm your earlier points on 6782f29 are resolved by the Mutex rewrite (cancellation reaching queued writers, no parked IO threads, the grant-after-timeout race, write ordering), check that the tests cover them, and flag any security issues. Keep it focused on this diff. |
|
Claude finished @fluck-boss's task in 4m 50s —— View job Review of
|
| # | Finding on 6782f29 |
Status on 723df2b |
|---|---|---|
| 1 | Cancellation never reaches a parked writer; cancelled send still writes | ✅ Resolved |
| 2 | Waiters pin Dispatchers.IO threads (cap 64) |
✅ Resolved for threads — residual is memory, see #5 |
| 3 | Grant-after-timeout race | ✅ Handled — see note below |
| 4 | Fairness ≠ write ordering, comment claimed it | |
| 5–9 | jitter, dying-session status, composed bound, Any shadowing, knob validation, undeclared dep |
❌ unchanged |
Cancellation (#1). inputMutex.lock(ticket) is a suspension point, so a cancelled RPC now abandons its queued write rather than landing bytes up to 5 s after the caller gave up. currentCoroutineContext().ensureActive() at TerminalSession.kt:114 closes the grant-races-cancellation window before writeInput runs. The residual window — cancellation arriving after ensureActive() and during the blocking writeInput — is inherent and much narrower than what it replaces. TerminalPipeFailureTest confirms the suspend conversion didn't disturb the ABORTED/FAILED_PRECONDITION paths.
IO threads (#2). Waiters suspend, so withContext(Dispatchers.IO) in sendInput releases its thread. Worst-case IO occupancy is now one thread per holder, and activeSlots = Semaphore(16) bounds sessions, so ≤16 threads rather than 64. That's the fix.
Grant-after-timeout (#3). Correct, and the fresh kotlin.Any() ticket per call is load-bearing in a way worth not losing: Mutex.lock(owner) throws IllegalStateException if that same owner already holds it, so a shared owner object would have been a bug here. Two notes on the defensive unlock at :128:
- It is unreachable on real cancellation.
withTimeoutOrNullonly swallows its ownTimeoutCancellationException; an externally cancelled job rethrows straight out ofacquireInputLock, skipping theholdsLockcheck. So the comment's "a dead caller" case is precisely the one this line does not cover. - It doesn't need to. kotlinx 1.11's
Mutexreleases a permit granted concurrently with cancellation (onCancellationRelease), on both the timeout and the cancellation path. So this is belt-and-braces on a guarantee the library already makes — fine to keep, but reword the comment so nobody later concludes the invariant rests on this line.
1. A non-positive inputQueueTimeoutMillis bricks input entirely (was finding 7b, now worse)
withTimeoutOrNull returns null without running the block when timeMillis <= 0. So under the old tryLock a zero knob degraded to non-blocking acquisition; here it never attempts the lock at all, and every sendInput fails RESOURCE_EXHAUSTED on a completely idle session. One line in init:
require(inputQueueTimeoutMillis > 0)(Correcting myself from the last review: I claimed a negative value would build a Duration violating the shared-sign invariant. That was wrong — Java's truncating division and remainder keep seconds and nanos aligned in sign, so RetryInfo stays valid. The denial-of-input above is the real consequence.) Fix this →
2. import com.google.protobuf.Any is now actively costing you (was 7a)
TerminalSession.kt:7 shadows kotlin.Any for the whole file, and the rewrite made that visible three times over: kotlin.Any() at :109 and :122, and the fully-qualified com.google.rpc.Status at :147. import com.google.protobuf.Any as ProtoAny removes all three qualifications and the footgun — TerminalServiceImpl.kt:27 is private val lock = Any(), so code moved between these two files changes meaning silently. Fix this →
3. send is a suspend function that still blocks, with no dispatcher of its own
writeInput does writer.join(inputWriteTimeoutMillis) — up to 5 s of blocking on whatever thread the caller happens to be on. In production that's fine because TerminalServiceImpl.sendInput wraps in withContext(Dispatchers.IO), but the contract now lives in the caller rather than in send. Worth noting because test 3 launches a writer on a single-thread dispatcher and only passes because that writer never acquires the lock — if it did, it would block that thread for the write bound. A withContext(Dispatchers.IO) (or runInterruptible) around writeInput inside send would make the blocking local to where it happens.
4. The queue bound equals the write bound, so a stalled write sheds every waiter by construction
Both default to 5 s. A holder that stalls aborts at t=5 s and every waiter sheds at t=5 s — the waiters can never benefit from the holder's own abort-and-close path, which is the one case where waiting would have paid off. Worst-case occupancy of one sendInput is their sum, 10 s, which is still undocumented on either constructor param. Either default the queue bound above the write bound, or say in the KDoc that the two compose.
5. Waiter count is unbounded, and each waiter retains its payload
The residual of finding 2, now in memory rather than threads. TerminalServiceImpl.kt:146 calls request.data.toByteArray() before send, so each suspended waiter holds a copy of up to 64 KiB (plus the SendInputRequest itself) across the full bound. BossIpcServer doesn't set maxConcurrentCallsPerConnection, so grpc-netty's default leaves the stream count effectively unbounded.
Severity is much lower than the thread version: session(id) runs IpcCall.current() and IpcCall.requireOwner(found.ownerInstance) before send, so this needs an already-authenticated session owner — a buggy or runaway client, not an attacker. A Semaphore(N) on waiters in the activeSlots/streamSlots idiom, shedding instantly past the cap, would close it; a follow-up is fine.
6. The ordering claim survives outside the code
The class comment at :39-41 correctly dropped it, but the PR description still says "FIFO drain order is preserved by the fair mutex" and TerminalSendQueueTest.kt:99 comments "Queued writers drain in arrival order". kotlinx's Mutex is FIFO over lock-arrival order, and since sendInput is a unary RPC dispatched concurrently onto the IO pool, two keystrokes sent back to back can reach lock() inverted — the mutex will then faithfully preserve the wrong order. The property you have is anti-starvation, which is genuinely worth having. Hazard is pre-existing; only the claim is new.
7. Still unchanged from the last review
- No jitter on
retryDelay— every shed writer gets the identical full bound, so all N wake together att+5sand re-form the convoy. - A shed writer on a dying session is told to retry.
requireUsableInput()runs only after acquisition, so duringclose()/terminate()waiters getRESOURCE_EXHAUSTED+ retry-after for a terminal that has exited, instead ofFAILED_PRECONDITION. Cheaper now that they're suspended rather than pinning threads, but still the wrong answer. - Undeclared transitive dependency.
com.google.rpc.RetryInfoandio.grpc.protobuf.StatusProtoreach this module only viaapi(libs.grpc.protobuf)in:boss-ipc;boss-app-terminal/build.gradle.ktsdeclares neither. It compiles, andAny.packneeds no reflection sonativeCompile --no-fallbackshould be fine, but declaring them is honest.
Tests
Good coverage of the two things that mattered:
- Test 3 (
suspends without pinning its dispatcher thread) is the direct regression test for finding 2, and the probe-coroutine-on-a-single-thread-dispatcher construction is the right way to assert it — atryLock-parked writer deadlocks the probe. Clean mutation story. - Test 4 (
cancelling a queued writer abandons its write) is the direct regression test for finding 1, and assertingprocess.writes == [0]after the pipe drains is exactly right — checking only at cancellation time would pass against the old code.
Gaps:
CompletableFuture.runAsyncon blocking work, still. These tasks block without aManagedBlocker, so the commonForkJoinPoolcreates no compensation threads andCompletableFutureonly falls back to thread-per-task at parallelism ≤ 1. On a 3-core runner (parallelism 2) test 1's permanently-blockedholderpins one worker and the six "concurrent" writers run one at a time on the other. Assertions still pass (~1.8 s against a 30 s latch) but the test stops exercising the concurrency in its name.Executors.newFixedThreadPool(writers + 1), shut down infinally, makes it independent of runner core count. Fix this →- Test 2's ordering assertion can't fail for ordering reasons. One queued writer means
[0, 1]distinguishes "wrote in order" from "didn't write at all", not from "wrote out of order". Two queued writers with a deterministic arrival gap would pin the FIFO property the comment claims. - Nothing pins the
RESOURCE_EXHAUSTED-vs-FAILED_PRECONDITIONsplit for a waiter that acquires after exit (finding 7's second bullet).TerminalPipeFailureTestcovers the uncontended exited-process case only. - Nothing pins the
requirefrom ⬆️ actions:(deps): Bump supabase/setup-cli from 2 to 3 #1 once it's added.
I could not run :boss-app-terminal:test here — Gradle execution isn't in my allowed tools in this run, so CI's result stands.
Security
Nothing blocking.
- NUL check —
723df2brestores'' !in namealongside the existing'' !in valueand'=' !in name, matching dev. Correct and correctly scoped; the branch's net diff against its merge base leavesvalidateLaunchInputuntouched, which is what a revert should look like. - Auth / approval bypass — none.
session(id)performsIpcCall.current()andIpcCall.requireOwner(found.ownerInstance)beforesendis reached, so the queue wait happens strictly after authorization and an unauthorized caller never enqueues.ownerInstanceis aval, so a 5 s suspension cannot outlive an ownership change (unlikestreamOutput, which re-checks per chunk because it can). - Secrets / logging — none.
inputBusy()carries no session id and no payload;RetryInfoleaks nothing. - Injection / path handling — untouched.
validateLaunchInput, the=/NUL checks andIpcEnvironment.removeCredentialsare unchanged apart from the restoration above. - Availability — the thread-starvation vector from the last review is gone; what remains is the bounded-in-time-but-not-in-count memory point in ci: use GitHub-hosted arm64 runners instead of self-hosted labels #5, reachable only by an authenticated session owner.
|
Re-review on 723df2b: the suspending-mutex queue resolves both earlier findings (no Dispatchers.IO pinning; cancellation abandons queued writes), the rebase-dropped NUL check is restored, CI green on ubuntu+windows, Claude review is on this head, maintainer diff read clean. Squash-merging to dev. |
…isa-labs-inc#928) Adds TerminalService.CloseInput, backed by TerminalSession.closeStdin(), which delivers EOF on a session's stdin without terminating it. A stdin-consuming one-shot command (sort, grep, cat with no args, or a pipeline ending in one) otherwise blocks on read() forever, and its run_command exit chunk is never produced. Rebased onto dev's reworked input path (risa-labs-inc#939, risa-labs-inc#1527): - closeStdin() takes the same input mutex as send(), through the same bounded acquireInputLock, so a close never races a write on the same fd, and a caller behind a stalled writer is answered RESOURCE_EXHAUSTED "Terminal input is busy" with a retry-after, exactly as a queued send is. - A deliberate close is tracked apart from dev's inputClosed (set when a write fails), so a send after it reads "Terminal input has been closed", not a broken pipe. - IpcVersion.CURRENT moves to 1.4.0 (1.3.0 is now risa-labs-inc#1144's NodeSkipped), and :boss-ipc's module version follows, so verifyIpcVersionConsistency passes. The history note says the bump is not a capability signal: an older host answers CloseInput with UNIMPLEMENTED, which callers must treat as "not supported". - inputBusy() moved to file scope and requireUsableInput() folded to one throw, to stay within detekt's TooManyFunctions and ThrowsCount limits. Tests: TerminalCloseInputTest (5) covers EOF delivery with and without input, an idempotent second close, a send refused after close while the process is still alive (drain-stdin-hold fixture), and the busy answer while a 1 MiB write holds the lock, bounded so a regression fails instead of hanging. Mutation-checked: an unbounded wait in closeStdin, or dropping the closed-on-request check, each fail a test. Refs risa-labs-inc#928
…isa-labs-inc#928) Adds TerminalService.CloseInput, backed by TerminalSession.closeStdin(), which delivers EOF on a session's stdin without terminating it. A stdin-consuming one-shot command (sort, grep, cat with no args, or a pipeline ending in one) otherwise blocks on read() forever, and its run_command exit chunk is never produced. Rebased onto dev's reworked input path (risa-labs-inc#939, risa-labs-inc#1527): - closeStdin() takes the same input mutex as send(), through the same bounded acquireInputLock, so a close never races a write on the same fd, and a caller behind a stalled writer is answered RESOURCE_EXHAUSTED "Terminal input is busy" with a retry-after, exactly as a queued send is. - A deliberate close is tracked apart from dev's inputClosed (set when a write fails), so a send after it reads "Terminal input has been closed", not a broken pipe. - IpcVersion.CURRENT moves to 1.4.0 (1.3.0 is now risa-labs-inc#1144's NodeSkipped), and :boss-ipc's module version follows, so verifyIpcVersionConsistency passes. The history note says the bump is not a capability signal: an older host answers CloseInput with UNIMPLEMENTED, which callers must treat as "not supported". - inputBusy() moved to file scope and requireUsableInput() folded to one throw, to stay within detekt's TooManyFunctions and ThrowsCount limits. Tests: TerminalCloseInputTest (5) covers EOF delivery with and without input, an idempotent second close, a send refused after close while the process is still alive (drain-stdin-hold fixture), and the busy answer while a 1 MiB write holds the lock, bounded so a regression fails instead of hanging. Mutation-checked: an unbounded wait in closeStdin, or dropping the closed-on-request check, each fail a test. Refs risa-labs-inc#928
) Adds TerminalService.CloseInput, backed by TerminalSession.closeStdin(), which delivers EOF on a session's stdin without terminating it. A stdin-consuming one-shot command (sort, grep, cat with no args, or a pipeline ending in one) otherwise blocks on read() forever, and its run_command exit chunk is never produced. Rebased onto dev's reworked input path (#939, #1527): - closeStdin() takes the same input mutex as send(), through the same bounded acquireInputLock, so a close never races a write on the same fd, and a caller behind a stalled writer is answered RESOURCE_EXHAUSTED "Terminal input is busy" with a retry-after, exactly as a queued send is. - A deliberate close is tracked apart from dev's inputClosed (set when a write fails), so a send after it reads "Terminal input has been closed", not a broken pipe. - IpcVersion.CURRENT moves to 1.4.0 (1.3.0 is now #1144's NodeSkipped), and :boss-ipc's module version follows, so verifyIpcVersionConsistency passes. The history note says the bump is not a capability signal: an older host answers CloseInput with UNIMPLEMENTED, which callers must treat as "not supported". - inputBusy() moved to file scope and requireUsableInput() folded to one throw, to stay within detekt's TooManyFunctions and ThrowsCount limits. Tests: TerminalCloseInputTest (5) covers EOF delivery with and without input, an idempotent second close, a send refused after close while the process is still alive (drain-stdin-hold fixture), and the busy answer while a 1 MiB write holds the lock, bounded so a regression fails instead of hanging. Mutation-checked: an unbounded wait in closeStdin, or dropping the closed-on-request check, each fail a test. Refs #928
#1527) * fix(terminal): queue concurrent send writers instead of fail-fast spin TerminalSession.send's tryLock-fail path now queues writers on a kotlinx.coroutines Mutex for a bounded 5s wait (injectable via inputQueueTimeoutMillis); callers that outwait the bound are shed with RESOURCE_EXHAUSTED carrying a google.rpc RetryInfo retry-delay via StatusProto instead of an instant spin-inducing failure. The queue wait suspends rather than parking a Dispatchers.IO thread, so a stalled pipe cannot pin the pool under load, and coroutine cancellation abandons a queued write instead of letting it through after the caller gave up; cancellation surfaces as CANCELLED. * fix(terminal): keep the NUL check on environment variable names Added additional null character check for environment variable values. --------- Co-authored-by: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Co-authored-by: fluck-boss <62e43a68001@mail.instinct.com>
) Adds TerminalService.CloseInput, backed by TerminalSession.closeStdin(), which delivers EOF on a session's stdin without terminating it. A stdin-consuming one-shot command (sort, grep, cat with no args, or a pipeline ending in one) otherwise blocks on read() forever, and its run_command exit chunk is never produced. Rebased onto dev's reworked input path (#939, #1527): - closeStdin() takes the same input mutex as send(), through the same bounded acquireInputLock, so a close never races a write on the same fd, and a caller behind a stalled writer is answered RESOURCE_EXHAUSTED "Terminal input is busy" with a retry-after, exactly as a queued send is. - A deliberate close is tracked apart from dev's inputClosed (set when a write fails), so a send after it reads "Terminal input has been closed", not a broken pipe. - IpcVersion.CURRENT moves to 1.4.0 (1.3.0 is now #1144's NodeSkipped), and :boss-ipc's module version follows, so verifyIpcVersionConsistency passes. The history note says the bump is not a capability signal: an older host answers CloseInput with UNIMPLEMENTED, which callers must treat as "not supported". - inputBusy() moved to file scope and requireUsableInput() folded to one throw, to stay within detekt's TooManyFunctions and ThrowsCount limits. Tests: TerminalCloseInputTest (5) covers EOF delivery with and without input, an idempotent second close, a send refused after close while the process is still alive (drain-stdin-hold fixture), and the busy answer while a 1 MiB write holds the lock, bounded so a regression fails instead of hanging. Mutation-checked: an unbounded wait in closeStdin, or dropping the closed-on-request check, each fail a test. Refs #928
Summary
Test plan