copy_tuner_client 2.2.1 → 2.4.0
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.
- checksums.yaml +4 -4
- data/CLAUDE.md +10 -0
- data/app/assets/javascripts/copytuner.js +69 -78
- data/docs/poller-startup.md +108 -0
- data/index.html +54 -0
- data/lib/copy_tuner_client/fork_hook.rb +53 -0
- data/lib/copy_tuner_client/poller.rb +87 -23
- data/lib/copy_tuner_client/process_guard.rb +13 -4
- data/lib/copy_tuner_client/queue_with_timeout.rb +8 -2
- data/lib/copy_tuner_client/version.rb +1 -1
- data/spec/copy_tuner_client/fork_hook_spec.rb +171 -0
- data/spec/copy_tuner_client/poller_spec.rb +148 -1
- data/spec/copy_tuner_client/process_guard_spec.rb +8 -0
- data/spec/copy_tuner_client/queue_with_timeout_spec.rb +27 -0
- data/spec/spec_helper.rb +6 -0
- data/src/copyray-overlay.ts +54 -47
- data/src/copytuner-bar.ts +0 -11
- data/src/copytuner-root.ts +31 -0
- data/src/main.ts +22 -12
- data/src/styles.ts +62 -41
- data/src/util.ts +1 -36
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 803784553a4dbeb3916ccdffef8ebe40626644c18b1ac7fa0e42ff9158d9ae1a
|
|
4
|
+
data.tar.gz: aa985dc5ac60c2f137fe95a9fd5c3c15fd7e8667e7b4f183d49f1e89ba448aa7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 321d3cc14cfd86127ca2bbe204fa89cd76f152a9770991ed0d8939f6b7724500b1bd7c92dca63b615e4ae741b2652ef0176f60e4c6342afd0a1c718ae8503ddc
|
|
7
|
+
data.tar.gz: 3860c2738c49a2c7fd4af233ba3f062fda3fff1a6d789a851a1f2973029e4cbc201f6d61898ba05f63bf9f79fd5e19d2b8a40ddf387f58d0699e0475d2d08f8c
|
data/CLAUDE.md
CHANGED
|
@@ -34,6 +34,16 @@ Rails 統合は engine.rb のイニシャライザ経由(ヘルパー/SimpleFo
|
|
|
34
34
|
(vite.config.ts が `src/main.ts` → `app/assets/javascripts/copytuner.js` を出力)。
|
|
35
35
|
- `local_first_key_regexp` — locale を除いたキー対象・lookup 時に作用(ローカル YAML 優先)。
|
|
36
36
|
local_first キーのアップロード抑止は `Cache#[]=` に集約されている。
|
|
37
|
+
- **poller スレッドの fork 対応は `ForkHook`(`Process._fork` に prepend)に集約する**
|
|
38
|
+
(fork の直前に `Poller#stop` で協調的に停止し、fork 後に親子の両方で張り直す。理由: fork 後の子に残る
|
|
39
|
+
Thread オブジェクトの見え方(`alive?` / `join` の結果)はドキュメント化されていない CRuby の実装依存なので、
|
|
40
|
+
pid を記録して差分を見るような後始末には寄せない。子でも張り直すので、Puma の `fork_worker` のように
|
|
41
|
+
worker が worker を fork する構成でもサーバ固有のフックなしで poller が立つ。
|
|
42
|
+
アプリケーションサーバごとのフック(`ProcessGuard#register_*_hook`)を増やす前にここで足りるか確認する)。
|
|
43
|
+
起動方法・モードごとにどのプロセスで poller が起動するかは `docs/poller-startup.md` に実測結果がある。
|
|
44
|
+
- **`Poller#poll` は例外をスレッドの外へ漏らさない**
|
|
45
|
+
(`Poller#stop` は fork 経路から呼ばれ、`Thread#join` はスレッドの例外を再送出するため、漏らすと
|
|
46
|
+
poller の失敗がアプリ側の `fork` を壊す)。
|
|
37
47
|
- **アップロード抑止の新ルールは `Cache#[]=` に足す。`I18nBackend` の書き込み経路(`lookup` / `default` / `store_item`)ごとに個別ガードを足さない**
|
|
38
48
|
(理由: cache への書き込みは全経路が最終的に `Cache#[]=` を通る単一の関門。経路ごとにガードを足すと付け忘れの穴が生まれ、同じチェックが分散して保守負担になる。実際 local_first の抑止は当初 `default` 個別に足したが穴が残り、`Cache#[]=` への集約に作り直した)。
|
|
39
49
|
|
|
@@ -1,69 +1,50 @@
|
|
|
1
1
|
//#region src/styles.ts
|
|
2
|
-
var e =
|
|
3
|
-
let t = e.getBoundingClientRect();
|
|
4
|
-
return {
|
|
5
|
-
top: t.top + (window.pageYOffset - document.documentElement.clientTop),
|
|
6
|
-
left: t.left + (window.pageXOffset - document.documentElement.clientLeft)
|
|
7
|
-
};
|
|
8
|
-
}, r = (e) => {
|
|
9
|
-
if (!t(e)) return null;
|
|
10
|
-
let r = n(e);
|
|
11
|
-
return r.right = r.left + e.offsetWidth, r.bottom = r.top + e.offsetHeight, {
|
|
12
|
-
left: r.left,
|
|
13
|
-
top: r.top,
|
|
14
|
-
width: r.right - r.left,
|
|
15
|
-
height: r.bottom - r.top
|
|
16
|
-
};
|
|
17
|
-
}, i = (e, t) => {
|
|
18
|
-
let n;
|
|
19
|
-
return (...r) => {
|
|
20
|
-
clearTimeout(n), n = setTimeout(() => e(...r), t);
|
|
21
|
-
};
|
|
22
|
-
}, a = () => Array.from(document.querySelectorAll("[data-copyray-key]")).map((e) => ({
|
|
2
|
+
var e = () => Array.from(document.querySelectorAll("[data-copyray-key]")).map((e) => ({
|
|
23
3
|
keys: (e.getAttribute("data-copyray-key") ?? "").split(",").filter(Boolean),
|
|
24
4
|
element: e
|
|
25
|
-
})),
|
|
5
|
+
})), t = class extends HTMLElement {
|
|
26
6
|
#e = () => {};
|
|
27
7
|
#t = () => {};
|
|
28
8
|
#n;
|
|
29
9
|
#r;
|
|
30
|
-
#i;
|
|
10
|
+
#i = /* @__PURE__ */ new Map();
|
|
11
|
+
#a = null;
|
|
31
12
|
constructor() {
|
|
32
13
|
super();
|
|
33
14
|
let e = this.attachShadow({ mode: "open" }), t = document.createElement("style");
|
|
34
|
-
t.textContent = "\n:host {\n position: absolute;\n
|
|
15
|
+
t.textContent = "\n:host {\n position: absolute;\n width: 0;\n height: 0;\n}\n\n.backdrop {\n position: fixed;\n inset: 0;\n background-image: radial-gradient(\n ellipse farthest-corner at center,\n rgba(0, 0, 0, 0.4) 10%,\n rgba(0, 0, 0, 0.8) 100%\n );\n z-index: 9000;\n}\n\n.specimen {\n position: fixed;\n background: rgba(255, 50, 50, 0.1);\n outline: 1px solid rgba(255, 50, 50, 0.8);\n outline-offset: -1px;\n color: #666;\n font-family: 'Helvetica Neue', sans-serif;\n font-size: 13px;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);\n z-index: 2000000000;\n}\n\n.specimen:hover {\n cursor: pointer;\n background: rgba(255, 50, 50, 0.4);\n}\n\n.specimen[hidden] {\n display: none;\n}\n\n.specimen-handle {\n float: left;\n margin: 0 2px 2px 0;\n background: rgba(255, 50, 50, 0.8);\n padding: 0 3px;\n color: #fff;\n font-size: 10px;\n cursor: pointer;\n}\n", e.append(t), this.#n = document.createElement("div"), this.#n.classList.add("backdrop"), this.#n.addEventListener("click", () => this.#t()), this.#r = document.createElement("div"), e.append(this.#n, this.#r);
|
|
35
16
|
}
|
|
36
17
|
set onOpen(e) {
|
|
37
18
|
this.#e = e;
|
|
38
19
|
}
|
|
39
|
-
set
|
|
20
|
+
set onClose(e) {
|
|
40
21
|
this.#t = e;
|
|
41
22
|
}
|
|
42
|
-
get isShowing() {
|
|
43
|
-
return !this.#n.hidden;
|
|
44
|
-
}
|
|
45
23
|
show() {
|
|
46
|
-
this.
|
|
47
|
-
for (let { element:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
24
|
+
this.hide();
|
|
25
|
+
for (let { element: t, keys: n } of e()) this.#i.set(t, this.makeBox(n));
|
|
26
|
+
this.#s(), this.#r.append(...this.#i.values()), document.addEventListener("scroll", this.#o, {
|
|
27
|
+
capture: !0,
|
|
28
|
+
passive: !0
|
|
29
|
+
}), window.addEventListener("resize", this.#o, { passive: !0 });
|
|
51
30
|
}
|
|
52
31
|
hide() {
|
|
53
|
-
this.
|
|
32
|
+
document.removeEventListener("scroll", this.#o, { capture: !0 }), window.removeEventListener("resize", this.#o), this.#a !== null && (cancelAnimationFrame(this.#a), this.#a = null), this.#i.clear(), this.#r.replaceChildren();
|
|
54
33
|
}
|
|
55
|
-
|
|
56
|
-
this.#
|
|
34
|
+
#o = () => {
|
|
35
|
+
this.#a === null && (this.#a = requestAnimationFrame(() => {
|
|
36
|
+
this.#a = null, this.#s();
|
|
37
|
+
}));
|
|
38
|
+
};
|
|
39
|
+
#s() {
|
|
40
|
+
let e = Array.from(this.#i, ([e, t]) => [t, e.getBoundingClientRect()]);
|
|
41
|
+
for (let [t, n] of e) t.hidden = n.width === 0 && n.height === 0, !t.hidden && (t.style.left = `${n.left}px`, t.style.top = `${n.top}px`, t.style.width = `${n.width}px`, t.style.height = `${n.height}px`);
|
|
57
42
|
}
|
|
58
|
-
makeBox(e
|
|
59
|
-
let
|
|
60
|
-
|
|
61
|
-
let
|
|
62
|
-
|
|
63
|
-
let { position: a, top: o, left: s } = getComputedStyle(e);
|
|
64
|
-
a === "fixed" && (i.style.position = "fixed", i.style.top = o, i.style.left = s), i.addEventListener("click", () => this.#e(t[0]));
|
|
65
|
-
for (let e of t) i.append(this.makeLabel(e));
|
|
66
|
-
return i;
|
|
43
|
+
makeBox(e) {
|
|
44
|
+
let t = document.createElement("div");
|
|
45
|
+
t.classList.add("specimen"), t.addEventListener("click", () => this.#e(e[0]));
|
|
46
|
+
for (let n of e) t.append(this.makeLabel(n));
|
|
47
|
+
return t;
|
|
67
48
|
}
|
|
68
49
|
makeLabel(e) {
|
|
69
50
|
let t = document.createElement("div");
|
|
@@ -71,30 +52,29 @@ var e = navigator.platform.toUpperCase().includes("MAC"), t = (e) => !!(e.offset
|
|
|
71
52
|
t.stopPropagation(), this.#e(e);
|
|
72
53
|
}), t;
|
|
73
54
|
}
|
|
74
|
-
},
|
|
55
|
+
}, n = navigator.platform.toUpperCase().includes("MAC"), r = (e, t) => {
|
|
56
|
+
let n;
|
|
57
|
+
return (...r) => {
|
|
58
|
+
clearTimeout(n), n = setTimeout(() => e(...r), t);
|
|
59
|
+
};
|
|
60
|
+
}, i = class extends HTMLElement {
|
|
75
61
|
#e = () => {};
|
|
76
62
|
#t;
|
|
77
63
|
#n;
|
|
78
64
|
constructor() {
|
|
79
65
|
super(), this.attachShadow({ mode: "open" });
|
|
80
66
|
}
|
|
81
|
-
|
|
82
|
-
this
|
|
83
|
-
}
|
|
84
|
-
init({ url: e, data: t, keysSkipped: n, onOpen: r }) {
|
|
85
|
-
this.#e = r;
|
|
67
|
+
init({ url: e, data: t, keysSkipped: n, onOpen: i }) {
|
|
68
|
+
this.#e = i;
|
|
86
69
|
let a = this.shadowRoot, o = document.createElement("style");
|
|
87
|
-
o.textContent = "\n:host {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n height: 40px;\n padding: 0 8px;\n background: #222;\n font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;\n font-weight: 200;\n color: #fff;\n z-index: 2147483647;\n box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.1), inset 0 2px 6px rgba(0, 0, 0, 0.8);\n background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3));\n box-sizing: border-box;\n}\n\n
|
|
70
|
+
o.textContent = "\n:host {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n height: 40px;\n padding: 0 8px;\n background: #222;\n font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;\n font-weight: 200;\n color: #fff;\n z-index: 2147483647;\n box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.1), inset 0 2px 6px rgba(0, 0, 0, 0.8);\n background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3));\n box-sizing: border-box;\n}\n\n.log-menu {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 40px;\n max-height: calc(100vh - 40px);\n background: #222;\n color: #fff;\n overflow-y: auto;\n}\n\n.log-menu[hidden] {\n display: none;\n}\n\n.log-menu tbody td {\n padding: 2px 8px;\n}\n\n.log-menu tbody tr {\n cursor: pointer;\n}\n\n.log-menu tbody tr:hover {\n background: #444;\n}\n\n.log-menu tbody tr[hidden] {\n display: none;\n}\n\n.button {\n position: relative;\n display: inline-block;\n color: #fff;\n margin: 8px 1px;\n height: 24px;\n line-height: 24px;\n padding: 0 8px;\n font-size: 14px;\n cursor: pointer;\n vertical-align: middle;\n background-color: #444;\n background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.2));\n border-radius: 2px;\n box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.2),\n inset 0 0 2px rgba(255, 255, 255, 0.2);\n text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.4);\n text-decoration: none;\n}\n\n.button:hover,\n.button:focus {\n color: #fff;\n text-decoration: none;\n background-color: #555;\n}\n\n.notice {\n display: inline-block;\n margin: 8px;\n font-size: 13px;\n line-height: 24px;\n vertical-align: middle;\n color: #ffd24d;\n}\n\n.search {\n appearance: none;\n border: none;\n border-radius: 2px;\n background-image: linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0));\n box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.2), inset 0 0 2px rgba(0, 0, 0, 0.2);\n padding: 2px 8px;\n margin: 0;\n line-height: 20px;\n vertical-align: middle;\n color: black;\n width: auto;\n height: auto;\n font-size: 14px;\n}\n", a.append(o);
|
|
88
71
|
let s = this.makeButton("CopyTuner", e, "_blank"), c = this.makeButton("Sync", "/copytuner", "_blank"), l = this.makeButton("Translations in this page", "javascript:void(0)");
|
|
89
72
|
this.#t = document.createElement("input"), this.#t.type = "text", this.#t.classList.add("search"), this.#t.placeholder = "search", a.append(s, c, l, this.#t), this.#n = this.makeLogMenu(t), a.append(this.#n), n && this.appendSkippedNotice(), l.addEventListener("click", (e) => {
|
|
90
73
|
e.preventDefault(), this.toggleLogMenu();
|
|
91
|
-
}), this.#t.addEventListener("input",
|
|
74
|
+
}), this.#t.addEventListener("input", r(this.onSearch.bind(this), 250));
|
|
92
75
|
}
|
|
93
76
|
show() {
|
|
94
|
-
this
|
|
95
|
-
}
|
|
96
|
-
hide() {
|
|
97
|
-
this.hidden = !0;
|
|
77
|
+
this.#t.focus();
|
|
98
78
|
}
|
|
99
79
|
makeButton(e, t, n) {
|
|
100
80
|
let r = document.createElement("a");
|
|
@@ -135,30 +115,41 @@ var e = navigator.platform.toUpperCase().includes("MAC"), t = (e) => !!(e.offset
|
|
|
135
115
|
let t = [...this.#n.querySelectorAll("tr")];
|
|
136
116
|
for (let n of t) n.hidden = !(e === "" || [...n.querySelectorAll("td")].some((t) => (t.textContent ?? "").includes(e)));
|
|
137
117
|
}
|
|
118
|
+
}, a = class extends HTMLElement {
|
|
119
|
+
dialog;
|
|
120
|
+
#e = () => {};
|
|
121
|
+
constructor() {
|
|
122
|
+
super();
|
|
123
|
+
let e = this.attachShadow({ mode: "open" }), t = document.createElement("style");
|
|
124
|
+
t.textContent = "\n/* transform / filter / perspective / contain は置かない(理由は OVERLAY_STYLES のコメント参照) */\ndialog {\n position: fixed;\n inset: 0;\n width: 100%;\n height: 100%;\n max-width: none;\n max-height: none;\n margin: 0;\n border: none;\n padding: 0;\n background: transparent;\n color: inherit;\n}\n\ndialog::backdrop {\n background: transparent;\n}\n\n.toggle-button {\n display: block;\n position: fixed;\n left: 0;\n bottom: 0;\n color: white;\n background: black;\n padding: 12px 16px;\n border-radius: 0 10px 0 0;\n opacity: 0;\n transition: opacity 0.6s ease-in-out;\n z-index: 10000;\n font-size: 12px;\n cursor: pointer;\n text-decoration: none;\n}\n\n.toggle-button:hover {\n opacity: 1;\n}\n\n@media screen and (max-width: 480px) {\n .toggle-button {\n display: none;\n }\n}\n", e.append(t), this.dialog = document.createElement("dialog");
|
|
125
|
+
let n = document.createElement("a");
|
|
126
|
+
n.classList.add("toggle-button"), n.textContent = "Open CopyTuner", n.addEventListener("click", () => this.#e()), e.append(this.dialog, n);
|
|
127
|
+
}
|
|
128
|
+
set onToggle(e) {
|
|
129
|
+
this.#e = e;
|
|
130
|
+
}
|
|
138
131
|
};
|
|
139
|
-
customElements.define("copytuner-bar",
|
|
140
|
-
var
|
|
141
|
-
let { url:
|
|
142
|
-
document.body.append(a)
|
|
143
|
-
|
|
144
|
-
|
|
132
|
+
customElements.define("copytuner-bar", i), customElements.define("copyray-overlay", t), customElements.define("copytuner-root", a);
|
|
133
|
+
var o = () => {
|
|
134
|
+
let { url: e, data: t, keysSkipped: r } = window.CopyTuner, i = (t) => window.open(`${e}/blurbs/${t}/edit`), a = document.createElement("copytuner-root");
|
|
135
|
+
document.body.append(a);
|
|
136
|
+
let o = document.createElement("copytuner-bar"), s = document.createElement("copyray-overlay");
|
|
137
|
+
s.onOpen = i, a.dialog.append(s, o), o.init({
|
|
138
|
+
url: e,
|
|
139
|
+
data: t,
|
|
145
140
|
keysSkipped: !!r,
|
|
146
141
|
onOpen: i
|
|
147
142
|
});
|
|
148
|
-
let
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
},
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
(e && t.metaKey || !e && t.ctrlKey) && t.shiftKey && t.key.toLowerCase() === "k" && l();
|
|
161
|
-
}), console && console.log(`Ready to Copyray. Press ${e ? "cmd+shift+k" : "ctrl+shift+k"} to scan your UI.`);
|
|
143
|
+
let c = () => {
|
|
144
|
+
a.dialog.showModal(), s.show(), o.show();
|
|
145
|
+
}, l = () => {
|
|
146
|
+
a.dialog.close(), s.hide();
|
|
147
|
+
}, u = () => a.dialog.open ? l() : c();
|
|
148
|
+
a.onToggle = u, s.onClose = l, window.CopyTuner.toggle = u, a.dialog.addEventListener("close", () => {
|
|
149
|
+
a.dialog.open || s.hide();
|
|
150
|
+
}), document.addEventListener("keydown", (e) => {
|
|
151
|
+
(n && e.metaKey || !n && e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "k" && u();
|
|
152
|
+
}), console && console.log(`Ready to Copyray. Press ${n ? "cmd+shift+k" : "ctrl+shift+k"} to scan your UI.`);
|
|
162
153
|
};
|
|
163
|
-
document.readyState === "complete" || document.readyState !== "loading" ?
|
|
154
|
+
document.readyState === "complete" || document.readyState !== "loading" ? o() : document.addEventListener("DOMContentLoaded", () => o());
|
|
164
155
|
//#endregion
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# poller がどのプロセスで起動するか
|
|
2
|
+
|
|
3
|
+
`Poller` はバックグラウンドスレッドで CopyTuner サーバと同期する。**リクエストを処理するプロセスで
|
|
4
|
+
このスレッドが動いていないと翻訳が更新されない**が、動いていなくてもエラーにはならず、翻訳が古いまま
|
|
5
|
+
になるだけなので気づきにくい。アプリケーションサーバの起動方法とモードによって「どのプロセスがアプリを
|
|
6
|
+
ロードするか」が変わるため、ここに実測結果を残す。
|
|
7
|
+
|
|
8
|
+
Puma 以外(Unicorn / Passenger / delayed_job / good_job)については `ProcessGuard` のフック登録
|
|
9
|
+
メソッドを参照。
|
|
10
|
+
|
|
11
|
+
## 前提: アプリをロードしたプロセスで initializer が走る
|
|
12
|
+
|
|
13
|
+
`CopyTunerClient.configure`(= `Configuration#apply` = `ProcessGuard#start`)は、Rails の
|
|
14
|
+
initializer として **アプリをロードしたプロセスで 1 回だけ**走る。したがって「どのプロセスがアプリを
|
|
15
|
+
ロードするか」が起点になる。
|
|
16
|
+
|
|
17
|
+
## 実測結果
|
|
18
|
+
|
|
19
|
+
検証環境: puma 8.0.2 / Rails 8.1.3.1 / Ruby 4.0.2(2026-09-03 実測)
|
|
20
|
+
|
|
21
|
+
| 起動方法 | モード | アプリをロードするプロセス | poller の起動経路 | master に poller |
|
|
22
|
+
| --- | --- | --- | --- | --- |
|
|
23
|
+
| `rails server` | single | そのプロセス | 非 spawner 経路(`start_polling`) | (単一プロセス) |
|
|
24
|
+
| `rails server` | cluster(preload の有無を問わず) | **master のみ** | master で `start_polling` → `ForkHook` が fork 後に worker で張り直す | 立つ |
|
|
25
|
+
| `puma -C` | single | そのプロセス(`Runner#load_and_bind`) | `Puma::Runner#start_server` への prepend | (単一プロセス) |
|
|
26
|
+
| `puma -C` | cluster + preload | master のみ | 各 worker の `start_server` で prepend したフックが発火 | 立たない |
|
|
27
|
+
| `puma -C` | cluster + preload なし | **各 worker** | `$0` の `'cluster worker'` 判定による分岐 | 立たない(master はアプリをロードしない) |
|
|
28
|
+
|
|
29
|
+
### 判定に使っている値
|
|
30
|
+
|
|
31
|
+
`ProcessGuard#puma_spawner?` は `defined?(Puma::Runner) && $PROGRAM_NAME.include?('puma')` で判定する。
|
|
32
|
+
実測値は次のとおり。
|
|
33
|
+
|
|
34
|
+
| 起動方法 | `$PROGRAM_NAME` | `defined?(Puma)` | `defined?(Puma::Runner)` | `puma_spawner?` |
|
|
35
|
+
| --- | --- | --- | --- | --- |
|
|
36
|
+
| `rails server` | `"bin/rails"` | yes | **no** | false |
|
|
37
|
+
| `puma -C`(master / single) | `".../bin/puma"` | yes | yes | true |
|
|
38
|
+
| `puma -C`(preload なしの worker) | `"puma: cluster worker 0: <master pid> [app]"` | yes | yes | true |
|
|
39
|
+
|
|
40
|
+
`$PROGRAM_NAME` が master と worker で違うのは Puma 側の実装差による。master は
|
|
41
|
+
`launcher.rb` の `Process.setproctitle`(`$0` を変えない)、worker は `cluster/worker.rb` の
|
|
42
|
+
`$0 = title`(変える)を使っている。
|
|
43
|
+
|
|
44
|
+
## 各行の背景
|
|
45
|
+
|
|
46
|
+
### `rails server` は preload 設定に関係なく master でアプリをロードする
|
|
47
|
+
|
|
48
|
+
Rails は Puma を起動する**前に** Rack アプリを組み立てて(`Rails::Server#log_to_stdout` →
|
|
49
|
+
`wrapped_app`)、オブジェクトとして Puma に渡す。そのため `preload_app!` を明示的に切っても worker が
|
|
50
|
+
アプリをロードし直すことはなく、必ず master でロードされる。
|
|
51
|
+
|
|
52
|
+
この構成では `Puma::Runner` が initializer の時点でまだ未定義なので `puma_spawner?` は false になり、
|
|
53
|
+
master で poller が起動する。fork 後の worker には `ForkHook`(`Process._fork` に prepend)が
|
|
54
|
+
引き継ぐ。master にも poller が 1 本残るが、`puma_spawner?` を無理に真にしようとすると起動方法ごとの
|
|
55
|
+
判定を増やすことになるため、余分な 1 本を許容している。
|
|
56
|
+
|
|
57
|
+
### Puma 8 は `workers > 1` のとき preload が既定で有効
|
|
58
|
+
|
|
59
|
+
`Puma::Configuration#set_conditional_default_options` が `preload_app` の既定値を
|
|
60
|
+
`!prune_bundler && workers > 1 && Puma.forkable?` で決めている。非 preload を試すには
|
|
61
|
+
`preload_app!(false)` を明示する必要がある。
|
|
62
|
+
|
|
63
|
+
### 非 preload では `start_server` の中でアプリがロードされる
|
|
64
|
+
|
|
65
|
+
`Runner#start_server` は `Puma::Server.new(app, ...)` を呼び、`Runner#app` が
|
|
66
|
+
`@app ||= @config.app` で遅延ロードする。つまり非 preload の worker では、initializer が走る時点で
|
|
67
|
+
既に `start_server` が実行中であり、**そこで `Puma::Runner` に prepend しても間に合わない**。
|
|
68
|
+
|
|
69
|
+
このため `$0` の `'cluster worker'` 判定は最適化ではなく、この構成で poller を起動する唯一の経路になっている。
|
|
70
|
+
|
|
71
|
+
## 表のとおりにならない例外
|
|
72
|
+
|
|
73
|
+
上の表は「poller のスレッドが起動する経路」であって、起動した poller が動き続けることまでは
|
|
74
|
+
保証しない。`Poller` はスレッドの生死とは別にライフサイクルの意図を持っており、次の場合は
|
|
75
|
+
表の経路を通っても poller が居なくなる。
|
|
76
|
+
|
|
77
|
+
- **`InvalidApiKey`** — API キーが不正だと `poll` が自ら終了し、以後は fork をまたいでも張り直さない
|
|
78
|
+
(張り直しても同じ理由で死ぬだけなので)。ログに `Invalid API key` が出る
|
|
79
|
+
- **起動時に CopyTuner サーバへ到達できない** — `Configuration#apply` の `cache.download` が
|
|
80
|
+
`ConnectionError` を再送出するため、そもそもプロセスが起動に失敗する。Puma の worker では
|
|
81
|
+
`! Unable to start worker` になる
|
|
82
|
+
|
|
83
|
+
再検証の際は、まずこの 2 つに当たっていないかをログで確かめる。
|
|
84
|
+
|
|
85
|
+
## 再検証のしかた
|
|
86
|
+
|
|
87
|
+
Puma や Rails を上げたときにこの表が変わっていないか確かめるには、次の観測点を見るのが早い。
|
|
88
|
+
|
|
89
|
+
1. 最小の Rails アプリを用意し、`config/initializers` で `Process.pid` / `Process.ppid` /
|
|
90
|
+
`$PROGRAM_NAME` / `defined?(Puma::Runner)` を出力する。これで**どのプロセスがアプリをロードしたか**が分かる
|
|
91
|
+
2. 任意のエンドポイントで `CopyTunerClient.poller` の `@thread` を覗き、`alive?` を返す。
|
|
92
|
+
fork 後の worker が親から継承した dead な Thread を持っていると、ここが `false` になる
|
|
93
|
+
3. 翻訳を返すだけの偽 CopyTuner サーバを立て(`draft_blurbs.json` を返し、`draft_blurbs` /
|
|
94
|
+
`deploys` の POST を受ける)、`polling_delay` を数秒にする。起動後にサーバ側の翻訳を書き換え、
|
|
95
|
+
worker が拾うかどうかで実際の同期を確認する
|
|
96
|
+
4. 上の表の 5 構成(`rails server` / `puma -C` × single / cluster+preload / cluster+非 preload)で回す
|
|
97
|
+
|
|
98
|
+
`ProcessGuard` のログ(`Register Puma fork hook` / `Puma would be clustered mode without preload_app` /
|
|
99
|
+
`start poller thread`)をプロセス ID 付きで集計すると、どの経路を通ったかが分かる。
|
|
100
|
+
|
|
101
|
+
## 関連
|
|
102
|
+
|
|
103
|
+
- `CopyTunerClient::ForkHook` — fork をまたいで poller を引き継ぐ。判定が外れて master で poller が
|
|
104
|
+
起動してしまった場合の安全網でもある
|
|
105
|
+
- `CopyTunerClient::Poller` — スレッドの生死とは別に `@running`(ポーリングを継続する意図)と
|
|
106
|
+
`@aborted`(張り直しても同じ理由で死ぬ終わり方をしたか)を持つ。`ForkHook` が fork 後に張り直すか
|
|
107
|
+
どうかは `Poller#stop` の戻り値、すなわちこの意図で決まる(スレッドが生きているかではない)
|
|
108
|
+
- [Ruby における fork と Thread の挙動の調査](https://gist.github.com/shunichi/c236b6a85a46a16c60262047ca299608)
|
data/index.html
CHANGED
|
@@ -6,6 +6,35 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
8
|
<title>CopyTuner Dev</title>
|
|
9
|
+
<style>
|
|
10
|
+
.fixed-banner {
|
|
11
|
+
position: fixed;
|
|
12
|
+
top: 0;
|
|
13
|
+
right: 0;
|
|
14
|
+
padding: 8px 12px;
|
|
15
|
+
background: #ffe9a8;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* width は横スクロール時の left 経路の検証用 */
|
|
19
|
+
.spacer {
|
|
20
|
+
height: 150vh;
|
|
21
|
+
width: 150vw;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* transform を持つ祖先は fixed 要素の containing block になり、viewport 基準からずれるケースの再現用 */
|
|
25
|
+
.transform-ancestor {
|
|
26
|
+
transform: translateZ(0);
|
|
27
|
+
height: 80px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.transformed-fixed {
|
|
31
|
+
position: fixed;
|
|
32
|
+
bottom: 0;
|
|
33
|
+
left: 0;
|
|
34
|
+
padding: 8px 12px;
|
|
35
|
+
background: #a8d8ff;
|
|
36
|
+
}
|
|
37
|
+
</style>
|
|
9
38
|
</head>
|
|
10
39
|
|
|
11
40
|
<body>
|
|
@@ -15,6 +44,23 @@
|
|
|
15
44
|
<li data-copyray-key="projects.index.blurbs">翻訳キー</li>
|
|
16
45
|
<li data-copyray-key="projects.index.members">メンバー数</li>
|
|
17
46
|
</ul>
|
|
47
|
+
|
|
48
|
+
<p class="fixed-banner" data-copyray-key="projects.index.fixed_banner">固定表示のバナー</p>
|
|
49
|
+
|
|
50
|
+
<div class="transform-ancestor">
|
|
51
|
+
<p class="transformed-fixed" data-copyray-key="projects.index.transformed_fixed">transform 祖先を持つ固定要素</p>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<button type="button" id="open-app-modal">アプリのモーダルを開く</button>
|
|
55
|
+
|
|
56
|
+
<dialog id="app-modal">
|
|
57
|
+
<p data-copyray-key="projects.index.modal_body">モーダル内の文言</p>
|
|
58
|
+
<button type="button" id="close-app-modal">閉じる</button>
|
|
59
|
+
</dialog>
|
|
60
|
+
|
|
61
|
+
<div class="spacer"></div>
|
|
62
|
+
|
|
63
|
+
<p data-copyray-key="projects.index.below_spacer">スクロール後に見える文言</p>
|
|
18
64
|
</div>
|
|
19
65
|
<script>
|
|
20
66
|
window.CopyTuner = {
|
|
@@ -23,8 +69,16 @@
|
|
|
23
69
|
'projects.index.locales': '言語',
|
|
24
70
|
'projects.index.blurbs': '翻訳キー',
|
|
25
71
|
'projects.index.members': 'メンバー数',
|
|
72
|
+
'projects.index.fixed_banner': '固定表示のバナー',
|
|
73
|
+
'projects.index.transformed_fixed': 'transform 祖先を持つ固定要素',
|
|
74
|
+
'projects.index.modal_body': 'モーダル内の文言',
|
|
75
|
+
'projects.index.below_spacer': 'スクロール後に見える文言',
|
|
26
76
|
},
|
|
27
77
|
}
|
|
78
|
+
|
|
79
|
+
const appModal = document.getElementById('app-modal')
|
|
80
|
+
document.getElementById('open-app-modal').addEventListener('click', () => appModal.showModal())
|
|
81
|
+
document.getElementById('close-app-modal').addEventListener('click', () => appModal.close())
|
|
28
82
|
</script>
|
|
29
83
|
<script type="module" src="/src/main.ts"></script>
|
|
30
84
|
</body>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module CopyTunerClient
|
|
2
|
+
# fork をまたいで poller スレッドを引き継ぐためのフック。
|
|
3
|
+
# Process._fork に prepend し、fork の直前に poller を協調的に停止して、
|
|
4
|
+
# fork のあと親子それぞれで張り直す。
|
|
5
|
+
module ForkHook
|
|
6
|
+
# Module#prepend は同じモジュールを二重に挿さないので、呼び出し側で登録済みかを見なくてよい
|
|
7
|
+
def self.install
|
|
8
|
+
::Process.singleton_class.prepend(self)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def _fork
|
|
12
|
+
# fork はアプリの任意のタイミングで起きる。Process._fork への prepend は外せないので、
|
|
13
|
+
# configure 前や configuration 差し替え中に NoMethodError でアプリの fork を
|
|
14
|
+
# 壊さないよう nil を許容する
|
|
15
|
+
poller = CopyTunerClient.configuration&.poller
|
|
16
|
+
# スレッドは子プロセスに引き継がれない。fork 後に子へ残る Thread オブジェクトの見え方
|
|
17
|
+
# (alive? / join の結果)はドキュメント化されていない CRuby の実装依存なので、
|
|
18
|
+
# そこに依存した後始末はせず、fork 前に協調的に停止しておく。
|
|
19
|
+
# ここは rescue しない。停止の失敗を握り潰すと、動いている poller ごと fork することになり
|
|
20
|
+
# このフックの目的そのものを裏切る
|
|
21
|
+
restart = poller ? poller.stop : false
|
|
22
|
+
|
|
23
|
+
begin
|
|
24
|
+
super
|
|
25
|
+
ensure
|
|
26
|
+
# ensure は親(super の戻り値 = 子の pid)と子(同 0)の両方を通り、super が失敗した
|
|
27
|
+
# ときも親で復元する。「fork 前に動いていたプロセスは fork 後も動いている」状態を保つ。
|
|
28
|
+
# 子でも張り直すのは、Puma の fork_worker のように worker が worker を fork する
|
|
29
|
+
# 構成でもサーバ固有のフックに頼らず poller を立てるため
|
|
30
|
+
ForkHook.restart_poller(poller) if restart
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Process.singleton_class に prepend されるため、インスタンスメソッドとして定義すると
|
|
35
|
+
# Process 自身にメソッドが生えてしまう。フックの補助はモジュール側の特異メソッドに置く
|
|
36
|
+
#
|
|
37
|
+
# fork 後に例外を漏らすと「子プロセスは生成済みなのに親の fork が例外を投げる」状態になり、
|
|
38
|
+
# 呼び出し側が子の pid を受け取れなくなる。起動の失敗はログに落として fork は成立させる
|
|
39
|
+
def self.restart_poller(poller)
|
|
40
|
+
poller.start
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
log_error("CopyTuner: fork 後の poller 起動に失敗しました: #{e.class}: #{e.message}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ログ出力自体が失敗しても fork は成立させる。ここで例外を漏らすと、失敗を握るために
|
|
46
|
+
# 置いた rescue が逆に fork を壊す
|
|
47
|
+
def self.log_error(message)
|
|
48
|
+
CopyTunerClient.configuration&.logger&.error(message)
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -9,28 +9,60 @@ module CopyTunerClient
|
|
|
9
9
|
# @option options [Logger] :logger where errors should be logged
|
|
10
10
|
# @option options [Fixnum] :polling_delay how long to wait in between requests
|
|
11
11
|
def initialize(cache, options)
|
|
12
|
-
@cache
|
|
13
|
-
@polling_delay
|
|
14
|
-
@logger
|
|
15
|
-
@command_queue
|
|
16
|
-
@mutex
|
|
17
|
-
@thread
|
|
12
|
+
@cache = cache
|
|
13
|
+
@polling_delay = options[:polling_delay]
|
|
14
|
+
@logger = options[:logger]
|
|
15
|
+
@command_queue = CopyTunerClient::QueueWithTimeout.new
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@thread = nil
|
|
18
|
+
@last_synced_at = nil
|
|
19
|
+
# スレッドの生死とは別に、ライフサイクルの意図を持つ。
|
|
20
|
+
# @running: ポーリングを継続する意図があるか(start で true / stop で false)
|
|
21
|
+
# @aborted: 張り直しても同じ理由で死ぬと分かっている終わり方をしたか
|
|
22
|
+
@running = false
|
|
23
|
+
@aborted = false
|
|
18
24
|
end
|
|
19
25
|
|
|
20
26
|
def start
|
|
21
27
|
@mutex.synchronize do
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
@running = true
|
|
29
|
+
# 回復の見込みがない理由で終了しているなら張り直さない。fork のたびに同じ例外で
|
|
30
|
+
# 死ぬスレッドを作り直してログを埋めるだけになる
|
|
31
|
+
next if @aborted
|
|
32
|
+
# fork 後の子は親から dead な Thread オブジェクトを継承するため、nil かどうかだけでは
|
|
33
|
+
# 「動いていない」を判定できない。死んでいるスレッドは張り直す
|
|
34
|
+
next if @thread&.alive?
|
|
35
|
+
|
|
36
|
+
# コマンドキューは世代ごとに作り直し、スレッドに自分のキューを渡す。前の世代宛に
|
|
37
|
+
# 積まれたまま未消費で残った :stop を次の世代が 1 周目で拾って自殺するのを、
|
|
38
|
+
# 「1 つのキューは 1 本のスレッドだけのもの」という不変条件で構造的に防ぐ
|
|
39
|
+
queue = CopyTunerClient::QueueWithTimeout.new
|
|
40
|
+
@command_queue = queue
|
|
41
|
+
@logger.info 'start poller thread'
|
|
42
|
+
@thread = Thread.new { poll(queue) } or logger.error("Couldn't start poller thread")
|
|
26
43
|
end
|
|
27
44
|
end
|
|
28
45
|
|
|
46
|
+
# 戻り値は ForkHook が「fork 後に張り直すか」を決めるのに使う。スレッドの生死ではなく
|
|
47
|
+
# ポーリングを継続する意図があったかを返す。想定外の例外で死んだだけのスレッドは
|
|
48
|
+
# fork 後に張り直したいが、生死で判定すると張り直せなくなる
|
|
49
|
+
#
|
|
50
|
+
# @return [Boolean] ポーリング継続の意図があった(= fork 後に張り直すべき)なら +true+
|
|
29
51
|
def stop
|
|
30
52
|
@mutex.synchronize do
|
|
31
|
-
@
|
|
32
|
-
@
|
|
53
|
+
resumable = @running && !@aborted
|
|
54
|
+
@running = false
|
|
55
|
+
|
|
56
|
+
thread = @thread
|
|
33
57
|
@thread = nil
|
|
58
|
+
# 例外で終わったスレッドは非 nil のまま dead で残る。それに :stop を積んでも誰も
|
|
59
|
+
# pop しないので、生きているときだけ積んで待つ
|
|
60
|
+
if thread&.alive?
|
|
61
|
+
@command_queue.uniq_push(:stop)
|
|
62
|
+
thread.join
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
resumable
|
|
34
66
|
end
|
|
35
67
|
end
|
|
36
68
|
|
|
@@ -46,20 +78,52 @@ module CopyTunerClient
|
|
|
46
78
|
|
|
47
79
|
attr_reader :cache, :logger, :polling_delay
|
|
48
80
|
|
|
49
|
-
def poll
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
command = @command_queue.pop_with_timeout(polling_delay)
|
|
55
|
-
break if command == :stop
|
|
56
|
-
rescue ThreadError
|
|
57
|
-
# timeout
|
|
58
|
-
end
|
|
81
|
+
def poll(queue)
|
|
82
|
+
timeout = remaining_delay
|
|
83
|
+
until wait_for_command(queue, timeout) == :stop
|
|
84
|
+
sync
|
|
85
|
+
timeout = polling_delay
|
|
59
86
|
end
|
|
60
|
-
|
|
87
|
+
logger.info 'stop poller thread'
|
|
61
88
|
rescue InvalidApiKey => e
|
|
89
|
+
# キーが不正なら張り直しても同じ結果になるので、以後の再開を止める
|
|
90
|
+
@aborted = true
|
|
62
91
|
logger.error(e.message)
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
# 例外はスレッドの外へ漏らさない。stop の join は fork の直前にも呼ばれるため、
|
|
94
|
+
# 漏らすと poller の失敗がアプリ側の fork まで巻き添えにする。
|
|
95
|
+
# ここで握ると report_on_exception による stderr 出力も消えるので backtrace を残す
|
|
96
|
+
logger.error("poller thread aborted: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def sync
|
|
100
|
+
cache.sync
|
|
101
|
+
@last_synced_at = monotonic_now
|
|
102
|
+
logger.flush if logger.respond_to?(:flush)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# 最初の待ち時間を「前回 sync からの残り」にすることで、stop / start を繰り返しても
|
|
106
|
+
# sync の間隔を保つ。fork のたびに stop / start されるので、これが無いと worker を
|
|
107
|
+
# fork する数だけ親プロセスで sync(HTTP 往復)が走り直し、その分 join も待たされる。
|
|
108
|
+
# 基点には NTP や手動の時刻変更で巻き戻らない CLOCK_MONOTONIC を使う
|
|
109
|
+
# (Time.now だと時計が後ろに飛んだぶんだけ待ち時間が伸びて同期が止まる)
|
|
110
|
+
def remaining_delay
|
|
111
|
+
return 0 if @last_synced_at.nil?
|
|
112
|
+
|
|
113
|
+
remaining = polling_delay - (monotonic_now - @last_synced_at)
|
|
114
|
+
remaining.negative? ? 0 : remaining
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# 自分の世代のキューを受け取る。@command_queue は start のたびに差し替わるため、
|
|
118
|
+
# 終了処理中の古いスレッドが新しい世代のキューを覗いてしまわないようにする
|
|
119
|
+
def wait_for_command(queue, timeout)
|
|
120
|
+
queue.pop_with_timeout(timeout)
|
|
121
|
+
rescue ThreadError
|
|
122
|
+
nil # timeout
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def monotonic_now
|
|
126
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
63
127
|
end
|
|
64
128
|
end
|
|
65
129
|
end
|