/// SPDX-License-Identifier: GPL-3.0-or-later /// SPDX-FileCopyrightText: Copyright © 2022 Tony Garnock-Jones import { Dataspace, IdentitySet, Ref, Bytes, Observe, Turn, Reader, genericEmbeddedTypeDecode, Schemas, GenericEmbedded, Dictionary, fromJS, stringify } from "@syndicate-lang/core"; import { QuasiValue as Q } from "@syndicate-lang/core"; const Trace = Schemas.trace; import * as P from './pict'; import { boot as bootHtml, UIFragment } from "@syndicate-lang/html"; assertion type URLContent(url: string, content: Bytes | false); const SWIMLANE_WIDTH = 100; const LIFELINE_WIDTH = 5; const ACTIVATION_WIDTH = LIFELINE_WIDTH * 2; const VERTICAL_GAP = 20; const POST_TURN_GAP = VERTICAL_GAP * 3; const PLAYGROUND_PADDING = 10; const TAIL_LENGTH = 60; const TERMINATOR_SIZE = new P.Point(LIFELINE_WIDTH * 3, LIFELINE_WIDTH); export function main() { Dataspace.boot(ds => { P.init(); bootHtml(ds); bootFetcher(ds); bootRenderer(ds); }); } function bootFetcher(ds: Ref) { spawn named 'fetcher' { at ds { during Observe({ "pattern": :pattern URLContent(\Q.lit($url: string), \_) }) => spawn named ['fetch', url] { const facet = Turn.activeFacet; facet.preventInertCheck(); fetch(url) .then(response => { if (!response.ok) { console.log('unsuccessful request', url, response); facet.turn(() => { assert URLContent(url, false); }); } else { response.arrayBuffer().then(bs => facet.turn(() => { assert URLContent(url, Bytes.from(bs)); })); } }) .catch(error => facet.turn(() => { console.error('failed request', url, error); assert URLContent(url, false); })); } } } } function bootRenderer(ds: Ref) { spawn named 'renderer' { const r = new Renderer(ds); at ds { const traceUrl = 'd2'; during URLContent(traceUrl, false) => { alert("Couldn't fetch trace file: " + JSON.stringify(traceUrl)); } during URLContent(traceUrl, $bs: Bytes) => { const vs = new Reader(bs.fromUtf8(), { embeddedDecode: genericEmbeddedTypeDecode, }).readToEnd(); const f = Turn.activeFacet; // let count = 0; function loop(n: number) { // if (count >= 100) return; if (n > 0) { const v = vs.shift(); if (v !== void 0) { r.addTraceEntry(Trace.asTraceEntry(v)); // count++; loop(n - 1); } } else { setTimeout(() => f.turn(() => loop(1)), 10); // r.container.scrollLeft = // (r.playground.width.baseVal.value - r.container.getBoundingClientRect().width) // / 2; } } loop(0); } } } } function indented(x: any): string { return stringify(x, { indent: 2}); } function prettyFacetPath(p: Schemas.trace.FacetId[]): string { return p.map(i => stringify(i)).join(':'); } function reindent(s: string, amount: number, firstAmount: number = amount): string { const indent = ' '.repeat(amount); return s.split('\n').map((line, i) => { if (i === 0) { return ' '.repeat(firstAmount) + line; } else { return indent + line; } }).join('\n'); } function prettyName(n: Schemas.trace.Name): string { switch (n._variant) { case "named": return indented(n.name); case "anonymous": return '(anonymous)'; } } function prettyTarget(e: Schemas.trace.Target): string { const oid = typeof e.oid === 'number' ? ('0000000000000000' + e.oid.toString(16)).slice(-16) : stringify(e.oid); return `${stringify(e.actor)}/${stringify(e.facet)}:${oid}`; } function prettyAssertionDescription(a: Schemas.trace.AssertionDescription): string { switch (a._variant) { case "value": return indented(a.value); case "opaque": return `⌜${stringify(a.description)}⌝`; } } class Swimlane { readonly actorId: Schemas.trace.ActorId; actorName: Schemas.trace.Name = Trace.Name.anonymous(); readonly renderer: Renderer; readonly laneNumber: number; readonly startY: number; readonly entries: Array> = []; readonly lifeline = P.rect([LIFELINE_WIDTH, 0], {"class": "lifeline"}); tail: P.BoundedPict | null = P.rect([LIFELINE_WIDTH, TAIL_LENGTH], {"class": "lifeline-tail"}); constructor (actorId: Schemas.trace.ActorId, laneNumber: number, renderer: Renderer) { this.actorId = actorId; this.renderer = renderer; this.laneNumber = laneNumber; this.startY = renderer.ypos; this.lifeline.node.appendChild(P.svg.tag(SVGTitleElement)``); this.lifelineTooltipAppend(`Actor ID: ${indented(this.actorId)}`); this.lifeline.updateBounds(b => b.withTopMid([this.midX, this.startY])); this.updateLifelineHeight(); this.renderer.underlay.appendChild(this.lifeline.node); this.renderer.underlay.appendChild(this.tail!.node); } updateLifelineHeight() { this.lifeline.updateBounds(b => b.withHeight(this.renderer.ypos - b.top)); this.tail?.updateBounds(b => b.withTopMid([this.midX, this.renderer.ypos])); } lifelineTooltipAppend(s: string) { const title = this.lifeline.node.querySelector('title')!; title.appendChild(document.createTextNode(s)); } get midX(): number { return this.laneNumber * SWIMLANE_WIDTH; } get label(): string { return `${prettyName(this.actorName)}[${stringify(this.actorId)}]`; } addEntry(klass: string | null, entry: P.BoundedPict): P.BoundedPict { this.entries.push(entry); if (klass !== null) { const pin = P.circle(2, {"class": "pin"}); pin.updateBounds(b => b.withTopMid([this.midX, this.renderer.ypos])); this.renderer.advance(pin.bounds.height); this.renderer.overlay.appendChild(pin.node); } const n = klass === null ? entry : P.placard(entry, {"class": klass}); n.updateBounds(b => b.withTopMid([this.midX, this.renderer.ypos])); this.renderer.overlay.appendChild(n.node); this.renderer.advance(n.bounds.height); this.updateLifelineHeight(); this.renderer.adjustViewport(); return n; } finish() { this.renderer.underlay.removeChild(this.tail!.node); this.tail = null; this.renderer.releaseSwimlane(this.laneNumber); } } class Renderer { ds: Ref; readonly container: HTMLDivElement = document.getElementById('container')! as any; readonly playground: SVGSVGElement = document.getElementById('playground')! as any; readonly underlay: SVGGElement = this.playground.getElementById('underlay')! as any; readonly overlay: SVGGElement = this.playground.getElementById('overlay')! as any; readonly swimlanes = new Dictionary(); readonly busyLanes = new IdentitySet(); readonly turnMap = new Dictionary(); readonly handleMap = new Dictionary>(); ypos = 0; constructor (ds: Ref) { this.ds = ds; this.adjustViewport(); at this.ds { on asserted UIFragment($_fragmentId, $_newSelector, $_newHtml, $_newOrderBy) => { this.adjustViewport(); } } } adjustViewport() { const bbox = this.playground.getBBox(); this.playground.viewBox.baseVal.x = bbox.x - PLAYGROUND_PADDING; this.playground.viewBox.baseVal.y = bbox.y - PLAYGROUND_PADDING; this.playground.viewBox.baseVal.width = bbox.width + PLAYGROUND_PADDING * 2; this.playground.viewBox.baseVal.height = bbox.height + PLAYGROUND_PADDING * 2; this.playground.width.baseVal.value = bbox.width + PLAYGROUND_PADDING * 2; this.playground.height.baseVal.value = bbox.height + PLAYGROUND_PADDING * 2; } allocateSwimlane(): number { let i = 0; while (this.busyLanes.has(i)) i++; this.busyLanes.add(i); return i; } releaseSwimlane(n: number) { this.busyLanes.delete(n); } swimlaneFor(actorId: Schemas.trace.ActorId): Swimlane { let k = fromJS(actorId); let s = this.swimlanes.get(k); if (s === void 0) { this.swimlanes.set(k, s = new Swimlane(actorId, this.allocateSwimlane(), this)); } return s; } advance(delta: number) { this.ypos += delta; } connect(ps: P.Point, pe: P.Point) { if (pe.x == ps.x) { const pullMagnitude = pe.sub(ps).scale(0.1).length; const pullAngle = Math.PI; const pull = P.Point.rTheta(pullMagnitude + 30, pullAngle); // TODO: compute marker size from the actual definition const arrowheadSpace = P.Point.rTheta(15 - 3.75, pullAngle); this.overlay.appendChild( P.cubicBezier(ps, ps.add(pull), pe.add(pull), pe.add(arrowheadSpace), {"class": "arrow"}).node); } else { const pullMagnitude = pe.sub(ps).scale(0.1).length; const pullAngle = pe.x > ps.x ? Math.PI/4 : 3*Math.PI/4; const pull = P.Point.rTheta(pullMagnitude, pullAngle); // TODO: compute marker size from the actual definition const arrowheadSpace = P.Point.rTheta(15 - 3.75, pullAngle); const pe1 = pe.sub(arrowheadSpace); this.overlay.appendChild( P.cubicBezier(ps, ps.add(pull), pe1.sub(pull), pe1, {"class": "arrow"}).node); } } rewindTo(pos: number, f: () => R): R { const saved = this.ypos; this.ypos = pos; try { return f(); } finally { this.ypos = Math.max(saved, this.ypos); } } prettyTurnEvent(t: Schemas.trace.TurnEvent): string { switch (t._variant) { case "assert": this.handleMap.set(t.handle, t.assertion); return `assert H${t.handle} ↔ ${prettyAssertionDescription(t.assertion)}`; case "retract": { const assertion = this.handleMap.get(t.handle) ?? Trace.AssertionDescription.opaque(Symbol.for('???')); return `retract H${t.handle} ↔ ${prettyAssertionDescription(assertion)}`; } case "message": return `message ${prettyAssertionDescription(t.body)}`; case "sync": return `sync ${prettyTarget(t.peer)}`; case "breakLink": return `link broken`; } } addTraceEntry(entry: Schemas.trace.TraceEntry) { switch (entry.item._variant) { case "start": { this.advance(LIFELINE_WIDTH); const swimlane = this.swimlaneFor(entry.actor); swimlane.addEntry(null, P.rect(TERMINATOR_SIZE, {"class": "terminator"})); this.advance(LIFELINE_WIDTH); swimlane.actorName = entry.item.actorName; swimlane.addEntry("start", P.text(swimlane.label)); swimlane.lifelineTooltipAppend(`\nActor name: ${prettyName(entry.item.actorName)}`); break; } case "turn": { const turn = entry.item.value; const swimlane = this.swimlaneFor(entry.actor); const activation = P.rect([ACTIVATION_WIDTH, 0], {"class": "activation"}); this.advance(LIFELINE_WIDTH); activation.updateBounds(b => b.withTopMid([swimlane.midX, this.ypos])); let causingTurnId: Schemas.trace.TurnId | null = null; switch (turn.cause._variant) { case "turn": causingTurnId = turn.cause.id; break; case "delay": causingTurnId = turn.cause.causingTurn; break; default: break; } const cause = (causingTurnId !== null) ? this.turnMap.get(fromJS(causingTurnId))! : void 0; if (cause !== void 0) { this.connect(cause.point, new P.Point(swimlane.midX, this.ypos)); } let triggerEvent: Schemas.trace.TargetedTurnEvent | null = null; this.advance(LIFELINE_WIDTH); swimlane.addEntry("turn-start", P.text( indented(turn.id) + ': ' + indented(turn.cause))); turn.actions.forEach(a => { let entryClass: string; let label: string; let tooltip: string = `Turn ${stringify(turn.id)} in ${swimlane.label}`; if (cause !== void 0) { tooltip += `\n caused by ${stringify(causingTurnId!)} in ${cause.swimlane.label}`; } if (a._variant !== "dequeue" && a._variant !== "dequeueInternal" && triggerEvent !== null) { tooltip += `\n while processing ${prettyTarget(triggerEvent.target)} ← ${reindent(this.prettyTurnEvent(triggerEvent.detail), 4, 0)}`; } let isInternal = false; switch (a._variant) { case "dequeueInternal": isInternal = true; /* FALL THROUGH */ case "dequeue": entryClass = "event"; label = `received for ${prettyTarget(a.event.target)}:\n${reindent(this.prettyTurnEvent(a.event.detail), 2)}`; triggerEvent = a.event; break; case "enqueueInternal": isInternal = true; /* FALL THROUGH */ case "enqueue": entryClass = "action"; label = `sending to ${prettyTarget(a.event.target)}:\n${reindent(this.prettyTurnEvent(a.event.detail), 2)}`; break; case "link": { const other = this.swimlaneFor(a.childActor); entryClass = "action"; label = `linked to ${other.label}`; break; } case "spawn": { const other = this.swimlaneFor(a.id); entryClass = "action"; label = `spawn ${other.label}`; break; } case "facetStart": entryClass = "internal"; label = `start ${prettyFacetPath(a.path)}`; break; case "facetStop": entryClass = "internal"; label = `stop ${prettyFacetPath(a.path)}`; switch (a.reason._variant) { case "explicitAction": label += " (explicit action)"; break; case "parentStopping": label += " (parent facet stopping)"; break; case "inert": label += " (facet is inert)"; break; case "actorStopping": label += " (actor terminating)"; break; default: label += '\n' + indented(a.reason); break; } break; case "linkedTaskStart": entryClass = "start"; label = indented(a); break; } this.advance(LIFELINE_WIDTH); const fPos = this.ypos; const e = swimlane.addEntry(entryClass, P.text(label)); if (tooltip !== null) { e.node.appendChild(P.svg.tag(SVGTitleElement)`${tooltip}`); } switch (a._variant) { case "link": { const other = this.swimlaneFor(a.childActor); const f = this.rewindTo(fPos, () => other.addEntry(entryClass, P.text(`linked to ${swimlane.label}`))); // TODO: compute marker size from the actual definition const arrowheadSpace = new P.Point( (15 - 3.75) * (other.midX > swimlane.midX ? -1 : 1), 0); if (e.bounds.height < f.bounds.height) { e.updateBounds(b => b.withMidY(f.bounds.midY)); } else { f.updateBounds(b => b.withMidY(e.bounds.midY)); } const ps = other.midX > swimlane.midX ? e.bounds.midRight.sub(arrowheadSpace) : e.bounds.midLeft.sub(arrowheadSpace); const pe = other.midX > swimlane.midX ? f.bounds.midLeft.add(arrowheadSpace) : f.bounds.midRight.sub(arrowheadSpace); this.overlay.appendChild(P.line(ps, pe, {"class": "doublearrow"}).node); break; } case "spawn": { if (!a.link) { const other = this.swimlaneFor(a.id); this.connect(new P.Point(swimlane.midX, this.ypos), new P.Point(other.midX, this.ypos)); } break; } default: break; } }); this.advance(LIFELINE_WIDTH); this.turnMap.set(fromJS(turn.id), { point: new P.Point(swimlane.midX, this.ypos), swimlane, }); activation.updateBounds(b => b.withHeight(this.ypos - b.top)); this.underlay.appendChild(activation.node); this.advance(POST_TURN_GAP - VERTICAL_GAP); swimlane.updateLifelineHeight(); break; } case "stop": { this.advance(VERTICAL_GAP); const swimlane = this.swimlaneFor(entry.actor); switch (entry.item.status._variant) { case "ok": swimlane.addEntry("stop-ok", P.text("✓")); break; case "Error": swimlane.addEntry("stop-error", P.text( "ERROR: " + indented(entry.item.status.value), {"class": "stop-error"})); break; } this.advance(LIFELINE_WIDTH); swimlane.addEntry(null, P.rect(TERMINATOR_SIZE, {"class": "terminator"})); this.advance(LIFELINE_WIDTH); swimlane.finish(); break; } } } }