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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a9a2fe07a403679fa3bf6d10c72c13fadcd31536af653a79b3e6d84e80c10ab
4
- data.tar.gz: b3300594e38799ec7004f4c4fb0e35b051a447c6dd8b66ffb512bb59d7b851b2
3
+ metadata.gz: 803784553a4dbeb3916ccdffef8ebe40626644c18b1ac7fa0e42ff9158d9ae1a
4
+ data.tar.gz: aa985dc5ac60c2f137fe95a9fd5c3c15fd7e8667e7b4f183d49f1e89ba448aa7
5
5
  SHA512:
6
- metadata.gz: 5af9d06699d0361e360ea31dd4bb67ba04d2e711e0324ee9d85ee59914404ef905e173e7b6fd6ef81ede67773784fd4788ccb45fac7399ce57ad1b67d6d094ce
7
- data.tar.gz: 2143f94b230219cae83f8d317f52ba338b282894178edce8129eac5382be76836209fbb29b8dd9e25ac96d3a91a6b93fa326fed4c426f9b2304b9b12d9233482
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 = navigator.platform.toUpperCase().includes("MAC"), t = (e) => !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length > 0), n = (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
- })), o = class extends HTMLElement {
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 top: 0;\n left: 0;\n width: 0;\n height: 0;\n}\n\n:host([hidden]) {\n display: none;\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: absolute;\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-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\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.#n = document.createElement("div"), this.#n.classList.add("backdrop"), this.#n.addEventListener("click", () => this.hide()), this.#r = document.createElement("div"), this.#r.classList.add("specimens"), this.#i = document.createElement("a"), this.#i.classList.add("toggle-button"), this.#i.textContent = "Open CopyTuner", this.#i.addEventListener("click", () => this.#t()), e.append(this.#n, this.#r, this.#i), this.hide();
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 onToggle(e) {
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.reset(), this.#n.hidden = !1;
47
- for (let { element: e, keys: t } of a()) {
48
- let n = this.makeBox(e, t);
49
- n && this.#r.append(n);
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.reset(), this.#n.hidden = !0;
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
- reset() {
56
- this.#r.replaceChildren();
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, t) {
59
- let n = r(e);
60
- if (n === null) return null;
61
- let i = document.createElement("div");
62
- i.classList.add("specimen"), i.style.left = `${n.left}px`, i.style.top = `${n.top}px`, i.style.width = `${n.width}px`, i.style.height = `${n.height}px`;
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
- }, s = class extends HTMLElement {
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
- connectedCallback() {
82
- this.hidden = !0;
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:host([hidden]) {\n display: none;\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);
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", i(this.onSearch.bind(this), 250));
74
+ }), this.#t.addEventListener("input", r(this.onSearch.bind(this), 250));
92
75
  }
93
76
  show() {
94
- this.hidden = !1, this.#t.focus();
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", s), customElements.define("copyray-overlay", o);
140
- var c = () => {
141
- let { url: t, data: n, keysSkipped: r } = window.CopyTuner, i = (e) => window.open(`${t}/blurbs/${e}/edit`), a = document.createElement("copytuner-bar");
142
- document.body.append(a), a.init({
143
- url: t,
144
- data: n,
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 o = document.createElement("copyray-overlay");
149
- o.onOpen = i, document.body.append(o);
150
- let s = () => {
151
- o.show(), a.show();
152
- }, c = () => {
153
- o.hide(), a.hide();
154
- }, l = () => o.isShowing ? c() : s();
155
- o.onToggle = l, window.CopyTuner.toggle = l, document.addEventListener("keydown", (t) => {
156
- if (o.isShowing && ["Escape", "Esc"].includes(t.key)) {
157
- c();
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" ? c() : document.addEventListener("DOMContentLoaded", () => c());
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 = cache
13
- @polling_delay = options[:polling_delay]
14
- @logger = options[:logger]
15
- @command_queue = CopyTunerClient::QueueWithTimeout.new
16
- @mutex = Mutex.new
17
- @thread = nil
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
- if @thread.nil?
23
- @logger.info 'start poller thread'
24
- @thread = Thread.new { poll } or logger.error("Couldn't start poller thread")
25
- end
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
- @command_queue.uniq_push(:stop)
32
- @thread&.join
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
- loop do
51
- cache.sync
52
- logger.flush if logger.respond_to?(:flush)
53
- begin
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
- @logger.info 'stop poller thread'
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