cortad 0.3.1-rc.9 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/lib/app-port.mjs +9 -0
- package/lib/auto-reach.mjs +88 -0
- package/lib/backing.mjs +230 -0
- package/lib/calls.mjs +23 -0
- package/lib/cli.mjs +115 -30
- package/lib/contact-text.mjs +167 -0
- package/lib/data.mjs +16 -22
- package/lib/dissection-text.mjs +232 -0
- package/lib/document.mjs +75 -0
- package/lib/env-secrets.mjs +48 -0
- package/lib/failed-text.mjs +36 -0
- package/lib/fresh.mjs +33 -12
- package/lib/health.mjs +61 -0
- package/lib/held.mjs +67 -0
- package/lib/home.mjs +55 -17
- package/lib/hosted.mjs +201 -0
- package/lib/identities.mjs +1 -1
- package/lib/keeping.mjs +162 -0
- package/lib/ledger.mjs +20 -0
- package/lib/lock.mjs +6 -1
- package/lib/mcp.mjs +52 -18
- package/lib/mint.mjs +179 -38
- package/lib/mongo.mjs +74 -0
- package/lib/numbers-text.mjs +68 -0
- package/lib/pg.mjs +118 -0
- package/lib/proc.mjs +100 -0
- package/lib/proof.mjs +1014 -0
- package/lib/proxy.mjs +184 -0
- package/lib/pyhook/cortad_sites.py +218 -0
- package/lib/pyhook/cortad_tied.py +291 -0
- package/lib/pyhook/sitecustomize.py +1175 -166
- package/lib/qdrant.mjs +47 -0
- package/lib/reach.mjs +223 -0
- package/lib/read-text.mjs +78 -24
- package/lib/redis.mjs +130 -0
- package/lib/register.mjs +36 -27
- package/lib/replay.mjs +464 -34
- package/lib/runner.mjs +85 -0
- package/lib/service.mjs +25 -0
- package/lib/spec.mjs +7 -4
- package/lib/start.mjs +33 -2
- package/lib/stick.mjs +65 -25
- package/lib/stores.mjs +347 -0
- package/lib/switches.mjs +36 -0
- package/lib/tether.mjs +9 -0
- package/lib/text.mjs +314 -179
- package/lib/tied.cjs +217 -0
- package/lib/trace.cjs +386 -514
- package/lib/verbs.mjs +155 -38
- package/lib/wire.cjs +882 -0
- package/lib/words.mjs +2 -0
- package/lib/writes.mjs +2 -2
- package/local.mjs +661 -297
- package/package.json +31 -1
- package/skill/SKILL.md +49 -42
- package/skill/references/results.md +279 -102
package/lib/proxy.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// For an app the hook cannot load into (Go, Ruby, Java, Rust, PHP, Deno, or a Node or Python app
|
|
2
|
+
// that would not take it): what the hook sees from inside the process is seen from outside it, on
|
|
3
|
+
// this machine. Two servers on 127.0.0.1. The front sits before the app's own port: the agent's
|
|
4
|
+
// request and every conversation of a run go through it, and it writes the exchange rows the hook
|
|
5
|
+
// writes. The model proxy is where the app's model settings point (OPENAI_BASE_URL and the others):
|
|
6
|
+
// it hands each call to the provider the app's own settings name and writes the call rows the hook
|
|
7
|
+
// writes. Both write into the same trace file, so the command reads them as it reads the hook's
|
|
8
|
+
// (lib/replay.mjs). The line of code that made a call is not known here. Header values are never
|
|
9
|
+
// written, and nothing goes anywhere the app would not have sent it.
|
|
10
|
+
import { appendFileSync } from "node:fs";
|
|
11
|
+
import { createServer, request } from "node:http";
|
|
12
|
+
import { connect } from "node:net";
|
|
13
|
+
import { once } from "node:events";
|
|
14
|
+
import wire from "./wire.cjs";
|
|
15
|
+
import tied from "./tied.cjs";
|
|
16
|
+
|
|
17
|
+
const { MAX, REPLY_MAX, MODEL_HOST, scrubbed, turnOf, decoded, cookieNames, makeRecorder } = wire;
|
|
18
|
+
|
|
19
|
+
// Where each SDK sends its calls when the app's settings name nowhere else.
|
|
20
|
+
const DEFAULTS = { OPENAI_BASE_URL: "https://api.openai.com/v1", ANTHROPIC_BASE_URL: "https://api.anthropic.com", GOOGLE_GEMINI_BASE_URL: "https://generativelanguage.googleapis.com" };
|
|
21
|
+
// Another setting of the app's that addresses a model: a URL on a model host, or a base URL named for one.
|
|
22
|
+
const MODEL_SETTING = /OPENAI|ANTHROPIC|GEMINI|LLM|OLLAMA|LITELLM|VLLM/i;
|
|
23
|
+
const ADDRESS_SETTING = /BASE|URL|ENDPOINT|HOST/i;
|
|
24
|
+
const SECRET_SETTING = /KEY|TOKEN|SECRET|PASSWORD/i;
|
|
25
|
+
// Said by one connection, never passed on. A provider's reply is passed on decoded, so its encoding goes too.
|
|
26
|
+
const HOP = /^(?:host|connection|keep-alive|transfer-encoding|upgrade|te|trailer|proxy-.*|content-length|accept-encoding)$/i;
|
|
27
|
+
const REPLY_HOP = /^(?:connection|keep-alive|transfer-encoding|content-length|content-encoding)$/i;
|
|
28
|
+
|
|
29
|
+
// The person's words in a request, a form's fields as much as JSON's.
|
|
30
|
+
const asked = (raw) => (/^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)*$/.test(raw) ? [...new URLSearchParams(raw).values()].flatMap(tied.sentencesIn) : tied.sentencesIn(raw));
|
|
31
|
+
// A request whose reply said something of its own, to words the person sent, with no model call begun
|
|
32
|
+
// this long after it: an app that asks its model after the page answers (a job Rails or Celery runs)
|
|
33
|
+
// does so well within it.
|
|
34
|
+
// ponytail: a worker slower than this gets a request said unseen; the wait per app if one is seen.
|
|
35
|
+
const UNSEEN_MS = 3000;
|
|
36
|
+
|
|
37
|
+
const listen = async (server) => { server.listen(0, "127.0.0.1"); await once(server, "listening"); return server.address().port; };
|
|
38
|
+
|
|
39
|
+
// `file`: the trace file the command reads. `target()`: where the app answers now, { host, port };
|
|
40
|
+
// it moves when the app is started again on another port. `onSeen`: the first model call came through.
|
|
41
|
+
// `onUnseen(door)`: a request the app answered in words with no model call begun, said once, as "POST /chat".
|
|
42
|
+
export async function openProxy({ file, rulesFile, target, onSeen = () => {}, onUnseen = () => {} }) {
|
|
43
|
+
const row = (value) => { try { appendFileSync(file, JSON.stringify(scrubbed(value)) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ } };
|
|
44
|
+
const rec = makeRecorder({ row, rulesFile });
|
|
45
|
+
// The requests in flight through the front that a model call can be made for.
|
|
46
|
+
const open = new Set();
|
|
47
|
+
// Model calls begun: a call is counted as it arrives, before the app's reply that it feeds can end.
|
|
48
|
+
let calls = 0, unseen = false;
|
|
49
|
+
const unseenIf = (method, path, sent, reply) => {
|
|
50
|
+
if (calls || unseen || !(reply.status >= 200 && reply.status < 300)) return;
|
|
51
|
+
const words = asked(sent);
|
|
52
|
+
if (!words.length || !tied.sentencesIn(reply.text).some((w) => !words.includes(w))) return;
|
|
53
|
+
setTimeout(() => { if (!calls && !unseen) { unseen = true; onUnseen(`${method} ${path.split("?")[0]}`); } }, UNSEEN_MS).unref();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const front = createServer((req, res) => {
|
|
57
|
+
const { "x-cortad-turn": _turn, ...headers } = req.headers;
|
|
58
|
+
const ctx = /^(?:HEAD|OPTIONS)$/.test(req.method) ? null : rec.requestCtx(req.method, req.url || "/", headers, turnOf(req.headers));
|
|
59
|
+
if (ctx && req.method !== "GET") open.add(ctx);
|
|
60
|
+
const finish = (reply) => {
|
|
61
|
+
if (!ctx || ctx.ended) return;
|
|
62
|
+
open.delete(ctx);
|
|
63
|
+
rec.ended(ctx, reply);
|
|
64
|
+
if (req.method !== "GET") unseenIf(req.method, ctx.path, Buffer.concat(ctx.chunks).toString("utf8"), reply);
|
|
65
|
+
};
|
|
66
|
+
const { host, port } = target();
|
|
67
|
+
// The Host the caller used goes on unchanged: an app that writes its own address from it (a Rails
|
|
68
|
+
// redirect to sign in) then sends the caller back here, where the request is seen.
|
|
69
|
+
const up = request({ host, port, method: req.method, path: req.url, headers });
|
|
70
|
+
if (ctx && req.method !== "GET") req.on("data", (c) => { if (ctx.size < MAX) { ctx.chunks.push(c); ctx.size += c.length; } });
|
|
71
|
+
req.pipe(up);
|
|
72
|
+
res.on("close", () => up.destroy());
|
|
73
|
+
// Nothing answered: the caller sees the connection close, as it would at the app's own port.
|
|
74
|
+
up.on("error", () => { finish({ status: 0, type: "", text: "", cookies: [], cut: true }); res.destroy(); });
|
|
75
|
+
up.on("response", (r) => {
|
|
76
|
+
const got = [];
|
|
77
|
+
let size = 0, writes = 0;
|
|
78
|
+
r.on("data", (c) => { writes += 1; if (size < MAX) { got.push(c); size += c.length; } });
|
|
79
|
+
const text = () => decoded(Buffer.concat(got), r.headers["content-encoding"]);
|
|
80
|
+
r.on("end", () => { if (ctx) rec.tied.replied(ctx, r.statusCode, text); });
|
|
81
|
+
r.on("error", () => res.destroy());
|
|
82
|
+
const done = () => finish({ status: r.statusCode, type: String(r.headers["content-type"] || ""), writes: r.headers["content-length"] ? 1 : writes, text: text(), cookies: cookieNames(r.headers["set-cookie"]), cut: !res.writableFinished });
|
|
83
|
+
res.once("finish", done);
|
|
84
|
+
res.once("close", done);
|
|
85
|
+
res.writeHead(r.statusCode, r.statusMessage, r.rawHeaders);
|
|
86
|
+
r.pipe(res);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
// A socket the page opens (live reload, a chat over a WebSocket) is passed through as it is, unrecorded.
|
|
90
|
+
front.on("upgrade", (req, socket, head) => {
|
|
91
|
+
const { host, port } = target();
|
|
92
|
+
const up = connect({ host, port }, () => {
|
|
93
|
+
const lines = [];
|
|
94
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
|
|
95
|
+
up.write(`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n${lines.join("\r\n")}\r\n\r\n`);
|
|
96
|
+
if (head?.length) up.write(head);
|
|
97
|
+
socket.pipe(up).pipe(socket);
|
|
98
|
+
});
|
|
99
|
+
const end = () => { socket.destroy(); up.destroy(); };
|
|
100
|
+
up.on("error", end);
|
|
101
|
+
socket.on("error", end);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Each upstream the app's settings name, by its place: /u/<n>/<the SDK's own path>.
|
|
105
|
+
const slots = [];
|
|
106
|
+
const relay = async (req, res) => {
|
|
107
|
+
const m = /^\/u\/(\d+)([/?][^]*)?$/.exec(req.url || "");
|
|
108
|
+
const base = m && slots[Number(m[1])];
|
|
109
|
+
if (!base) { res.writeHead(404, { "content-type": "application/json" }); res.end('{"error":{"message":"cortad: no model setting points here"}}'); return; }
|
|
110
|
+
// A listing of models answers nobody; a call that sends a prompt does.
|
|
111
|
+
const metered = !/^(?:GET|HEAD)$/.test(req.method);
|
|
112
|
+
if (metered && ++calls === 1) onSeen();
|
|
113
|
+
const parts = [];
|
|
114
|
+
for await (const c of req) parts.push(c);
|
|
115
|
+
const body = Buffer.concat(parts);
|
|
116
|
+
// Read as chat when the AI SDK sent it in its own format; the provider still gets `body` as sent.
|
|
117
|
+
const sent = wire.asChat(body.toString("utf8"), wire.GATEWAY_MODEL.map((h) => req.headers[h]).find(Boolean));
|
|
118
|
+
const url = new URL(base.replace(/\/+$/, "") + (m[2] ?? ""));
|
|
119
|
+
// ponytail: a call is pinned by the person's words, else to the one request open. Two at once that
|
|
120
|
+
// open with the same or short words ("refund?") leave both calls unpinned and their exchanges
|
|
121
|
+
// unwritten, and a chat sent as a GET (EventSource) is never pinned; never pinned wrong. The
|
|
122
|
+
// request's own context, as the hook has it, is what tells them apart.
|
|
123
|
+
const ctx = metered ? rec.note(undefined, sent, open.size === 1 ? [...open][0] : undefined) : undefined;
|
|
124
|
+
const t0 = Date.now();
|
|
125
|
+
const meter = (status, type, text) => { if (metered) rec.meter({ host: url.host, path: url.pathname, sent, status, type, body: text, ctx, t0 }); };
|
|
126
|
+
// The app stopped reading: the provider stops too, so the app's tokens stop with it.
|
|
127
|
+
const gone = new AbortController();
|
|
128
|
+
res.on("close", () => { if (!res.writableFinished) gone.abort(); });
|
|
129
|
+
let got;
|
|
130
|
+
try {
|
|
131
|
+
got = await fetch(url, { method: req.method, headers: Object.fromEntries(Object.entries(req.headers).filter(([k]) => !HOP.test(k))), body: metered && body.length ? body : undefined, redirect: "manual", signal: gone.signal });
|
|
132
|
+
} catch (e) {
|
|
133
|
+
meter(0, "", "");
|
|
134
|
+
if (gone.signal.aborted) return;
|
|
135
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
136
|
+
res.end(JSON.stringify({ error: { message: `cortad could not reach ${url.host}: ${e.cause?.code ?? e.message}` } }));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (ctx) ctx.heard = true;
|
|
140
|
+
const headers = {};
|
|
141
|
+
for (const [k, v] of got.headers) if (!REPLY_HOP.test(k) && k !== "set-cookie") headers[k] = v;
|
|
142
|
+
const cookies = got.headers.getSetCookie();
|
|
143
|
+
res.writeHead(got.status, cookies.length ? { ...headers, "set-cookie": cookies } : headers);
|
|
144
|
+
const kept = [];
|
|
145
|
+
let size = 0;
|
|
146
|
+
try {
|
|
147
|
+
for await (const c of got.body ?? []) {
|
|
148
|
+
res.write(c);
|
|
149
|
+
if (size < REPLY_MAX) { kept.push(Buffer.from(c)); size += c.length; }
|
|
150
|
+
}
|
|
151
|
+
} catch { /* broken off: what arrived stands */ }
|
|
152
|
+
res.end();
|
|
153
|
+
meter(got.status, got.headers.get("content-type") ?? "", Buffer.concat(kept).toString("utf8"));
|
|
154
|
+
};
|
|
155
|
+
// An app that hangs up while it sends a call ends that call, never this command.
|
|
156
|
+
const model = createServer((req, res) => { relay(req, res).catch(() => res.destroy()); });
|
|
157
|
+
|
|
158
|
+
const frontPort = await listen(front);
|
|
159
|
+
const modelPort = await listen(model);
|
|
160
|
+
row({ hello: "proxy", pid: process.pid });
|
|
161
|
+
const via = (upstream) => {
|
|
162
|
+
if (!slots.includes(upstream)) slots.push(upstream);
|
|
163
|
+
return `http://127.0.0.1:${modelPort}/u/${slots.indexOf(upstream)}`;
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
port: frontPort,
|
|
167
|
+
modelPort,
|
|
168
|
+
// The app's model settings, pointed here: each SDK's own, and any other of the app's that addresses a model.
|
|
169
|
+
env(base) {
|
|
170
|
+
const out = {};
|
|
171
|
+
for (const [name, fallback] of Object.entries(DEFAULTS)) out[name] = via(base[name] || fallback);
|
|
172
|
+
for (const [name, value] of Object.entries(base)) {
|
|
173
|
+
if (name in out || SECRET_SETTING.test(name) || typeof value !== "string") continue;
|
|
174
|
+
let u;
|
|
175
|
+
try { u = new URL(value); } catch { continue; }
|
|
176
|
+
if (/^https?:$/.test(u.protocol) && (MODEL_HOST.test(u.hostname) || (MODEL_SETTING.test(name) && ADDRESS_SETTING.test(name)))) out[name] = via(value);
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
},
|
|
180
|
+
// Whether a model call has begun yet.
|
|
181
|
+
seen: () => calls > 0,
|
|
182
|
+
close: () => { for (const s of [front, model]) { s.closeAllConnections(); s.close(); } },
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Where in the app's own code each part of a reply was written, so a block the reply carried is placed
|
|
2
|
+
# at the line that sent it, not at the model call that ran beside it. A framework's send is never that
|
|
3
|
+
# line: for a streamed reply it is where the generator the framework iterates stopped to hand the chunk
|
|
4
|
+
# over, or, when that generator only passes on what the one it loops over gave it, where that one
|
|
5
|
+
# stopped. One entry per line and event name, with the first bytes written there.
|
|
6
|
+
import dis
|
|
7
|
+
import functools
|
|
8
|
+
import gc
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import types
|
|
12
|
+
|
|
13
|
+
SITES_MAX, TEXT_MAX = 16, 300
|
|
14
|
+
# ponytail: a reply's first thousand writes are traced; a leak later in a longer stream goes unplaced.
|
|
15
|
+
WRITES_MAX = 1000
|
|
16
|
+
# A framework puts a few frames between its send and the loop that iterates the reply, and a few
|
|
17
|
+
# generators between that loop and the app's own; never dozens.
|
|
18
|
+
FRAMES_MAX, DEPTH_MAX, WIDTH_MAX, NODES_MAX = 24, 8, 8, 256
|
|
19
|
+
GENERATORS = (types.GeneratorType, types.AsyncGeneratorType)
|
|
20
|
+
# What an object that holds the reply's iterator is made of: a response's attributes, a partial.
|
|
21
|
+
HOLDERS = (dict, list, tuple, functools.partial)
|
|
22
|
+
SSE_EVENT = re.compile(rb"^event:[ \t]*([\w.:-]{1,40})", re.M)
|
|
23
|
+
JSON_EVENT = re.compile(rb'"(?:type|event)"\s*:\s*"([\w.:-]{1,40})"')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _event(chunk):
|
|
27
|
+
head = chunk[:400]
|
|
28
|
+
m = SSE_EVENT.search(head) or JSON_EVENT.search(head)
|
|
29
|
+
return m.group(1).decode("ascii") if m else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _frame(gen):
|
|
33
|
+
return getattr(gen, "ag_frame", None) or getattr(gen, "gi_frame", None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _live(o):
|
|
37
|
+
return isinstance(o, GENERATORS) and _frame(o) is not None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _iterator(o):
|
|
41
|
+
return hasattr(type(o), "__next__") or hasattr(type(o), "__anext__")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# What a suspended generator holds, its locals and then its stack: the generator's own referents from
|
|
45
|
+
# Python 3.11, its frame's before that.
|
|
46
|
+
def _referents(gen):
|
|
47
|
+
return gc.get_referents(gen) if sys.version_info >= (3, 11) else gc.get_referents(_frame(gen))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# The suspended generators a generator holds in its locals and on its stack.
|
|
51
|
+
def _under(gen):
|
|
52
|
+
try:
|
|
53
|
+
return [g for g in _referents(gen) if _live(g)]
|
|
54
|
+
except Exception:
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _same(v, chunk):
|
|
59
|
+
if isinstance(v, (bytes, bytearray)):
|
|
60
|
+
return v == chunk
|
|
61
|
+
return isinstance(v, str) and len(v) <= len(chunk) and v.encode("utf-8", "replace") == chunk
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _holds(frame, chunk):
|
|
65
|
+
return any(_same(v, chunk) for v in frame.f_locals.values())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# A send wrapper: the frame was called with the message, where the one that wrote it built it.
|
|
69
|
+
def _handed(frame, message):
|
|
70
|
+
code = frame.f_code
|
|
71
|
+
return any(frame.f_locals.get(name) is message for name in code.co_varnames[:code.co_argcount + code.co_kwonlyargcount])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# The local the last loop before `lasti` in `code` gives each item to: the first store after its header.
|
|
75
|
+
@functools.lru_cache(maxsize=512)
|
|
76
|
+
def _loop_target(code, lasti):
|
|
77
|
+
target, header = None, False
|
|
78
|
+
for ins in dis.get_instructions(code):
|
|
79
|
+
if ins.offset >= lasti:
|
|
80
|
+
break
|
|
81
|
+
if ins.opname in ("FOR_ITER", "GET_ANEXT"):
|
|
82
|
+
target, header = None, True
|
|
83
|
+
elif header and ins.opname.startswith("STORE_FAST"):
|
|
84
|
+
target, header = ins.argval[0] if isinstance(ins.argval, tuple) else ins.argval, False
|
|
85
|
+
return target
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# The generator that handed `gen` the chunk it just yielded: the one it delegates to with `yield from`,
|
|
89
|
+
# else, when it yielded the item of the innermost loop it stopped in as the item came, that loop's
|
|
90
|
+
# iterator, which the loop keeps on the stack, the innermost last. A chunk it made from the item is its own.
|
|
91
|
+
def _fed_by(gen, chunk):
|
|
92
|
+
inner, frame = getattr(gen, "gi_yieldfrom", None), _frame(gen)
|
|
93
|
+
if inner is None and _same(frame.f_locals.get(_loop_target(frame.f_code, frame.f_lasti)), chunk):
|
|
94
|
+
mine = {id(v) for v in frame.f_locals.values()}
|
|
95
|
+
inner = next((o for o in reversed(_referents(gen)) if id(o) not in mine and _iterator(o)), None)
|
|
96
|
+
return inner if _live(inner) else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _followed(o):
|
|
100
|
+
return isinstance(o, HOLDERS) or (hasattr(o, "__dict__") and type(o).__module__ != "builtins" and not isinstance(o, (type, types.ModuleType)))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# The live generators reachable from `roots` through the objects that hold them, nearest first.
|
|
104
|
+
def _generators(roots):
|
|
105
|
+
seen, level, found = set(), list(roots), []
|
|
106
|
+
while level and len(seen) < NODES_MAX and not found:
|
|
107
|
+
deeper = []
|
|
108
|
+
for o in level:
|
|
109
|
+
if id(o) in seen:
|
|
110
|
+
continue
|
|
111
|
+
seen.add(id(o))
|
|
112
|
+
if isinstance(o, GENERATORS):
|
|
113
|
+
if _frame(o) is not None:
|
|
114
|
+
found.append(o)
|
|
115
|
+
elif _followed(o):
|
|
116
|
+
deeper.extend(gc.get_referents(o))
|
|
117
|
+
level = deeper
|
|
118
|
+
return found
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Sites:
|
|
122
|
+
# `own_at(frame)` is "file:line" for a frame of the app's own code, else None.
|
|
123
|
+
def __init__(self, own_at):
|
|
124
|
+
self.own_at = own_at
|
|
125
|
+
|
|
126
|
+
# The app's line that gave `chunk` out of the generator `gen` the framework iterates: the first of
|
|
127
|
+
# the app's generators under it, then down the generators that only passed the chunk on.
|
|
128
|
+
def _made_by(self, gen, chunk):
|
|
129
|
+
level = [gen]
|
|
130
|
+
for _ in range(DEPTH_MAX):
|
|
131
|
+
own = next((g for g in level if _frame(g) and self.own_at(_frame(g))), None)
|
|
132
|
+
if own is not None:
|
|
133
|
+
return self._handed_down(own, chunk)
|
|
134
|
+
level = [u for g in level for u in _under(g)][:WIDTH_MAX]
|
|
135
|
+
if not level:
|
|
136
|
+
return None
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
# Down the generators that only handed the chunk on, to the one that made it where it stopped.
|
|
140
|
+
def _handed_down(self, gen, chunk):
|
|
141
|
+
at = self.own_at(_frame(gen))
|
|
142
|
+
for _ in range(DEPTH_MAX):
|
|
143
|
+
gen = _fed_by(gen, chunk)
|
|
144
|
+
if gen is None:
|
|
145
|
+
break
|
|
146
|
+
at = self.own_at(_frame(gen)) or at
|
|
147
|
+
return at
|
|
148
|
+
|
|
149
|
+
# An ASGI body message on its way out, from `frame`, the frame that called send, up to `stop`.
|
|
150
|
+
def sent(self, req, message, frame, stop):
|
|
151
|
+
chunk = bytes(message.get("body") or b"")
|
|
152
|
+
if self._room(req, chunk):
|
|
153
|
+
self._note(req, chunk, self._sender(req, message, chunk, frame, stop))
|
|
154
|
+
|
|
155
|
+
# A chunk a response is sending out of `iterator`, the reply it was made with, as it sends it.
|
|
156
|
+
def made(self, req, iterator, chunk):
|
|
157
|
+
chunk = bytes(chunk)
|
|
158
|
+
if self._room(req, chunk):
|
|
159
|
+
self._note(req, chunk, next(filter(None, (self._made_by(g, chunk) for g in _generators([iterator]))), None))
|
|
160
|
+
|
|
161
|
+
# The app's line that wrote the message: past the send wrappers that were handed it, the frame that
|
|
162
|
+
# sent it, when that is the app's, or an app frame above that holds the bytes. Else the reply's
|
|
163
|
+
# iterator, held by the framework frame that iterates it, which holds the chunk it was given, looked
|
|
164
|
+
# for once. An app frame above the sender that holds neither, such as a middleware's
|
|
165
|
+
# `await self.app(scope, receive, send)`, only called into what wrote it.
|
|
166
|
+
def _sender(self, req, message, chunk, frame, stop):
|
|
167
|
+
gen = req.get("iterated")
|
|
168
|
+
if gen:
|
|
169
|
+
return self._made_by(gen, chunk)
|
|
170
|
+
sender = True
|
|
171
|
+
for _ in range(FRAMES_MAX):
|
|
172
|
+
if frame is None or frame.f_code is stop:
|
|
173
|
+
break
|
|
174
|
+
if not _handed(frame, message):
|
|
175
|
+
at, held = self.own_at(frame), _holds(frame, chunk)
|
|
176
|
+
if at and (sender or held):
|
|
177
|
+
return at
|
|
178
|
+
sender = False
|
|
179
|
+
at = self._first_made(req, list(frame.f_locals.values()), chunk) if gen is None and held else None
|
|
180
|
+
if at:
|
|
181
|
+
return at
|
|
182
|
+
frame = frame.f_back
|
|
183
|
+
if gen is None:
|
|
184
|
+
req["iterated"] = False
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
# The line the app made `chunk` at, in the first generator reachable from a frame's `values` that
|
|
188
|
+
# made it, which is kept as the reply's iterator.
|
|
189
|
+
def _first_made(self, req, values, chunk):
|
|
190
|
+
for g in _generators(values):
|
|
191
|
+
at = self._made_by(g, chunk)
|
|
192
|
+
if at:
|
|
193
|
+
req["iterated"] = g
|
|
194
|
+
return at
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
# A WSGI reply's next item, out of the iterable the app returned.
|
|
198
|
+
def iterated(self, req, iterable, chunk):
|
|
199
|
+
chunk = bytes(chunk)
|
|
200
|
+
if not self._room(req, chunk):
|
|
201
|
+
return
|
|
202
|
+
gen = req.get("iterated")
|
|
203
|
+
if gen is None:
|
|
204
|
+
gen = req["iterated"] = next(iter(_generators([iterable])), False)
|
|
205
|
+
self._note(req, chunk, self._made_by(gen, chunk) if gen else None)
|
|
206
|
+
|
|
207
|
+
def _room(self, req, chunk):
|
|
208
|
+
req["traced"] = req.get("traced", 0) + 1
|
|
209
|
+
return bool(chunk) and req["traced"] <= WRITES_MAX and len(req.setdefault("sites", [])) < SITES_MAX
|
|
210
|
+
|
|
211
|
+
def _note(self, req, chunk, at):
|
|
212
|
+
if not at:
|
|
213
|
+
return
|
|
214
|
+
event = _event(chunk)
|
|
215
|
+
sites = req["sites"]
|
|
216
|
+
if any(s["at"] == at and s.get("event") == event for s in sites):
|
|
217
|
+
return
|
|
218
|
+
sites.append({"at": at, **({"event": event} if event else {}), "text": chunk[:TEXT_MAX].decode("utf-8", "replace")})
|