| author | |
| committer | |
| log | e01c153c18a94a2aa33df7115c6cfee1eb09880f |
| tree | acbbf3db792b4ec10c267d73130e37edd99945b2 |
| parent | ef4cc6085e24d6309252412c6dab5482ac11a09f |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 195 insertions(+), 13 deletions(-)
apps/server/src/provider/AntigravityAuth.test.ts+60-1| ... | ... | @@ -54,6 +54,7 @@ const phase = (auth: AntigravityAuth, value: ProviderAuthState["phase"], session |
| 54 | 54 | const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( |
| 55 | 55 | options: { |
| 56 | 56 | readonly interactive?: boolean; |
| 57 | readonly authorizationUrls?: ReadonlyArray<string>; | |
| 57 | 58 | readonly supportsLogout?: boolean; |
| 58 | 59 | readonly forwardCallback?: Effect.Effect<void, ProviderSetupError>; |
| 59 | 60 | } = {}, |
| ... | ... | @@ -62,12 +63,16 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( |
| 62 | 63 | const discovered = yield* Deferred.make<void>(); |
| 63 | 64 | const closed = yield* Deferred.make<void>(); |
| 64 | 65 | const events: string[] = []; |
| 66 | let receiveAuthorizationUrl: | |
| 67 | | ((url: string) => Effect.Effect<void, AcpErrors.AcpError>) | |
| 68 | | undefined; | |
| 65 | 69 | let forwarded = 0; |
| 66 | 70 | let catalog = ["previous-account-model"]; |
| 67 | 71 | const auth = yield* makeAntigravityAuth({ |
| 68 | 72 | instanceId, |
| 69 | 73 | makeRuntime: (input) => |
| 70 | 74 | Effect.gen(function* () { |
| 75 | receiveAuthorizationUrl = input.onAuthorizationUrl; | |
| 71 | 76 | events.push("process-open"); |
| 72 | 77 | yield* Effect.addFinalizer(() => |
| 73 | 78 | Effect.gen(function* () { |
| ... | ... | @@ -87,7 +92,9 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( |
| 87 | 92 | Effect.gen(function* () { |
| 88 | 93 | events.push("authenticate"); |
| 89 | 94 | if (options.interactive !== false && input.onAuthorizationUrl) { |
| 90 | yield* input.onAuthorizationUrl(authorizationUrl); | |
| 95 | for (const url of options.authorizationUrls ?? [authorizationUrl]) { | |
| 96 | yield* input.onAuthorizationUrl(url); | |
| 97 | } | |
| 91 | 98 | } |
| 92 | 99 | yield* Deferred.await(authenticated); |
| 93 | 100 | events.push("session-new"); |
| ... | ... | @@ -124,10 +131,62 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( |
| 124 | 131 | events, |
| 125 | 132 | catalog: () => catalog, |
| 126 | 133 | forwarded: () => forwarded, |
| 134 | receiveAuthorizationUrl: (url: string) => | |
| 135 | Effect.suspend(() => | |
| 136 | receiveAuthorizationUrl | |
| 137 | ? receiveAuthorizationUrl(url) | |
| 138 | : Effect.die("Authorization URL receiver is not ready."), | |
| 139 | ), | |
| 127 | 140 | }; |
| 128 | 141 | }); |
| 129 | 142 | |
| 130 | 143 | it.layer(NodeServices.layer)("AntigravityAuth", (it) => { |
| 144 | it.effect("accepts the same authorization URL from stderr and stdout", () => | |
| 145 | Effect.gen(function* () { | |
| 146 | const harness = yield* makeHarness({ | |
| 147 | authorizationUrls: [authorizationUrl, authorizationUrl], | |
| 148 | }); | |
| 149 | yield* harness.auth.controller.start(owner); | |
| 150 | const waiting = yield* phase(harness.auth, "waiting"); | |
| 151 | assert.equal(waiting.authorizationUrl, authorizationUrl); | |
| 152 | ||
| 153 | yield* Deferred.succeed(harness.authenticated, undefined); | |
| 154 | yield* Deferred.succeed(harness.discovered, undefined); | |
| 155 | yield* phase(harness.auth, "succeeded"); | |
| 156 | }), | |
| 157 | ); | |
| 158 | ||
| 159 | it.effect("accepts a delayed duplicate after callback completion starts", () => | |
| 160 | Effect.gen(function* () { | |
| 161 | const harness = yield* makeHarness(); | |
| 162 | const state = yield* harness.auth.controller.start(owner); | |
| 163 | yield* phase(harness.auth, "waiting"); | |
| 164 | yield* harness.auth.controller.complete(owner, { | |
| 165 | flowId: state.flowId!, | |
| 166 | callbackUrl, | |
| 167 | }); | |
| 168 | ||
| 169 | yield* harness.receiveAuthorizationUrl(authorizationUrl); | |
| 170 | ||
| 171 | yield* Deferred.succeed(harness.authenticated, undefined); | |
| 172 | yield* Deferred.succeed(harness.discovered, undefined); | |
| 173 | yield* phase(harness.auth, "succeeded"); | |
| 174 | }), | |
| 175 | ); | |
| 176 | ||
| 177 | it.effect("rejects a different second authorization URL", () => | |
| 178 | Effect.gen(function* () { | |
| 179 | const harness = yield* makeHarness({ | |
| 180 | authorizationUrls: [authorizationUrl, `${authorizationUrl}&scope=another-request`], | |
| 181 | }); | |
| 182 | yield* harness.auth.controller.start(owner); | |
| 183 | ||
| 184 | const failed = yield* phase(harness.auth, "failed"); | |
| 185 | assert.isNull(failed.authorizationUrl); | |
| 186 | assert.deepEqual(harness.catalog(), ["previous-account-model"]); | |
| 187 | }), | |
| 188 | ); | |
| 189 | ||
| 131 | 190 | it.effect("keeps a remote flow private and waits for native auth and catalog discovery", () => |
| 132 | 191 | Effect.gen(function* () { |
| 133 | 192 | const harness = yield* makeHarness(); |
apps/server/src/provider/AntigravityAuth.ts+6-3| ... | ... | @@ -20,11 +20,13 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 20 | 20 | import * as AcpErrors from "effect-acp/errors"; |
| 21 | 21 | |
| 22 | 22 | import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; |
| 23 | import { parseAntigravityAuthorizationUrl } from "./antigravityAuthSupport.ts"; | |
| 23 | import { | |
| 24 | parseAntigravityAuthorizationUrl, | |
| 25 | type AntigravityAuthorizationUrl, | |
| 26 | } from "./antigravityAuthSupport.ts"; | |
| 24 | 27 | import { |
| 25 | 28 | forwardAntigravityCallback, |
| 26 | 29 | validateAntigravityCallbackUrl, |
| 27 | type AntigravityPendingCallback, | |
| 28 | 30 | } from "./antigravityCallback.ts"; |
| 29 | 31 | import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; |
| 30 | 32 | |
| ... | ... | @@ -43,7 +45,7 @@ interface AuthFlow { |
| 43 | 45 | readonly ownerSessionId: string; |
| 44 | 46 | readonly expiresAtMillis: number; |
| 45 | 47 | state: ProviderAuthState; |
| 46 | pending: AntigravityPendingCallback | undefined; | |
| 48 | pending: AntigravityAuthorizationUrl | undefined; | |
| 47 | 49 | callbackSent: boolean; |
| 48 | 50 | fiber: Fiber.Fiber<void> | undefined; |
| 49 | 51 | forwarding: Fiber.Fiber<void, ProviderSetupError> | undefined; |
| ... | ... | @@ -239,6 +241,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < |
| 239 | 241 | Effect.gen(function* () { |
| 240 | 242 | if (activeFlow !== flow || operation !== "auth") return; |
| 241 | 243 | if (flow.pending) { |
| 244 | if (flow.pending.authorizationUrl === authorization.authorizationUrl) return; | |
| 242 | 245 | return yield* new AcpErrors.AcpTransportError({ |
| 243 | 246 | detail: "Antigravity started more than one Google sign-in request.", |
| 244 | 247 | cause: undefined, |
apps/server/src/provider/Layers/AntigravityProvider.test.ts+22| ... | ... | @@ -13,6 +13,7 @@ import * as Layer from "effect/Layer"; |
| 13 | 13 | import * as Ref from "effect/Ref"; |
| 14 | 14 | import * as Schema from "effect/Schema"; |
| 15 | 15 | import * as Stream from "effect/Stream"; |
| 16 | import * as TestClock from "effect/testing/TestClock"; | |
| 16 | 17 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 17 | 18 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 18 | 19 | |
| ... | ... | @@ -411,6 +412,27 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { |
| 411 | 412 | ), |
| 412 | 413 | ); |
| 413 | 414 | |
| 415 | it.effect("allows a slow packaged runtime health check to finish", () => | |
| 416 | Effect.scoped( | |
| 417 | Effect.gen(function* () { | |
| 418 | const harness = yield* makeHarness(); | |
| 419 | yield* harness.initialize; | |
| 420 | yield* Ref.set(harness.probe, Effect.sleep("20 seconds").pipe(Effect.as(initializeResult))); | |
| 421 | ||
| 422 | const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); | |
| 423 | yield* Effect.yieldNow; | |
| 424 | yield* TestClock.adjust("20 seconds"); | |
| 425 | const snapshot = yield* Fiber.join(refresh); | |
| 426 | ||
| 427 | expect(snapshot).toMatchObject({ | |
| 428 | installed: true, | |
| 429 | status: "warning", | |
| 430 | auth: { status: "unknown" }, | |
| 431 | }); | |
| 432 | }), | |
| 433 | ), | |
| 434 | ); | |
| 435 | ||
| 414 | 436 | it.effect("distinguishes missing executables from a failed installed executable", () => |
| 415 | 437 | Effect.scoped( |
| 416 | 438 | Effect.gen(function* () { |
apps/server/src/provider/Layers/AntigravityProvider.ts+6-2| ... | ... | @@ -32,6 +32,7 @@ import { |
| 32 | 32 | |
| 33 | 33 | const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); |
| 34 | 34 | const MAX_WORKSPACE_SNAPSHOTS = 32; |
| 35 | const HEALTH_CHECK_TIMEOUT = "45 seconds"; | |
| 35 | 36 | const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity."; |
| 36 | 37 | const AUTH_UNCHECKED_MESSAGE = |
| 37 | 38 | "Antigravity is installed. Google account access is not checked yet."; |
| ... | ... | @@ -166,7 +167,10 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 166 | 167 | const checkProvider = Effect.fn("checkAntigravityProvider")(function* () { |
| 167 | 168 | if (!settings.enabled) return yield* getSnapshot; |
| 168 | 169 | const before = yield* SubscriptionRef.get(metadata); |
| 169 | const result = yield* options.probe.pipe(Effect.timeoutOption("15 seconds"), Effect.result); | |
| 170 | const result = yield* options.probe.pipe( | |
| 171 | Effect.timeoutOption(HEALTH_CHECK_TIMEOUT), | |
| 172 | Effect.result, | |
| 173 | ); | |
| 170 | 174 | const initialized = |
| 171 | 175 | Result.isSuccess(result) && Option.isSome(result.success) ? result.success.value : undefined; |
| 172 | 176 | const failure = Result.isFailure(result) ? result.failure : undefined; |
| ... | ... | @@ -180,7 +184,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 180 | 184 | ? "Antigravity is not installed or its executable could not be found." |
| 181 | 185 | : failure |
| 182 | 186 | ? "Antigravity could not complete its local health check." |
| 183 | : "Antigravity did not respond to its local health check within 15 seconds."; | |
| 187 | : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; | |
| 184 | 188 | const supportsTextGeneration = |
| 185 | 189 | initialized !== undefined ? yield* options.supportsTextGeneration : false; |
| 186 | 190 | const updatedAt = DateTime.formatIso(yield* DateTime.now); |
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+3-2| ... | ... | @@ -327,7 +327,7 @@ describe("AcpSessionRuntime", () => { |
| 327 | 327 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 328 | 328 | ); |
| 329 | 329 | |
| 330 | it.effect("drains large stderr output and bounds optional logging chunks", () => | |
| 330 | it.effect("drains large stderr output and keeps auth-sized logging chunks", () => | |
| 331 | 331 | Effect.gen(function* () { |
| 332 | 332 | const lengths: Array<number> = []; |
| 333 | 333 | for (const logStderr of [false, true]) { |
| ... | ... | @@ -348,7 +348,8 @@ describe("AcpSessionRuntime", () => { |
| 348 | 348 | }).pipe(Effect.scoped); |
| 349 | 349 | } |
| 350 | 350 | expect(lengths.length).toBeGreaterThan(0); |
| 351 | expect(Math.max(...lengths)).toBeLessThanOrEqual(8_192); | |
| 351 | expect(Math.max(...lengths)).toBeGreaterThanOrEqual(16_384); | |
| 352 | expect(Math.max(...lengths)).toBeLessThanOrEqual(32_768); | |
| 352 | 353 | }).pipe(Effect.provide(NodeServices.layer)), |
| 353 | 354 | ); |
| 354 | 355 |
apps/server/src/provider/acp/AcpSessionRuntime.ts+2-1| ... | ... | @@ -66,7 +66,8 @@ const defaultSessionLoadTimeout = Duration.seconds(90); |
| 66 | 66 | const defaultSessionLoadReplayIdleGap = Duration.seconds(2); |
| 67 | 67 | const defaultCancelTimeout = Duration.seconds(15); |
| 68 | 68 | const maxStartupMetadataUpdates = 32; |
| 69 | const maxStderrChunkLength = 8_192; | |
| 69 | // Antigravity can emit an accepted 16 KiB Google authorization URL on stderr. | |
| 70 | const maxStderrChunkLength = 32_768; | |
| 70 | 71 | |
| 71 | 72 | export interface AcpSpawnInput { |
| 72 | 73 | readonly command: string; |
apps/server/src/provider/acp/AntigravityAcpSupport.ts+8-2| ... | ... | @@ -19,7 +19,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; |
| 19 | 19 | |
| 20 | 20 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 21 | 21 | import { |
| 22 | drainAntigravityStderr, | |
| 22 | makeAntigravityStderrHandler, | |
| 23 | 23 | makeAntigravityStdoutTransform, |
| 24 | 24 | } from "../antigravityAuthSupport.ts"; |
| 25 | 25 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| ... | ... | @@ -73,7 +73,13 @@ export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")( |
| 73 | 73 | transformStdout: makeAntigravityStdoutTransform( |
| 74 | 74 | input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}, |
| 75 | 75 | ), |
| 76 | onStderr: drainAntigravityStderr, | |
| 76 | ...(input.onAuthorizationUrl | |
| 77 | ? { | |
| 78 | onStderr: makeAntigravityStderrHandler({ | |
| 79 | onAuthorizationUrl: input.onAuthorizationUrl, | |
| 80 | }), | |
| 81 | } | |
| 82 | : {}), | |
| 77 | 83 | transformSessionUpdate: normalizeAntigravitySessionUpdate, |
| 78 | 84 | }).pipe( |
| 79 | 85 | Layer.provide( |
apps/server/src/provider/antigravityAuthSupport.test.ts+56| ... | ... | @@ -17,6 +17,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne |
| 17 | 17 | import * as AcpErrors from "effect-acp/errors"; |
| 18 | 18 | |
| 19 | 19 | import { |
| 20 | ANTIGRAVITY_AUTH_BROWSER_MARKER, | |
| 20 | 21 | ANTIGRAVITY_AUTH_STDOUT_PREFIX, |
| 21 | 22 | ANTIGRAVITY_PERSONAL_AUTH, |
| 22 | 23 | ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, |
| ... | ... | @@ -26,6 +27,7 @@ import { |
| 26 | 27 | antigravityProfileSettings, |
| 27 | 28 | buildAntigravityAcpSpawnInput, |
| 28 | 29 | isAntigravitySignInRequiredError, |
| 30 | makeAntigravityStderrHandler, | |
| 29 | 31 | makeAntigravityStdoutTransform, |
| 30 | 32 | parseAntigravityAuthorizationUrl, |
| 31 | 33 | prepareAntigravityProfile, |
| ... | ... | @@ -388,6 +390,60 @@ describe("Antigravity stdout compatibility", () => { |
| 388 | 390 | ); |
| 389 | 391 | }); |
| 390 | 392 | |
| 393 | describe("Antigravity stderr compatibility", () => { | |
| 394 | it.effect("forwards an accepted browser-helper URL larger than 8 KiB", () => | |
| 395 | Effect.gen(function* () { | |
| 396 | const urls: string[] = []; | |
| 397 | const longAuthorizationUrl = `${authorizationUrl}&scope=${"a".repeat(9_000)}`; | |
| 398 | const handleStderr = makeAntigravityStderrHandler({ | |
| 399 | onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), | |
| 400 | }); | |
| 401 | ||
| 402 | expect(longAuthorizationUrl.length).toBeGreaterThan(8_192); | |
| 403 | yield* handleStderr( | |
| 404 | `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(longAuthorizationUrl)}\n`, | |
| 405 | ); | |
| 406 | ||
| 407 | expect(urls).toEqual([longAuthorizationUrl]); | |
| 408 | }), | |
| 409 | ); | |
| 410 | ||
| 411 | it.effect("forwards a fragmented browser-helper URL without exposing other stderr", () => | |
| 412 | Effect.gen(function* () { | |
| 413 | const urls: string[] = []; | |
| 414 | const markerLine = `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(authorizationUrl)}\n`; | |
| 415 | const handleStderr = makeAntigravityStderrHandler({ | |
| 416 | onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), | |
| 417 | }); | |
| 418 | ||
| 419 | yield* handleStderr(`native log\n${markerLine.slice(0, 12)}`); | |
| 420 | yield* handleStderr(markerLine.slice(12, 70)); | |
| 421 | yield* handleStderr(`${markerLine.slice(70)}another native log\n`); | |
| 422 | ||
| 423 | expect(urls).toEqual([authorizationUrl]); | |
| 424 | }), | |
| 425 | ); | |
| 426 | ||
| 427 | it.effect("ignores malformed and similar browser-helper messages", () => | |
| 428 | Effect.gen(function* () { | |
| 429 | const urls: string[] = []; | |
| 430 | const handleStderr = makeAntigravityStderrHandler({ | |
| 431 | onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), | |
| 432 | }); | |
| 433 | ||
| 434 | yield* handleStderr( | |
| 435 | ` ${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(authorizationUrl)}\n`, | |
| 436 | ); | |
| 437 | yield* handleStderr(`${ANTIGRAVITY_AUTH_BROWSER_MARKER}${authorizationUrl}\n`); | |
| 438 | yield* handleStderr( | |
| 439 | `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson("https://example.com")}\n`, | |
| 440 | ); | |
| 441 | ||
| 442 | expect(urls).toEqual([]); | |
| 443 | }), | |
| 444 | ); | |
| 445 | }); | |
| 446 | ||
| 391 | 447 | it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { |
| 392 | 448 | it.effect("preflights the no-browser helper and creates private directories only", () => |
| 393 | 449 | Effect.gen(function* () { |
apps/server/src/provider/antigravityAuthSupport.ts+32-2| ... | ... | @@ -24,9 +24,12 @@ export const ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE = |
| 24 | 24 | "Sign in to Antigravity in Settings before you continue."; |
| 25 | 25 | |
| 26 | 26 | const maxAuthorizationUrlLength = 16_384; |
| 27 | const maxBrowserHelperLineLength = | |
| 28 | ANTIGRAVITY_AUTH_BROWSER_MARKER.length + maxAuthorizationUrlLength + 2; | |
| 27 | 29 | const maxStdoutLineBytes = 16 * 1024 * 1024; |
| 28 | 30 | const authPrefixBytes = new TextEncoder().encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX); |
| 29 | 31 | const decodeUrl = Schema.decodeUnknownEffect(Schema.URLFromString); |
| 32 | const decodeBrowserHelperUrl = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String)); | |
| 30 | 33 | const ProfileSettingsFile = Schema.Struct({ |
| 31 | 34 | auth: Schema.Struct({ type: Schema.String }), |
| 32 | 35 | gcp: Schema.optional( |
| ... | ... | @@ -455,5 +458,32 @@ export function makeAntigravityStdoutTransform( |
| 455 | 458 | }); |
| 456 | 459 | } |
| 457 | 460 | |
| 458 | /** Upstream stderr can contain OAuth URLs, states, and redirect codes. */ | |
| 459 | export const drainAntigravityStderr = (_text: string): Effect.Effect<void> => Effect.void; | |
| 461 | /** Receives the URL emitted by T3's no-browser helper on stderr. */ | |
| 462 | export function makeAntigravityStderrHandler(input: { | |
| 463 | readonly onAuthorizationUrl: ( | |
| 464 | authorizationUrl: string, | |
| 465 | ) => Effect.Effect<void, AcpErrors.AcpError>; | |
| 466 | }) { | |
| 467 | let pending = ""; | |
| 468 | const handleLine = (line: string) => { | |
| 469 | const message = line.endsWith("\r") ? line.slice(0, -1) : line; | |
| 470 | if ( | |
| 471 | message.length > maxBrowserHelperLineLength || | |
| 472 | !message.startsWith(ANTIGRAVITY_AUTH_BROWSER_MARKER) | |
| 473 | ) { | |
| 474 | return Effect.void; | |
| 475 | } | |
| 476 | return decodeBrowserHelperUrl(message.slice(ANTIGRAVITY_AUTH_BROWSER_MARKER.length)).pipe( | |
| 477 | Effect.flatMap(parseAntigravityAuthorizationUrl), | |
| 478 | Effect.flatMap((request) => input.onAuthorizationUrl(request.authorizationUrl)), | |
| 479 | Effect.ignore, | |
| 480 | ); | |
| 481 | }; | |
| 482 | ||
| 483 | return Effect.fn("antigravityAuthSupport.handleStderr")(function* (text: string) { | |
| 484 | const lines = `${pending}${text}`.split("\n"); | |
| 485 | pending = lines.pop() ?? ""; | |
| 486 | if (pending.length > maxBrowserHelperLineLength) pending = ""; | |
| 487 | yield* Effect.forEach(lines, handleLine, { discard: true }); | |
| 488 | }); | |
| 489 | } |