Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,38 @@ circuit.add(
circuit.getCircuitJson()
```

## Routing progress and cancellation

`renderUntilSettled()` waits for asynchronous routing and emits routing progress
on the circuit, including routing inside isolated subcircuits:

```tsx
const controller = new AbortController()
circuit.on("autorouting:progress", (event) => {
console.log(event.phase, event.progress)
})

const rendering = circuit.renderUntilSettled({ signal: controller.signal })
// For example, a Cancel button can call:
// controller.abort(new Error("Canceled by user"))
await rendering
```

For isolated routing, `isolatedSubcircuitPath` identifies the render context from
outermost to innermost subcircuit. Combine it with `subcircuit_id` when tracking
concurrent phases; the IDs inside each isolated circuit remain local to its JSON.

Aborting rejects the render with `signal.reason`, stops the active local router,
and prevents later routing phases from starting. Remote requests and polling are
aborted locally; cancellation does not delete an already submitted server job.
Local cancellation is cooperative: a synchronous solver step must return before
the event loop can process cancellation.

For a manual loop using `circuit.render()`, call
`circuit.cancelRendering(reason)` to stop routing. Cancellation is terminal for
that circuit; create a new `Circuit` to restart. The optional signal is detached
after a completed render, so aborting it later does not cancel that circuit.

## Non-React Usage

```tsx
Expand Down
1 change: 1 addition & 0 deletions lib/IIsolatedCircuit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { RenderPhase } from "lib/components/base-components/Renderable"
import type { RootCircuitEventName } from "lib/events"

export interface IIsolatedCircuit {
readonly _renderAbortSignal: AbortSignal
emit(event: RootCircuitEventName, ...args: any[]): void
on(event: RootCircuitEventName, listener: (...args: any[]) => void): void
isDoneRendering(): boolean
Expand Down
35 changes: 33 additions & 2 deletions lib/IsolatedCircuit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Group } from "./components/primitive-components/Group"
import type { RootCircuitEventName } from "./events"
import { createInstanceFromReactElement } from "./fiber/create-instance-from-react-element"
import { isAssemblyDeviceContainer } from "./components/base-components/is-assembly-device-container"
import { abortableDelay } from "./utils/abortable-delay"

export class IsolatedCircuit {
firstChild: PrimitiveComponent | null = null
Expand Down Expand Up @@ -78,6 +79,12 @@ export class IsolatedCircuit {
projectUrl?: string

_hasRenderedAtleastOnce = false
private readonly _renderAbortController = new AbortController()

/** Captured by routing effects, including effects started with render(). */
get _renderAbortSignal(): AbortSignal {
return this._renderAbortController.signal
}
private _asyncEffectIdsByPhase = new Map<RenderPhase, Set<string>>()
private _asyncEffectPhaseById = new Map<string, RenderPhase>()
private _hasUnrenderedUpdatesFromAsyncEffects = false
Expand Down Expand Up @@ -202,6 +209,7 @@ export class IsolatedCircuit {
}

render() {
this._renderAbortSignal.throwIfAborted()
if (!this.firstChild) {
this._guessRootComponent()
}
Expand All @@ -213,7 +221,29 @@ export class IsolatedCircuit {
this._hasRenderedAtleastOnce = true
}

async renderUntilSettled(): Promise<void> {
/**
* Stop rendering and cancel active autorouting. A canceled circuit cannot be
* resumed; create a new Circuit to start another render.
*/
cancelRendering(reason?: unknown): void {
this._renderAbortController.abort(reason)
}

async renderUntilSettled({
signal,
}: { signal?: AbortSignal } = {}): Promise<void> {
const onAbort = () => this.cancelRendering(signal?.reason)
if (signal?.aborted) onAbort()
signal?.addEventListener("abort", onAbort, { once: true })
try {
await this._renderUntilSettled()
} finally {
signal?.removeEventListener("abort", onAbort)
}
}

private async _renderUntilSettled(): Promise<void> {
this._renderAbortSignal.throwIfAborted()
const existing = this.db.source_project_metadata.list()?.[0]
if (!existing) {
this.db.source_project_metadata.insert({
Expand All @@ -225,10 +255,11 @@ export class IsolatedCircuit {
this.render()

while (!this.isDoneRendering()) {
await new Promise((resolve) => setTimeout(resolve, 100))
await abortableDelay(100, this._renderAbortSignal)
this.render()
}

this._renderAbortSignal.throwIfAborted()
this.emit("renderComplete")
}

Expand Down
11 changes: 7 additions & 4 deletions lib/components/base-components/Renderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,9 +315,12 @@ export abstract class Renderable implements IRenderable {
}
})
.catch((error) => {
console.error(
`Async effect error in ${asyncEffect.phase} "${effectName}":\n${error.stack}`,
)
const signal = this._getRootCircuit()?._renderAbortSignal
if (!signal?.aborted || error !== signal.reason) {
console.error(
`Async effect error in ${asyncEffect.phase} "${effectName}":\n${error.stack}`,
)
}
asyncEffect.complete = true

// HACK: emit to the root circuit component that an async effect has completed
Expand All @@ -328,7 +331,7 @@ export abstract class Renderable implements IRenderable {
effectName,
componentDisplayName: this.getString(),
phase: asyncEffect.phase,
error: error.toString(),
error: String(error),
})
}
})
Expand Down
35 changes: 33 additions & 2 deletions lib/components/primitive-components/Group/Group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { AutorouterError } from "lib/errors/AutorouterError"
import type { AutorouterOptions } from "lib/utils/autorouting/CapacityMeshAutorouter"
import { FanoutAutorouter } from "lib/utils/autorouting/FanoutAutorouter"
import type { GenericLocalAutorouter } from "lib/utils/autorouting/GenericLocalAutorouter"
import { abortableDelay } from "lib/utils/abortable-delay"
import type {
SimpleRouteBounds,
SimpleRouteJson,
Expand Down Expand Up @@ -831,6 +832,8 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>

async _runEffectMakeHttpAutoroutingRequest() {
const { db } = this.root!
const signal = this.root!._renderAbortSignal
signal.throwIfAborted()
const debug = Debug("tscircuit:core:_runEffectMakeHttpAutoroutingRequest")
const props = this._parsedProps as SubcircuitGroupProps

Expand All @@ -846,7 +849,7 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
// @ts-ignore
options.headers["Tscircuit-Core-Version"] = this.root?.getCoreVersion()!
}
return fetch(url, options)
return fetch(url, { ...options, signal })
}

// Only include source and pcb elements
Expand Down Expand Up @@ -888,6 +891,7 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
},
},
).then((r) => r.json())
signal.throwIfAborted()
this._asyncAutoroutingResult = autorouting_result
this._markDirty("PcbTraceRender")
return
Expand All @@ -906,6 +910,7 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
},
},
).then((r) => r.json())
signal.throwIfAborted()
this._asyncAutoroutingResult = autorouting_result
this._markDirty("PcbTraceRender")
return
Expand All @@ -931,6 +936,7 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>

// Poll until job is complete
while (true) {
signal.throwIfAborted()
const { autorouting_job: job } = (await fetchWithDebug(
`${serverUrl}/autorouting/jobs/get`,
{
Expand All @@ -954,6 +960,7 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
finished_at?: string
}
}
signal.throwIfAborted()
if (job.is_finished) {
const { autorouting_job_output } = await fetchWithDebug(
`${serverUrl}/autorouting/jobs/get_output`,
Expand All @@ -966,6 +973,7 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
},
).then((r) => r.json())

signal.throwIfAborted()
this._asyncAutoroutingResult = {
output_pcb_traces: autorouting_job_output.output_pcb_traces,
}
Expand All @@ -986,7 +994,7 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
}

// Wait before polling again
await new Promise((resolve) => setTimeout(resolve, 100))
await abortableDelay(100, signal)
}
}

Expand All @@ -995,6 +1003,8 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
*/
async _runLocalAutorouting() {
const { db } = this.root!
const signal = this.root!._renderAbortSignal
signal.throwIfAborted()
const props = this._parsedProps as SubcircuitGroupProps
const debug = Debug("tscircuit:core:_runLocalAutorouting")
debug(`[${this.getString()}] starting local autorouting`)
Expand Down Expand Up @@ -1300,6 +1310,7 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
phaseStageCount,
},
] of routingStages.entries()) {
signal.throwIfAborted()
if (!usesPreviousStageOutput) {
previousStageOutputSimpleRouteJson = undefined
}
Expand Down Expand Up @@ -1546,6 +1557,7 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
const cachedResult = cacheKey
? await getCachedLocalAutoroutingPhaseResult({ cacheEngine, cacheKey })
: null
signal.throwIfAborted()
const cacheDisabledReason = phaseAutorouterConfig.algorithmFn
? "custom_algorithm"
: !localAutorouterStrategy.cacheable
Expand Down Expand Up @@ -1585,8 +1597,10 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
simpleRouteJson,
})
let autorouter: GenericLocalAutorouter | undefined
let removeAbortListener: (() => void) | undefined

try {
signal.throwIfAborted()
let traces: SimplifiedPcbTrace[]
if (cachedResult) {
debug(`[${this.getString()}] using cached local autorouting result`)
Expand Down Expand Up @@ -1622,15 +1636,25 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
if (!autorouter) {
throw new Error("Failed to create local autorouter")
}
signal.throwIfAborted()
const activeAutorouter = autorouter
const routingPromise = new Promise<SimplifiedPcbTrace[]>(
(resolve, reject) => {
const onAbort = () => {
activeAutorouter.stop()
reject(signal.reason)
}
signal.addEventListener("abort", onAbort, { once: true })
removeAbortListener = () =>
signal.removeEventListener("abort", onAbort)
activeAutorouter.on("complete", (event) => {
if (signal.aborted) return
debug(`[${this.getString()}] local autorouting complete`)
resolve(event.traces)
})

activeAutorouter.on("error", (event) => {
if (signal.aborted) return
debug(
`[${this.getString()}] local autorouting error: ${event.error.message}`,
)
Expand All @@ -1640,6 +1664,7 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
)

activeAutorouter.on("progress", (event) => {
if (signal.aborted) return
this.root?.emit("autorouting:progress", {
subcircuit_id: this.subcircuit_id,
componentDisplayName: this.getString(),
Expand All @@ -1659,6 +1684,7 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
activeAutorouter.start()
traces = await routingPromise
}
signal.throwIfAborted()

let transformedSimpleRouteJson =
autorouter?.getOutputSimpleRouteJson?.()
Expand Down Expand Up @@ -1740,6 +1766,7 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
},
})
}
signal.throwIfAborted()

this.root?.emit("autorouting:end", {
type: "autorouting:end",
Expand All @@ -1755,6 +1782,7 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
...autoroutingMetadata,
simpleRouteJson: outputSimpleRouteJson,
})
signal.throwIfAborted()

// Create source_traces for interconnect ports that were connected via
// off-board paths during routing. This allows DRC to understand that
Expand Down Expand Up @@ -1822,6 +1850,7 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>
)
}
} catch (error) {
if (signal.aborted) throw signal.reason
const { db } = this.root!
// Record the error
db.pcb_autorouting_error.insert({
Expand Down Expand Up @@ -1850,11 +1879,13 @@ export class Group<Props extends z.ZodType<any, any, any> = typeof groupProps>

throw error
} finally {
removeAbortListener?.()
// Ensure the autorouter is stopped
autorouter?.stop()
}
}

signal.throwIfAborted()
// Store the result
this._asyncAutoroutingResult = {
output_pcb_traces: outputTraces as any,
Expand Down
Loading
Loading